/**
 * gas_data_feed.gs  — Google Apps Script 버전
 *
 * Phase 2: GAS에서 Naver Finance를 직접 호출해 데이터 수집.
 * EUC-KR 인코딩을 GAS 네이티브로 처리 (iconv 불필요).
 *
 * 배포 방법:
 *   1. script.google.com → 새 프로젝트
 *   2. 이 파일 붙여넣기
 *   3. 트리거 설정: runDataFeed → 시간 기반 → 매일 → 16:30~17:30
 *
 * 실행 시간 전략 (GAS 6분 제한):
 *   - data_feed: 보유 10종목만 → ~30초
 *   - sector_flow: 분리된 섹터×3종목 → ~3분
 *   - macro/unified: 단순 집계 → ~30초
 *   - core_satellite(100종목): 별도 트리거, 청크 분할 실행
 *
 * 하네스 통합:
 *   - buildHarnessContext_()와 관련 헬퍼는 이 파일에 직접 포함된다.
 *   - 별도 하네스 파일 없이 이 파일 하나만 배포해도 된다.
 */

const SPREADSHEET_ID = "1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM";
const SCHEMA_VERSION = "2026-05-15-qg2";
const TICKERS_BASE = [
  { code: "005930", name: "삼성전자" },
  { code: "000660", name: "SK하이닉스" },
  { code: "000270", name: "기아" },
  { code: "091160", name: "KODEX 반도체" },
  { code: "463250", name: "TIGER K방산&우주" },
  { code: "064350", name: "현대로템" },
  { code: "012450", name: "한화에어로스페이스" },
  { code: "117700", name: "KODEX 건설" },
  { code: "028050", name: "삼성E&A" },
  { code: "454320", name: "HANARO CAPEX설비투자iSelect" },
  { code: "010120", name: "LS ELECTRIC" },
  { code: "0117V0", name: "TIGER AI전력기기" },
  { code: "491820", name: "HANARO 전력설비투자" },
  { code: "494670", name: "TIGER 조선TOP10" },
  { code: "471990", name: "KODEX AI반도체핵심장비" },
  { code: "434730", name: "HANARO 원자력iSelect" },
  { code: "0111J0", name: "HANARO 증권고배당TOP3플러스" },
  { code: "307520", name: "TIGER 지주회사" },
  { code: "0190C0", name: "RISE 현대차고정피지컬AI" },
  { code: "011070", name: "LG이노텍" },
  { code: "010620", name: "현대미포" },
  { code: "121600", name: "나노신소재" },
];

// TICKERS 우선순위: TICKERS_BASE → account_snapshot 보유종목 → watch_tickers_override 수동 추가.
// account_snapshot에 보유수량(qty > 0)이 있는 종목은 TICKERS_BASE에 없어도 자동 포함된다.
function getActiveTickers_() {
  let tickers = TICKERS_BASE.slice();
  const existingCodes = new Set(tickers.map(t => t.code));

  // ── 1. account_snapshot 자동 동기 ─────────────────────────────────────────
  // parse_status=CAPTURE_READ_OK + holding_quantity > 0 인 KR 종목을 자동 포함.
  // 미국 종목(GOOGL/MSFT 등, 순 알파벳 코드)은 Naver Finance 조회 불가 → skip.
  // 소수주(qty < 1)도 동일 종목코드가 이미 추가됐으면 중복 추가 없음.
  try {
    const ss = getSpreadsheet_();
    const snapSh = ss.getSheetByName("account_snapshot");
    if (snapSh) {
      const snapData = snapSh.getDataRange().getValues();
      // account_snapshot은 row 1(index 0) = 안내, row 2(index 1) = 헤더
      const headerRowIdx = snapData.length >= 2 ? 1 : 0;
      const hdr = snapData[headerRowIdx].map(h => String(h).trim());
      const codeIdx  = hdr.indexOf("ticker");
      const nameIdx  = hdr.indexOf("name");
      const qtyIdx   = hdr.indexOf("holding_quantity");
      const parseIdx = hdr.indexOf("parse_status");
      const ptIdx    = hdr.indexOf("position_type");
      if (codeIdx >= 0) {
        for (let i = headerRowIdx + 1; i < snapData.length; i++) {
          const row     = snapData[i];
          const rawCode = String(row[codeIdx] || "").trim();
          if (!rawCode) continue;
          // 미국 종목 skip: GOOGL, MSFT, NVDA 등 순수 알파벳은 Naver 조회 불가
          if (/^[A-Za-z]+$/.test(rawCode)) {
            Logger.log("[TICKERS_SNAPSHOT] US종목 skip: " + rawCode);
            continue;
          }
          const normCode = normalizeTickerCode(rawCode);
          if (!normCode) continue;
          if (parseIdx >= 0) {
            const ps = String(row[parseIdx] || "").trim().toUpperCase();
            if (ps && ps !== "CAPTURE_READ_OK") continue;
          }
          const qty = parseFloat(row[qtyIdx] ?? 0) || 0;
          if (qty <= 0) continue;
          if (!existingCodes.has(normCode)) {
            const name = nameIdx >= 0 ? String(row[nameIdx] || normCode).trim() : normCode;
            tickers.push({ code: normCode, name: name });
            existingCodes.add(normCode);
            Logger.log("[TICKERS_SNAPSHOT] 자동 추가: " + normCode + " (" + name + ") qty=" + qty);
          }
        }
      }
    }
  } catch (e) {
    Logger.log("[TICKERS_SNAPSHOT][WARN] account_snapshot 읽기 실패: " + e.message);
  }

  // ── 2. watch_tickers_override 수동 추가 (settings 탭) ──────────────────────
  // 형식: "코드1:이름1,코드2:이름2" — 위 두 소스에 없는 종목을 수동 추가할 때 사용.
  try {
    const ss = getSpreadsheet_();
    const sh = ss.getSheetByName("settings");
    if (sh) {
      const data = sh.getDataRange().getValues();
      for (let i = 0; i < data.length; i++) {
        if (String(data[i][0] || "").trim() !== "watch_tickers_override") continue;
        const raw = String(data[i][1] || "").trim();
        if (!raw) break;
        raw.split(",").forEach(entry => {
          const [code, name] = entry.trim().split(":").map(s => s.trim());
          const normCode = normalizeTickerCode(code || "");
          if (normCode && !existingCodes.has(normCode)) {
            tickers.push({ code: normCode, name: name || normCode });
            existingCodes.add(normCode);
            Logger.log("[TICKERS_OVERRIDE] 수동 추가: " + normCode + " (" + (name || normCode) + ")");
          }
        });
        break;
      }
    }
  } catch (e) {
    Logger.log("[TICKERS_OVERRIDE][WARN] settings 읽기 실패: " + e.message);
  }

  Logger.log("[getActiveTickers_] 최종 종목 수: " + tickers.length
    + " (base=" + TICKERS_BASE.length + " total=" + tickers.length + ")");
  return tickers;
}

// 하위 호환: 기존 코드가 TICKERS를 직접 참조하는 경우를 위해 별칭 유지.
// runDataFeed()는 getActiveTickers_()를 호출해 동적 목록을 사용.
const TICKERS = TICKERS_BASE;

// 종목 → 섹터 매핑 (sector_flow의 Sector_Rank → C5 daily_leader_scan에 사용)
const TICKER_SECTOR_MAP = {
  "005930": "반도체", "000660": "반도체", "042700": "반도체",
  "010120": "AI전력", "267260": "AI전력", "006260": "AI전력",
  "012450": "방산",   "079550": "방산",   "047810": "방산",   "064350": "방산",
  "329180": "조선",   "042660": "조선",   "009540": "조선",
  "028050": "플랜트/EPC","000720": "건설","006360": "건설",
  "005380": "자동차", "000270": "자동차", "012330": "자동차",
  "105560": "은행","055550": "은행","086790": "은행","316140": "은행","024110": "은행",
  "071050": "증권","006800": "증권","005940": "증권","016360": "증권","039490": "증권",
  "180640": "지주회사","267250": "지주회사","034730": "지주회사","000150": "지주회사","005490": "지주회사",
  "003550": "지주회사","006260": "지주회사","078930": "지주회사","001040": "지주회사","010060": "지주회사",
  "373220": "2차전지","006400": "2차전지","051910": "2차전지",
  "207940": "바이오", "068270": "바이오", "128940": "바이오",
  "099440": "원전",   "023450": "원전",   "015760": "원전",
  "028260": "소비재", "097950": "소비재", "004370": "소비재",
  // ETF — 해당 섹터로 매핑
  "091160": "반도체", "0117V0": "AI전력", "494670": "조선",
  "471990": "반도체",                      // KODEX AI반도체핵심장비 (누락 추가)
  "266410": "바이오", "091180": "자동차", "091170": "은행",
  "0111J0": "증권", "307520": "지주회사",
  "305720": "2차전지","139220": "소비재",
  "463250": "방산",   "434730": "원전",   "454320": "플랜트/EPC",
  "491820": "전력설비", "117700": "건설", "0190C0": "로보틱스",
  "011070": "로보틱스", "010620": "로보틱스", "121600": "로보틱스",
};

// 섹터 → Tier 매핑 (C5 daily_leader_scan 점수 정밀화)
// Tier_1=1.0(+rank≤3), Tier_2=0.5 고정, Tier_3=0
const SECTOR_TIER_MAP = {
  "반도체":   "Tier_1",
  "AI전력":   "Tier_1",
  "전력설비": "Tier_1",
  "방산":     "Tier_1",
  "조선":     "Tier_1",
  "자동차":   "Tier_2",
  "2차전지":  "Tier_2",
  "바이오":   "Tier_2",
  "원전":     "Tier_2",
  "건설":     "Tier_3",
  "플랜트/EPC": "Tier_3",
  "로보틱스": "Tier_2",
  "은행":"Tier_3",
  "증권":"Tier_3",
  "지주회사":"Tier_3",
  "소비재":   "Tier_3",
};

// KOSDAQ 상장 종목 Set — SS001_VAL_KOSDAQ_PEG(max 12pt) 적용 대상
// 현재 보유 10종목은 모두 KOSPI 상장. KOSDAQ 종목 편입 시 코드 추가.
const KOSDAQ_TICKERS = new Set([
  // e.g., "035900", "003230"
]);

const DART_CATALYST_KEYWORDS = [
  "수주",
  "계약",
  "실적",
  "공급",
  "납품",
  "증설",
  "합병",
  "인수",
  "배당",
  "자사주",
];

const DART_RISK_KEYWORDS = [
  "감자",
  // "정정" 제거: DART 제목 앞 접두어로 잠정실적·계약체결 등 모든 공시에 붙어 오탐 유발
  "상장폐지",
  "관리종목",
  "횡령",
  "배임",
  "불성실",
  "소송",
  "회생",
  "유상증자",
  "감사의견",    // 감사의견 거절·한정
  "공시번복",    // 공시 내용 번복 (실질적 정정)
  "조사",        // 금감원 조사
];

// GAS_CACHE_MAX_TTL: GAS CacheService 최대 허용 TTL = 21600초(6시간).
// 초과 시 put()이 silently fail(try/catch 흡수) → 캐시 저장 안됨 → 매번 re-fetch 유발.
const GAS_CACHE_MAX_TTL = 21600;

const FETCH_GOVERNANCE = {
  budget: {
    naver_flow: 1,
    naver_quote: 1,
    naver_ohlc: 1,
    naver_notice: 1,
    naver_consensus: 1,
    naver_fund: 1,           // 펀더멘털 fallback (분기별, 7일 캐시 우선)
    yahoo_price: 1,
    yahoo_quote: 1,
    yahoo_chart: 1,
    yahoo_financials: 1,
  },
  ttl: {
    naver_flow_ok:      GAS_CACHE_MAX_TTL,   // 6h (GAS 최대값)
    naver_quote_ok:     30 * 60,             // 30분 (장중 실시간 호가)
    naver_ohlc_ok:      GAS_CACHE_MAX_TTL,   // 6h — 수정: 43200 → 21600 (GAS 초과 버그 fix)
    naver_notice_ok:    4 * 60 * 60,         // 4h
    naver_consensus_ok: 4 * 60 * 60,         // 4h
    // 펀더멘털은 분기별 데이터 — CacheService 6h 저장 후 PropertiesService 7일 캐시로 이중 방어
    naver_fund_ok:      GAS_CACHE_MAX_TTL,   // 6h
    yahoo_price_ok:     GAS_CACHE_MAX_TTL,   // 6h
    yahoo_quote_ok:     30 * 60,             // 30분
    yahoo_chart_ok:     GAS_CACHE_MAX_TTL,   // 6h
    yahoo_financials_ok: GAS_CACHE_MAX_TTL,  // 6h
    failure:            10 * 60,             // 10분 (재시도 대기)
  },
  failureLimit: 3,
  coolDownMs: 3 * 60 * 60 * 1000,
};

// ── 운영 임계값 상수 (magic number 50개+ → 단일 위치로 통합) ────────────────
// 수치 변경 시 반드시 이 블록만 수정. 코드 본문 하드코딩 금지.
const THRESHOLDS = {
  // Val_Surge_Pct 상태 구간 (%)
  VAL_SURGE_WATCH:      15,
  VAL_SURGE_HOT:        35,
  VAL_SURGE_EXHAUSTED:  50,
  // 유동성 — 5D 평균 거래대금 (백만원)
  LIQUIDITY_PREFERRED_M: 100,
  LIQUIDITY_OK_M:         50,
  // 호가 스프레드 (%)
  SPREAD_OK_PCT:   0.25,
  SPREAD_WARN_PCT: 0.50,
  // Take Profit 승수 (진입가 대비)
  TP_CORE_1: 1.15,   // core 1차 +15%
  TP_CORE_2: 1.25,   // core 2차 +25%
  TP_SAT_1:  1.10,   // satellite 1차 +10%
  TP_SAT_2:  1.20,   // satellite 2차 +20%
  // Time Stop (calendar days)
  TIME_STOP_STAGE1: 60,
  TIME_STOP_STAGE2: 30,
  // Bucket 할당 목표 범위 (%)
  BUCKET_CORE_MIN:  60,
  BUCKET_CORE_MAX:  72,
  BUCKET_SAT_MIN:   10,
  BUCKET_SAT_MAX:   25,
  BUCKET_CASH_MIN:  10,
  BUCKET_CASH_MAX:  22,
  // Satellite 단일종목 비중 상한 (%)
  SAT_BAND_MAX:  7,
  // Orbit Gap 경보 (%p)
  ORBIT_MILD_BEHIND:        1,
  ORBIT_SIGNIFICANT_BEHIND: 3,
  ORBIT_AHEAD_TARGET:      -2,
  // 포지션 수량 위험 예산 (기본 — settings 탭 override 가능)
  DEFAULT_RISK_BUDGET: 0.007,
  // ATR 기반 손절 승수
  ATR_STOP_MULT:         1.5,
  ATR_TRAILING_MULT:     1.5,
  ATR_STOP_MULT_HIGH:    2.0,   // ATR20_Pct >= 8% 고변동성 종목 전용
  // Stage2 진입 최소 수익 (%)
  STAGE2_GATE_MIN_PCT: 1.5,
  // ── Sell_Priority_Score 산출 상수 (spec: portfolio_exposure.yaml:sell_priority_engine) ──
  SP_HARD_STOP:          50,  // EXIT_SIGNAL / EXIT_100
  SP_SELL_SIGNAL:        40,  // SELL_READY / TRIM 신호 확정
  SP_HOLDINGS_ROTATE:    20,  // EXIT_REVIEW / 보유주 교체 후보
  SP_TAKE_PROFIT:        10,  // Profit_Pct >= 10% (익절 후보)
  SP_ETF_DUPLICATE:      20,  // ETF + 섹터노출 >= 20% (중복노출 상한 초과)
  SP_ETF_MODERATE:       15,  // ETF + 섹터노출 >= 10%
  SP_CASH_LARGE:         15,  // Weight_Pct >= 3% (현금 회복 효과 대)
  SP_CASH_MID:           10,  // Weight_Pct >= 1%
  SP_CASH_SMALL:          3,
  SP_RW4:                20,  // RW_Partial >= 4
  SP_RW3:                15,  // RW_Partial == 3
  SP_RW2:                 8,  // RW_Partial == 2
  SP_BELOW_MA20:          8,  // close < MA20
  SP_LOSS_SATELLITE:     12,  // 손실 >= -10%, 위성, 비코어리더
  SP_OVERWEIGHT_LARGE:   12,  // 목표비중 초과 >= 5%p
  SP_OVERWEIGHT_MID:      6,  // 목표비중 초과 >= 2%p
  SP_CORE_LEADER:       -20,  // 직접 코어 주도주 + 상승추세 (패널티)
  SP_SS001_A:           -12,  // SS001 A등급 (패널티)
  SP_DUPLICATE_THRESH:   20,  // 섹터노출 중복 판정 기준 (%)
};


function getKrxMarketSessionStatus_(dt) {
  const d = dt instanceof Date ? dt : new Date(dt || new Date());
  if (isNaN(d.getTime())) {
    return { open: false, reason: "invalid_datetime" };
  }
  const kst = new Date(d.getTime() + 9 * 60 * 60 * 1000);
  const day = kst.getUTCDay();
  const minutes = kst.getUTCHours() * 60 + kst.getUTCMinutes();
  const open = day >= 1 && day <= 5 && minutes >= 9 * 60 && minutes < 15 * 60 + 30;
  return {
    open: open,
    reason: open ? "MARKET_OPEN" : "MARKET_CLOSED",
    kst_date: Utilities.formatDate(kst, "Asia/Seoul", "yyyy-MM-dd"),
    kst_time: Utilities.formatDate(kst, "Asia/Seoul", "HH:mm:ss"),
  };
}

// account_snapshot freshness 확인 — last_updated/captured_at 최신 행 기준 경과일 반환
function checkAccountSnapshotFreshness_() {
  try {
    const ss    = getSpreadsheet_();
    const sheet = ss.getSheetByName("account_snapshot");
    if (!sheet) return { fresh: false, reason: "account_snapshot 탭 없음" };
    const session = getKrxMarketSessionStatus_(new Date());
    const data  = sheet.getDataRange().getValues();
    if (data.length < 3) return { fresh: true, reason: "보유 원장 없음" };
    const hdr      = data[1].map(h => String(h).trim());
    const luIdx    = hdr.indexOf("last_updated") >= 0 ? hdr.indexOf("last_updated") : hdr.indexOf("captured_at");
    const qtyIdx   = hdr.indexOf("holding_quantity");
    const statusIdx = hdr.indexOf("parse_status");
    const confirmedIdx = hdr.indexOf("user_confirmed");
    if (luIdx < 0) return { fresh: null, reason: "last_updated/captured_at 컬럼 없음" };
    const today    = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
    let latestDate = null;
    for (let i = 2; i < data.length; i++) {
      const parseStatus = statusIdx >= 0 ? String(data[i][statusIdx] ?? "").trim() : "";
      const confirmed = confirmedIdx >= 0 ? String(data[i][confirmedIdx] ?? "").trim().toUpperCase() : "";
      if (parseStatus !== "CAPTURE_READ_OK" || !["Y", "YES", "TRUE", "1"].includes(confirmed)) continue;
      const qty = parseInt(data[i][qtyIdx]);
      if (!Number.isFinite(qty) || qty <= 0) continue;
      const raw = data[i][luIdx];
      const d   = raw instanceof Date
        ? Utilities.formatDate(raw, "Asia/Seoul", "yyyy-MM-dd")
        : String(raw).trim().substring(0, 10);
      if (/^\d{4}-\d{2}-\d{2}$/.test(d) && d > (latestDate ?? "")) latestDate = d;
    }
    if (!latestDate) return { fresh: null, reason: "last_updated 미입력" };
    const daysDiff = Math.round((new Date(today) - new Date(latestDate)) / 86400000);
    return {
      fresh:        daysDiff <= 1,
      last_updated: latestDate,
      days_stale:   daysDiff,
      reason:       daysDiff <= 1 ? "최신" : `${daysDiff}일 경과 (${latestDate})`,
      collection_allowed: session.open,
      market_session_open: session.open,
      market_session_reason: session.reason,
    };
  } catch(e) {
    return { fresh: null, reason: "읽기 오류: " + e.message };
  }
}

function snapshotExecutionGate_(freshness) {
  if (!freshness || freshness.fresh == null) {
    return {
      status: "BLOCK_EXECUTION",
      reason: freshness && freshness.reason ? freshness.reason : "account_snapshot freshness unknown",
    };
  }
  if (freshness.fresh === false) {
    return {
      status: "REVIEW_ONLY",
      reason: freshness.reason || "snapshot stale — proposal only",
    };
  }
  return {
    status: "ALLOW_EXECUTION",
    reason: freshness.reason || "최신",
  };
}

function calcDerivedPriceMetrics(rows, latestFirst) {
  if (!Array.isArray(rows) || rows.length === 0) return {};
  const ordered = latestFirst ? rows.slice().reverse() : rows.slice(); // oldest -> latest
  const latest = ordered[ordered.length - 1] || {};
  const previous = ordered[ordered.length - 2] || {};
  const prior = (n) => ordered[ordered.length - 1 - n] || null;
  const lastN = (n) => ordered.slice(Math.max(0, ordered.length - n));
  const prevN = (n) => ordered.slice(Math.max(0, ordered.length - 1 - n), ordered.length - 1);
  return {
    open: Number.isFinite(latest.open) ? latest.open : null,
    high: Number.isFinite(latest.high) ? latest.high : null,
    low: Number.isFinite(latest.low) ? latest.low : null,
    volume: Number.isFinite(latest.volume) ? latest.volume : null,
    prevClose: Number.isFinite(previous.close) ? previous.close : null,
    avgVolume5D: prevN(5).length >= 5 ? avgNumber_(prevN(5).map(r => r.volume)) : null,
    ma20: lastN(20).length >= 20 ? avgNumber_(lastN(20).map(r => r.close)) : null,
    ma60: lastN(60).length >= 60 ? avgNumber_(lastN(60).map(r => r.close)) : null,
    ret2D:  prior(2)  ? pctReturn_(latest.close, prior(2).close)  : null,
    ret5D:  prior(5)  ? pctReturn_(latest.close, prior(5).close)  : null,
    ret10D: prior(10) ? pctReturn_(latest.close, prior(10).close) : null,
    ret20D: prior(20) ? pctReturn_(latest.close, prior(20).close) : null,
    ret60D: prior(60) ? pctReturn_(latest.close, prior(60).close) : null,
  };
}

// ── F1: 기술적 타이밍 지표 계산 ──────────────────────────────────────────────
// rows: oldest→latest OHLCV 배열. 25행 이상 필요.
function calcTimingMetrics_(rows) {
  if (!Array.isArray(rows) || rows.length < 21) return {};
  const closes = rows.map(r => r.close);
  const n = closes.length;
  const close = closes[n - 1];

  // MA20 slope: (오늘 MA20 - 5일전 MA20) / 5일전 MA20 × 100
  const ma20Today = closes.slice(n - 20).reduce((a, b) => a + b, 0) / 20;
  let ma20Slope = null;
  if (n >= 25) {
    const ma20_5ago = closes.slice(n - 25, n - 5).reduce((a, b) => a + b, 0) / 20;
    if (ma20_5ago > 0) ma20Slope = parseFloat(((ma20Today - ma20_5ago) / ma20_5ago * 100).toFixed(3));
  }

  // 이격도: (종가/MA20 - 1) × 100
  const disparity = ma20Today > 0 ? parseFloat(((close / ma20Today - 1) * 100).toFixed(2)) : null;

  // RSI 14 (Wilder's smoothed)
  const rsi14 = calcRsi14_(closes);

  // 볼린저 밴드 (20일, 2σ)
  const bb20 = closes.slice(n - 20);
  const bbMean = bb20.reduce((a, b) => a + b, 0) / 20;
  const bbVar  = bb20.reduce((s, c) => s + Math.pow(c - bbMean, 2), 0) / 20;
  const bbStd  = Math.sqrt(bbVar);
  const bbUpper = bbMean + 2 * bbStd;
  const bbLower = bbMean - 2 * bbStd;
  const bbWidth = bbMean > 0 ? parseFloat(((bbUpper - bbLower) / bbMean * 100).toFixed(2)) : null;
  const bbPos   = (bbUpper > bbLower) ? parseFloat(((close - bbLower) / (bbUpper - bbLower) * 100).toFixed(1)) : null;

  return {
    ma20Slope,
    disparity,
    rsi14,
    bbWidth,
    bbPosition: bbPos,
    bbUpper: Math.round(bbUpper),
    bbLower: Math.round(bbLower),
  };
}

// RSI14 — Wilder 방식. 최대 50개 바 사용해 초기화 편향 최소화.
// 14개만 초기화하면 ±5~8pt 오차 발생 — 사용 가능한 전체 데이터로 안정화.
function calcRsi14_(closes) {
  if (closes.length < 15) return null;
  const lookback = Math.min(closes.length, 50);
  const c = closes.slice(closes.length - lookback);
  let avgGain = 0, avgLoss = 0;
  for (let i = 1; i <= 14; i++) {
    const d = c[i] - c[i - 1];
    if (d > 0) avgGain += d; else avgLoss -= d;
  }
  avgGain /= 14; avgLoss /= 14;
  for (let i = 15; i < c.length; i++) {
    const d = c[i] - c[i - 1];
    avgGain = (avgGain * 13 + Math.max(0, d)) / 14;
    avgLoss = (avgLoss * 13 + Math.max(0, -d)) / 14;
  }
  if (avgLoss === 0) return 100;
  return parseFloat((100 - 100 / (1 + avgGain / avgLoss)).toFixed(1));
}

// ── F2: Entry Mode 게이트 ─────────────────────────────────────────────────────
// PULLBACK: 눌림목 매수 조건 / BREAKOUT: 돌파 매수 조건 / NEUTRAL: 대기
function calcEntryMode_(timing, price) {
  const { ma20Slope, disparity, rsi14 } = timing;
  if (!Number.isFinite(disparity) || !Number.isFinite(rsi14)) {
    return { mode: "NEUTRAL", gate: "PENDING", reason: "지표_부족" };
  }
  const trendUp  = Number.isFinite(ma20Slope) && ma20Slope > 0;
  const valSurge = Number.isFinite(price.valSurge) ? price.valSurge : 0;
  const pct52H   = Number.isFinite(price.pct52WHigh) ? price.pct52WHigh : -100;

  // 과열 — 두 전략 모두 진입 금지
  if (disparity > 12 || rsi14 > 75) {
    return { mode: "OVERBOUGHT", gate: "BLOCK", reason: `과열(이격${disparity}%_RSI${rsi14})` };
  }
  // 눌림목: 이격도 -5~+4% + MA20 상승 + RSI 35~58
  if (trendUp && disparity >= -5 && disparity <= 4 && rsi14 >= 35 && rsi14 <= 58) {
    return { mode: "PULLBACK", gate: "PASS", reason: `눌림목(이격${disparity}%_RSI${rsi14})` };
  }
  // 돌파: 52주 고점 -5% 이내 + 거래량 폭발 + RSI 50~72 + MA20 상승
  if (trendUp && pct52H >= -5 && valSurge >= 50 && rsi14 > 50 && rsi14 <= 72) {
    return { mode: "BREAKOUT", gate: "PASS", reason: `돌파(52WH${pct52H.toFixed(1)}%_VOL+${valSurge.toFixed(0)}%)` };
  }
  // MA20 하락 추세
  if (!trendUp && Number.isFinite(ma20Slope)) {
    return { mode: "NEUTRAL", gate: "PENDING", reason: `MA20하락추세(slope${ma20Slope.toFixed(2)}%)` };
  }
  return { mode: "NEUTRAL", gate: "PENDING", reason: `조건미충족(이격${disparity}%_RSI${rsi14})` };
}

// ── F3: 매도 타이밍 신호 ──────────────────────────────────────────────────────
// 복수 신호 발생 시 파이프(|) 구분. 포지션 없으면 빈 문자열.
function calcExitSignalDetail_(timing, price) {
  const signals = [];
  const { disparity, rsi14, ma20Slope } = timing;
  const ret5D    = Number.isFinite(price.ret5D)    ? parseFloat(price.ret5D)    : null;
  const valSurge = Number.isFinite(price.valSurge) ? price.valSurge             : null;

  // 거래량 소진: 5일 수익률 양수인데 거래대금 평균 대비 -20% 미만
  if (ret5D !== null && ret5D > 0 && valSurge !== null && valSurge < -20) {
    signals.push("VOL_EXHAUSTION");
  }
  // MA20 붕괴: 종가 < MA20 AND MA20 하락
  if (price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) &&
      price.close < price.ma20 && Number.isFinite(ma20Slope) && ma20Slope < 0) {
    signals.push("MA20_BREAK");
  }
  // 극단 과열: 이격도 > 15%
  if (Number.isFinite(disparity) && disparity > 15) signals.push("DISPARITY_TOP");
  // RSI 과매수: RSI > 75
  if (Number.isFinite(rsi14) && rsi14 > 75) signals.push("RSI_OVERBOUGHT");

  return signals.join("|");
}

// ── F5: 타이밍 종합 액션 ──────────────────────────────────────────────────────
// 종목 점수(SS001)와 별개로 "지금 무엇을 할지"를 분리한다.
var calcEntryTimingSignal_ = function(ctx) {
  const reasons = [];
  let entryScore = 0;
  let exitScore = 0;

  const entryGate = String(ctx.entryModeGate ?? "");
  const entryMode = String(ctx.entryMode ?? "");
  const leaderGate = String(ctx.leaderGate ?? "");
  const acGate = String(ctx.acGate ?? "");
  const exitSignal = String(ctx.exitSignalDetail ?? "");
  const flowCredit = parseFloat(ctx.flowCredit);
  const leaderTotal = parseFloat(ctx.leaderTotal);
  const rwPartial = parseInt(ctx.rwPartial, 10);
  const rsi14 = parseFloat(ctx.rsi14);
  const disparity = parseFloat(ctx.disparity);
  const ma20Slope = parseFloat(ctx.ma20Slope);
  const spreadPct = parseFloat(ctx.spreadPct);
  const avgTradeValue5D = parseFloat(ctx.avgTradeValue5D);
  const profitPct = parseFloat(ctx.profitPct);
  const daysToTimeStop = parseInt(ctx.daysToTimeStop, 10);

  if (entryGate === "PASS") { entryScore += 25; reasons.push(`entry_${entryMode}`); }
  else if (entryGate === "BLOCK") { entryScore -= 25; reasons.push("entry_block"); }

  if (Number.isFinite(leaderTotal)) {
    if (leaderTotal >= 4) { entryScore += 20; reasons.push("leader_scan>=4"); }
    else if (leaderTotal >= 3) { entryScore += 10; reasons.push("leader_watch"); }
  }
  if (leaderGate === "PASS" || leaderGate === "EXPLORE_CANDIDATE") entryScore += 10;

  if (Number.isFinite(flowCredit)) {
    if (flowCredit >= 0.7) { entryScore += 20; reasons.push("flow_strong"); }
    else if (flowCredit >= 0.4) { entryScore += 10; reasons.push("flow_partial"); }
  }

  if (acGate === "CLEAR") { entryScore += 15; reasons.push("anti_climax_clear"); }
  else if (acGate === "CAUTION") { entryScore += 5; reasons.push("anti_climax_caution"); }
  else if (acGate === "BLOCK") { entryScore -= 35; exitScore += 15; reasons.push("anti_climax_block"); }

  if (Number.isFinite(ma20Slope)) {
    if (ma20Slope > 0) entryScore += 8;
    else { entryScore -= 8; exitScore += 8; reasons.push("ma20_down"); }
  }
  if (Number.isFinite(disparity)) {
    if (disparity >= -5 && disparity <= 4) entryScore += 10;
    else if (disparity > 4 && disparity <= 8) entryScore += 5;
    else if (disparity > 12) { entryScore -= 25; exitScore += 20; reasons.push("overextended"); }
    else if (disparity < -10) { entryScore -= 10; exitScore += 10; reasons.push("trend_damage"); }
  }
  if (Number.isFinite(rsi14)) {
    if (rsi14 >= 40 && rsi14 <= 65) entryScore += 10;
    else if (rsi14 > 65 && rsi14 <= 72) entryScore += 4;
    else if (rsi14 > 75) { entryScore -= 25; exitScore += 20; reasons.push("rsi_overbought"); }
    else if (rsi14 < 35) { entryScore -= 5; exitScore += 8; reasons.push("weak_rsi"); }
  }
  if (Number.isFinite(avgTradeValue5D) && avgTradeValue5D >= 50
      && (!Number.isFinite(spreadPct) || spreadPct <= 0.8)) {
    entryScore += 10;
  } else {
    entryScore -= 15;
    reasons.push("liquidity_or_spread_fail");
  }

  // RW: 수급 기반 상대약세 — 신뢰도 높아 25pt/건 (구: 20pt). 기술지표: 노이즈 多로 10pt/건 (구: 18pt).
  // 결과: RW=0 + 기술신호 4개 = 40pt → EXIT_REVIEW 미도달. RW=1 + 기술신호 2개 = 45pt → 대기.
  // RW=2 단독 = 50pt → EXIT_REVIEW. RW=3 단독 = 75pt → STOP_OR_TIME_EXIT_READY.
  if (Number.isFinite(rwPartial)) exitScore += Math.min(100, Math.max(0, rwPartial) * 25);
  if (exitSignal) exitScore += exitSignal.split("|").filter(Boolean).length * 10;
  if (Number.isFinite(daysToTimeStop) && daysToTimeStop >= 0 && daysToTimeStop <= 7) {
    exitScore += 20;
    reasons.push("time_stop_near");
  }
  if (Number.isFinite(profitPct) && profitPct >= 10) {
    exitScore += 15;
    reasons.push("profit_protect_zone");
  }

  entryScore = Math.max(0, Math.min(100, Math.round(entryScore)));
  exitScore = Math.max(0, Math.min(100, Math.round(exitScore)));

  let action = "HOLD_NO_TIMING_EDGE";
  if (ctx.priceStatus !== "PRICE_OK" || !Number.isFinite(parseFloat(ctx.atr20))) {
    action = "OBSERVE_DATA_MISSING";
  } else if (exitScore >= 75 || (Number.isFinite(rwPartial) && rwPartial >= 4)) {
    action = "STOP_OR_TIME_EXIT_READY";
  } else if (exitScore >= 50 || (Number.isFinite(rwPartial) && rwPartial >= 3)) {
    action = "EXIT_REVIEW";
  } else if (entryGate === "BLOCK" || acGate === "BLOCK" || entryMode === "OVERBOUGHT") {
    action = "NO_BUY_OVERHEATED";
  } else if (entryScore >= 75 && entryGate === "PASS" && leaderTotal >= 4) {
    action = entryMode === "BREAKOUT" ? "BUY_BREAKOUT_PILOT_ONLY" : "BUY_STAGE1_READY";
  } else if (entryScore >= 60 && entryGate === "PASS") {
    action = entryMode === "BREAKOUT" ? "BUY_BREAKOUT_PILOT_ONLY" : "BUY_PULLBACK_WAIT";
  } else if (leaderTotal >= 3 || flowCredit >= 0.4) {
    action = "WATCH_TIMING_SETUP";
  }

  return {
    entry_score: entryScore,
    exit_score: exitScore,
    action,
    reason: reasons.slice(0, 6).join("|"),
  };
}

// Backward-compatible thin wrapper.
// Existing data_feed callers still expect calcTimingRoute_.
var calcTimingRoute_ = function(ctx) {
  return calcEntryTimingSignal_(ctx || {});
}

// ── F6: 매도 신호·가격 산출 (방향 A: 수량 계산은 GAS 담당 아님) ──────────────────
// Sell_Qty는 GAS에서 산출하지 않는다. 신호 종류 + 가격 + 비율만 출력.
// 보유수량 × 비율 계산은 사용자가 ChatGPT에 캡처를 제공하는 단계에서 처리한다.
var calcExitSellAction_ = function(ctx) {
  const close = parseFloat(ctx.close);
  const stopPrice = parseFloat(ctx.stopPrice);
  const trailingStop = parseFloat(ctx.trailingStop);
  const tp1Price = parseFloat(ctx.tp1Price);
  const tp2Price = parseFloat(ctx.tp2Price);
  const profitPct = parseFloat(ctx.profitPct);
  const rwPartial = parseInt(ctx.rwPartial, 10);
  const timingExitScore = parseFloat(ctx.timingExitScore);
  const daysToTimeStop = parseInt(ctx.daysToTimeStop, 10);
  const timingAction = String(ctx.timingAction ?? "");
  const exitSignal = String(ctx.exitSignalDetail ?? "");
  const acGate = String(ctx.acGate ?? "");
  // sell_signal_priority level 2: REGIME_RISK_OFF (spec/exit/stop_loss.yaml)
  const regime = String(ctx.regime ?? "");
  const atr20 = parseFloat(ctx.atr20);

  let action = "HOLD";
  let ratio = 0;
  let reason = "";
  let price = "";
  let priceSource = "";
  let priceBasis = "";
  let executionWindow = "";
  let orderType = "";

  const stopCandidate = Number.isFinite(trailingStop) && trailingStop > 0
    ? trailingStop
    : Number.isFinite(stopPrice) && stopPrice > 0
      ? stopPrice
      : Number.isFinite(close) && close > 0
        ? close * 0.995
        : null;
  const protectiveLimit = Number.isFinite(close) && close > 0
    ? Math.round(Math.min(close * 0.995, stopCandidate ?? close * 0.995))
    : "";
  // ATR 기반 보호 하한: close - ATR20×0.3 (변동성 비례 버퍼). ATR 없으면 0.5% 폴백.
  const atrBuffer = Number.isFinite(atr20) && atr20 > 0 ? atr20 * 0.3 : (Number.isFinite(close) ? close * 0.005 : 0);
  const closeProtectLimit = Number.isFinite(close) && close > 0 ? Math.round(close - atrBuffer) : "";

  // priority 1: hard stop / strong RW exit (spec sell_signal_priority level 1)
  if (timingAction === "STOP_OR_TIME_EXIT_READY" || rwPartial >= 4) {
    action = "EXIT_100";
    ratio = 100;
    reason = rwPartial >= 4 ? "RW_EXIT_STRONG" : "STOP_OR_TIME_EXIT_READY";
    price = protectiveLimit;
    priceSource = Number.isFinite(trailingStop) ? "TRAILING_STOP" : "STOP_OR_CLOSE";
    priceBasis = Number.isFinite(trailingStop) ? "TRAILING_STOP_TRIGGER" : "STOP_OR_CLOSE_PROTECT";
    executionWindow = "INTRADAY_ON_TRIGGER";
    orderType = "PROTECTIVE_LIMIT_SELL";
  // priority 2: REGIME_TRIM_50 — 방향 A에서 개별 종목 신호 아님.
  // RISK_OFF 레짐 포트폴리오 축소 경고는 getDailyBrief() 매크로 섹션에서 처리.
  // priority 3: RW 신호 강 (spec level 3)
  } else if (rwPartial >= 3 || timingExitScore >= 75) {
    action = "TRIM_70";
    ratio = 70;
    reason = rwPartial >= 3 ? "RW_EXIT" : "TIMING_EXIT_SCORE";
    price = protectiveLimit;
    priceSource = "RISK_REDUCTION";
    priceBasis = "RISK_REDUCTION_CLOSE_PROTECT";
    executionWindow = "INTRADAY_AFTER_09_30";
    orderType = "PROTECTIVE_LIMIT_SELL";
  // priority 4: trailing stop 가격 직접 이탈 (spec level 4) — timingAction과 독립적으로 직접 비교
  } else if (Number.isFinite(trailingStop) && trailingStop > 0 && Number.isFinite(close) && close <= trailingStop) {
    action = "TRAILING_STOP_BREACH";
    ratio = 70;
    reason = "TRAILING_STOP_PRICE_BREACH";
    price = Math.round(trailingStop);  // 트레일링 스탑 이탈: 스탑 가격 자체가 보호선 — min 적용 금지
    priceSource = "TRAILING_STOP_PRICE";
    priceBasis = "TRAILING_STOP_TRIGGER";
    executionWindow = "INTRADAY_ON_TRIGGER";
    orderType = "PROTECTIVE_LIMIT_SELL";
  // priority 4 (계속): RW 신호 중 (spec level 3 하위)
  // RW=0 + 기술지표만으로는 TRIM_50 차단 — 수급 확인(rwPartial>=1) 필수
  } else if (rwPartial >= 2 || (rwPartial >= 1 && timingExitScore >= 50)) {
    action = "TRIM_50";
    ratio = 50;
    reason = rwPartial >= 2 ? "RW_REVIEW" : "TIMING_EXIT_REVIEW";
    price = closeProtectLimit;
    priceSource = "RELATIVE_WEAKNESS_CLOSE";
    priceBasis = "PRIOR_CLOSE_X_0.998";
    executionWindow = "INTRADAY_AFTER_09_30";
    orderType = "LIMIT_SELL";
  // priority 4b: RW 약세 초기 + 기술지표 경계 — 33% 선제 경량화
  } else if (rwPartial >= 1 && timingExitScore >= 30) {
    action = "TRIM_33";
    ratio = 33;
    reason = "RW_EARLY_WARNING";
    price = closeProtectLimit;
    priceSource = "EARLY_WARNING_CLOSE";
    priceBasis = "PRIOR_CLOSE_X_0.998";
    executionWindow = "INTRADAY_AFTER_09_30";
    orderType = "LIMIT_SELL";
  // priority 4c: RW 약세 감지 단독 (기술지표 미확인) — 25% 최소 경계
  } else if (rwPartial >= 1) {
    action = "TRIM_25";
    ratio = 25;
    reason = "RW_SIGNAL_ONLY";
    price = closeProtectLimit;
    priceSource = "SIGNAL_ONLY_CLOSE";
    priceBasis = "PRIOR_CLOSE_X_0.998";
    executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN";
    orderType = "LIMIT_SELL";
  // priority 5: 익절 사다리 (spec level 5) — time_stop보다 우선
  } else if (Number.isFinite(profitPct) && profitPct >= 50) {
    action = "PROFIT_TRIM_50";
    ratio = 50;
    reason = "PROFIT_PROTECT_50";
    price = Number.isFinite(tp2Price) && tp2Price > 0 ? Math.round(tp2Price) : closeProtectLimit;
    priceSource = Number.isFinite(tp2Price) ? "TP2_PRICE" : "CLOSE_PROFIT_PROTECT";
    priceBasis = Number.isFinite(tp2Price) ? "TAKE_PROFIT_TIER2_PRICE" : "PRIOR_CLOSE_X_0.998";
    executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW";
    orderType = "LIMIT_SELL";
  } else if (Number.isFinite(profitPct) && profitPct >= 30) {
    action = "PROFIT_TRIM_35";
    ratio = 35;
    reason = "PROFIT_PROTECT_30";
    price = Number.isFinite(tp2Price) && tp2Price > 0 ? Math.round(tp2Price) : closeProtectLimit;
    priceSource = Number.isFinite(tp2Price) ? "TP2_PRICE" : "CLOSE_PROFIT_PROTECT";
    priceBasis = Number.isFinite(tp2Price) ? "TAKE_PROFIT_TIER2_PRICE" : "PRIOR_CLOSE_X_0.998";
    executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW";
    orderType = "LIMIT_SELL";
  } else if (Number.isFinite(profitPct) && profitPct >= 20) {
    action = "PROFIT_TRIM_25";
    ratio = 25;
    reason = "PROFIT_PROTECT_20";
    price = Number.isFinite(tp1Price) && tp1Price > 0 ? Math.round(tp1Price) : closeProtectLimit;
    priceSource = Number.isFinite(tp1Price) ? "TP1_PRICE" : "CLOSE_PROFIT_PROTECT";
    priceBasis = Number.isFinite(tp1Price) ? "TAKE_PROFIT_TIER1_PRICE" : "PRIOR_CLOSE_X_0.998";
    executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW";
    orderType = "LIMIT_SELL";
  } else if (Number.isFinite(profitPct) && profitPct >= 10) {
    action = "TAKE_PROFIT_TIER1";
    ratio = 25;
    reason = "TP1_PROFIT_10PCT";
    price = Number.isFinite(tp1Price) && tp1Price > 0 ? Math.round(tp1Price) : closeProtectLimit;
    priceSource = Number.isFinite(tp1Price) ? "TP1_PRICE" : "CLOSE_PROFIT_PROTECT";
    priceBasis = Number.isFinite(tp1Price) ? "TAKE_PROFIT_TIER1_PRICE" : "PRIOR_CLOSE_X_0.998";
    executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW";
    orderType = "LIMIT_SELL";
  // priority 6: 시간 손절 (spec level 6) — 익절 사다리보다 후순위; 손절·레짐·RW 없을 때만 도달
  } else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 0) {
    action = "TIME_EXIT_100";
    ratio = 100;
    reason = "TIME_STOP_EXPIRED";
    price = protectiveLimit;
    priceSource = "TIME_STOP_CLOSE";
    priceBasis = "TIME_STOP_CLOSE_PROTECT";
    executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN";
    orderType = "PROTECTIVE_LIMIT_SELL";
  } else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 7) {
    action = "TIME_TRIM_50";
    ratio = 50;
    reason = "TIME_STOP_NEAR";
    price = closeProtectLimit;
    priceSource = "TIME_STOP_NEAR_CLOSE";
    priceBasis = "ATR_PROTECT_LIMIT";
    executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN";
    orderType = "LIMIT_SELL";
  // priority 6b: 타임스탑 14일 이내 조기 경보 — 25% 선제 축소
  } else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 14) {
    action = "TIME_TRIM_25";
    ratio = 25;
    reason = "TIME_STOP_APPROACHING";
    price = closeProtectLimit;
    priceSource = "TIME_STOP_APPROACHING_CLOSE";
    priceBasis = "ATR_PROTECT_LIMIT";
    executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN";
    orderType = "LIMIT_SELL";
  }

  const cashPreservePlan = calcCashPreservationPlan_({
    sellAction: action,
    cashFloorStatus: String(ctx.cashFloorStatus ?? ""),
    regime,
    isCoreLeader: !!ctx.isCoreLeader,
    isEtf: !!ctx.isEtf,
    liquidityStatus: String(ctx.liquidityStatus ?? ""),
    spreadStatus: String(ctx.spreadStatus ?? ""),
    accountType: String(ctx.accountType ?? ""),
    profitPct,
    rwPartial,
    reboundHoldbackScore: parseFloat(ctx.reboundHoldbackScore),
  });
  if (action !== "EXIT_100" && action !== "TRAILING_STOP_BREACH" && action !== "HOLD") {
    const targetRatio = cashPreservePlan.recommended_ratio;
    if (Number.isFinite(targetRatio) && targetRatio > 0 && targetRatio < ratio) {
      ratio = targetRatio;
      if (ratio <= 25) action = "TRIM_25";
      else if (ratio <= 33) action = "TRIM_33";
      else action = "TRIM_50";
      reason = reason ? `${reason}|CASH_PRESERVE:${cashPreservePlan.style}` : `CASH_PRESERVE:${cashPreservePlan.style}`;
    }
  }

  // SL003_PRIORITY_MATRIX: 복수 손절 조건 동시 발동 시 max(prices) 적용 — spec/exit/stop_loss.yaml
  // TP 계열(PROFIT_TRIM_*, TAKE_PROFIT_TIER1)은 별도 프레임워크이므로 이 블록 적용 제외
  const isStopTypeAction_ = /^(EXIT_100|TRIM_70|TRAILING_STOP_BREACH|TRIM_50|TRIM_33|TRIM_25|TIME_EXIT_100|TIME_TRIM_50|TIME_TRIM_25)$/.test(action);
  if (isStopTypeAction_ && Number.isFinite(close) && close > 0) {
    const slpCands_ = [];
    const pushSlp_ = (src, p) => { if (Number.isFinite(p) && p > 0) slpCands_.push({ src, p }); };
    if (timingAction === "STOP_OR_TIME_EXIT_READY" || rwPartial >= 4) pushSlp_("HARD_STOP", protectiveLimit);
    // REGIME 후보는 방향 A에서 포트폴리오 레벨 처리 — SL003에서 제외
    if (rwPartial >= 3 || timingExitScore >= 75) pushSlp_("RW_TRIM70", protectiveLimit);
    if (Number.isFinite(trailingStop) && trailingStop > 0 && close <= trailingStop)
      pushSlp_("TRAILING", Math.round(trailingStop));  // 트레일링 스탑 가격이 보호선
    if (rwPartial >= 2 || (rwPartial >= 1 && timingExitScore >= 50)) pushSlp_("RW_TRIM50", closeProtectLimit);
    if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 7) pushSlp_("TIME_STOP", closeProtectLimit);
    if (slpCands_.length >= 2) {
      const maxSlp_ = slpCands_.reduce((a, b) => b.p > a.p ? b : a);
      const curPrice_ = parseFloat(price);
      if (maxSlp_.p > (Number.isFinite(curPrice_) ? curPrice_ : 0)) {
        price = maxSlp_.p;
        priceSource = "PRIORITY_MATRIX_MAX";
        priceBasis  = `SL003_MAX(${slpCands_.map(c => `${c.src}:${c.p}`).join("|")})`;
      }
    }
  }

  // 방향 A: 수량 계산 없음. 가격이 유효하면 SIGNAL_CONFIRMED.
  let validation = "NO_SELL_ACTION";
  if (action !== "HOLD") {
    validation = (Number.isFinite(parseFloat(price)) && parseFloat(price) > 0)
      ? "SIGNAL_CONFIRMED"
      : "NO_SELL_PRICE";
  }

  return {
    action,
    ratio_pct: ratio,
    limit_price: price,
    price_source: priceSource,
    price_basis: priceBasis,
    execution_window: executionWindow,
    order_type: orderType,
    reason,
    validation,
    cash_preserve_style: cashPreservePlan.style,
    cash_preserve_ratio: cashPreservePlan.recommended_ratio,
    cash_preserve_reason: cashPreservePlan.reasons,
  };
}

// Backward-compatible thin wrapper.
// Existing data_feed callers still expect calcSellRoute_.
var calcSellRoute_ = function(ctx) {
  return calcExitSellAction_(ctx || {});
}

// ── [2026-05-21_CLA_HARNESS_V1] REPLACEMENT_ALPHA_GATE_V1 ───────────────────
/**
 * CLA 레짐에서 위성 신규 BUY 전 코어 대비 알파 우위 검증.
 * spec/13_formula_registry.yaml:REPLACEMENT_ALPHA_GATE_V1
 * @return {{ rag_v1: 'PASS'|'FAIL'|'EXEMPT', rag_reason: string }}
 */
function validateReplacementAlpha_(ctx) {
  const posRec = ctx.posRec;
  if (posRec && posRec.position_type === 'core') {
    return { rag_v1: 'EXEMPT', rag_reason: 'core_exempt' };
  }
  const r = String(ctx.globalRegimePrelim_ || '').toUpperCase();
  const isCLA = r.indexOf('CONCENTRATED_LEADER_ADVANCE') >= 0 || r === 'CLA';
  if (!isCLA) return { rag_v1: 'EXEMPT', rag_reason: 'regime_not_cla' };

  const rsVerdict    = String(ctx.rs_verdict   || 'UNKNOWN');
  const ss001Norm    = typeof ctx.ss001_norm   === 'number' ? ctx.ss001_norm   : null;
  const excessRet10d = typeof ctx.excess_ret_10d === 'number' ? ctx.excess_ret_10d : null;
  const coreAvgSS001 = typeof ctx.coreAvgSS001 === 'number' ? ctx.coreAvgSS001 : 60;

  const condA = ['LEADER', 'MARKET'].includes(rsVerdict);
  const condB = ss001Norm !== null && ss001Norm >= coreAvgSS001 - 10;
  const condC = excessRet10d !== null && excessRet10d >= -5;
  const condD = excessRet10d === null || excessRet10d >= 0 || rsVerdict === 'LEADER';

  const pass = condA && condB && condC && condD;
  return {
    rag_v1:     pass ? 'PASS' : 'FAIL',
    rag_reason: !condA ? 'rs_verdict_weak'   :
                !condB ? 'ss001_below_core'  :
                !condC ? 'excess_ret_breach' :
                !condD ? 'rs_slope_negative' : 'pass'
  };
}

// ── F7: 최종 액션 우선순위 엔진 ─────────────────────────────────────────────
// LLM이 호출마다 임의 판단하지 않도록 최종 액션·순위 점수를 룰 엔진에서 고정한다.
var calcPortfolioActionRoute_ = function(ctx) {
  const sellAction = String(ctx.sellAction ?? "HOLD");
  const sellValidation = String(ctx.sellValidation ?? "");
  const allowedAction = String(ctx.allowedAction ?? "");
  const timingAction = String(ctx.timingAction ?? "");
  const timingEntry = parseFloat(ctx.timingScoreEntry);
  const timingExit = parseFloat(ctx.timingScoreExit);
  const ss001Total = parseFloat(ctx.ss001Total);
  const flowCredit = parseFloat(ctx.flowCredit);
  const leaderTotal = parseFloat(ctx.leaderTotal);
  const rwPartial = parseFloat(ctx.rwPartial);
  const profitPct = parseFloat(ctx.profitPct);
  const daysToTimeStop = parseFloat(ctx.daysToTimeStop);
  const weightPct = parseFloat(ctx.weightPct);
  const acGate = String(ctx.acGate ?? "");
  const liquidityStatus = String(ctx.liquidityStatus ?? "");
  const spreadStatus = String(ctx.spreadStatus ?? "");
  const dartRisk = !!ctx.dartRisk;
  const missingFields = String(ctx.missingFields ?? "");

  let finalAction = "HOLD";
  let actionPriority = 99;
  let sourceTag = "RULE_ENGINE";

  if (sellAction !== "HOLD" && sellValidation === "SIGNAL_CONFIRMED") {
    // 미보유(weightPct=0) 종목에 SELL_READY를 주면 주문수량=0 이므로 WATCH_EXIT_SIGNAL 로 다운그레이드
    if (!(weightPct > 0)) {
      finalAction = "WATCH_EXIT_SIGNAL";
      actionPriority = 35;
    } else {
      finalAction = "SELL_READY";
      actionPriority = 10;
    }
  } else if (allowedAction === "EXIT_SIGNAL" || timingAction === "STOP_OR_TIME_EXIT_READY") {
    finalAction = "EXIT_SIGNAL";
    actionPriority = 28;
  } else if (allowedAction === "REVIEW_EXIT" || timingAction === "EXIT_REVIEW") {
    finalAction = "EXIT_REVIEW";
    actionPriority = 32;
  } else if (timingAction === "NO_BUY_OVERHEATED" && !dartRisk) {
    finalAction = "NO_BUY_OVERHEATED";
    actionPriority = 50;
  } else if (allowedAction === "BUY_STAGE1_READY" || timingAction === "BUY_STAGE1_READY") {
    finalAction = "BUY_STAGE1_READY";
    actionPriority = 60;
  } else if (allowedAction === "BUY_BREAKOUT_PILOT_ONLY" || timingAction === "BUY_BREAKOUT_PILOT_ONLY") {
    finalAction = "BUY_BREAKOUT_PILOT_ONLY";
    actionPriority = 70;
  } else if (allowedAction === "BUY_PULLBACK_WAIT" || timingAction === "BUY_PULLBACK_WAIT") {
    finalAction = "BUY_PULLBACK_WAIT";
    actionPriority = 80;
  } else if (allowedAction === "WATCH_CANDIDATE") {
    finalAction = "WATCH_TIMING_SETUP";
    actionPriority = 90;
  }

  if (missingFields) sourceTag = "RULE_ENGINE_WITH_MISSING_DATA";

  const timeStopUrgency = Number.isFinite(daysToTimeStop) && daysToTimeStop >= 0
    ? Math.max(0, 20 - Math.min(20, daysToTimeStop * 3))
    : 0;
  const overweightPenalty = Number.isFinite(weightPct) && weightPct > 7 ? 15 : 0;
  const overheatPenalty = acGate === "BLOCK" ? 30 : acGate === "CAUTION" ? 10 : 0;
  const liquidityPenalty =
    ["LOW", "DATA_MISSING"].includes(liquidityStatus) ||
    ["BLOCK", "WIDE", "QUOTE_NO_MATCH"].includes(spreadStatus)
      ? 15
      : 0;

  let priorityScore;
  if (actionPriority <= 40) {
    priorityScore =
      (Number.isFinite(timingExit) ? timingExit : 0) * 0.35 +
      (Number.isFinite(rwPartial) ? rwPartial : 0) * 15 +
      Math.max(0, Number.isFinite(profitPct) ? profitPct : 0) * 0.30 +
      timeStopUrgency +
      overweightPenalty;
  } else if (actionPriority >= 50 && actionPriority <= 80) {
    priorityScore =
      (Number.isFinite(timingEntry) ? timingEntry : 0) * 0.35 +
      (Number.isFinite(ss001Total) ? ss001Total : 0) * 0.30 +
      (Number.isFinite(flowCredit) ? flowCredit : 0) * 20 +
      (Number.isFinite(leaderTotal) ? leaderTotal : 0) * 5 -
      overheatPenalty -
      liquidityPenalty;
  } else {
    priorityScore =
      (Number.isFinite(timingEntry) ? timingEntry : 0) * 0.20 +
      (Number.isFinite(timingExit) ? timingExit : 0) * 0.20 +
      (Number.isFinite(flowCredit) ? flowCredit : 0) * 10;
  }

  return {
    final_action: finalAction,
    action_priority: actionPriority,
    priority_score: parseFloat(Math.max(0, priorityScore).toFixed(2)),
    source_tag: sourceTag,
  };
}

// Backward-compatible thin wrapper.
// Existing data_feed callers still expect calcFinalRoute_.
var calcFinalRoute_ = function(ctx) {
  const d = calcPortfolioActionRoute_(ctx || {});
  return {
    final_action: d.final_action,
    action_priority: d.action_priority,
    priority_score: d.priority_score,
    route_source: d.source_tag,
  };
}

// ── SS001 종목 점수 계산 (spec/08_scoring_rules.yaml SS001_SECTOR_MODEL_SCORE) ──
// runDataFeed 루프에서 분리. 1개 종목 → 점수 객체 반환.
// ctx 필드: rsPct20D, avgTV5D, avgTV20D, flowCredit, epsRevisionStatus,
//           regimePrelim, isKosdaq, sfMedPE, sfMedPBR, forwardPE, pbrVal, epsGrowth1y
function calcSS001Score_(ctx) {
  // SS001_P: price_strength (max 25) — RS_Pct_20D → percentile 변환
  const rsPercentile = Number.isFinite(ctx.rsPct20D) ? (100 - ctx.rsPct20D) : null;
  const ss001_p = rsPercentile !== null ? (rsPercentile <= 30 ? 25 : rsPercentile <= 60 ? 15 : 0) : 0;

  // SS001_V: volume_quality (max 15)
  const volRatio = Number.isFinite(ctx.avgTV5D) && Number.isFinite(ctx.avgTV20D) && ctx.avgTV20D > 0
    ? ctx.avgTV5D / ctx.avgTV20D : null;
  const ss001_v = volRatio !== null ? (volRatio >= 1.20 ? 15 : volRatio >= 0.80 ? 8 : 0) : 0;

  // SS001_F: flow_quality (max 25)
  const fc = ctx.flowCredit ?? 0;
  const ss001_f = fc >= 0.70 ? 25 : fc >= 0.40 ? 12 : 0;

  // SS001_E: earnings_revision (max 20)
  const ss001_e = ctx.epsRevisionStatus === "UP" ? 20 : ctx.epsRevisionStatus === "FLAT" ? 10 : 0;

  // SS001_M: macro_regime (max 10)
  const r = ctx.regimePrelim ?? "";
  const ss001_m = (r === "RISK_ON" || r === "LEADER_CONCENTRATION" || r === "SECULAR_LEADER_RISK_ON")
    ? 10 : r === "NEUTRAL" ? 5 : 0;

  // SS001_VAL: valuation (max 5 KOSPI / max 12 KOSDAQ)
  let ss001_val = 0, pegVal = "", pegGate = "";
  if (ctx.isKosdaq) {
    const epsG = Number.isFinite(ctx.epsGrowth1y) && ctx.epsGrowth1y > 0 ? ctx.epsGrowth1y : null;
    if (Number.isFinite(ctx.forwardPE) && epsG !== null) {
      pegVal  = parseFloat((ctx.forwardPE / epsG).toFixed(2));
      pegGate = pegVal <= 1.5 ? "PASS" : pegVal <= 2.5 ? "CAUTION" : "REJECT";
      ss001_val = pegVal <= 1.0 ? 12 : pegVal <= 1.5 ? 9 : pegVal <= 2.0 ? 5 : pegVal <= 2.5 ? 2 : 0;
    } else if (Number.isFinite(ctx.forwardPE) && Number.isFinite(ctx.sfMedPE) && ctx.sfMedPE > 0) {
      pegGate   = "FALLBACK";
      ss001_val = ctx.forwardPE <= ctx.sfMedPE * 2.0 ? 9 : ctx.forwardPE <= ctx.sfMedPE * 3.0 ? 4 : 0;
    }
  } else {
    const peOk  = Number.isFinite(ctx.forwardPE) && Number.isFinite(ctx.sfMedPE)  && ctx.sfMedPE  > 0;
    const pbrOk = Number.isFinite(ctx.pbrVal)    && Number.isFinite(ctx.sfMedPBR) && ctx.sfMedPBR > 0;
    if (peOk || pbrOk) {
      const atOrBelow = (peOk && ctx.forwardPE <= ctx.sfMedPE) || (pbrOk && ctx.pbrVal <= ctx.sfMedPBR);
      const at1_5x    = (peOk && ctx.forwardPE <= ctx.sfMedPE * 1.5) || (pbrOk && ctx.pbrVal <= ctx.sfMedPBR * 1.5);
      ss001_val = atOrBelow ? 5 : at1_5x ? 2 : 0;
    }
  }

  const ss001_total = ss001_p + ss001_v + ss001_f + ss001_e + ss001_m + ss001_val;
  const ss001_norm  = ss001_total / (ctx.isKosdaq ? 107 : 100) * 100;
  const ss001_grade = ss001_norm >= 80 ? "A" : ss001_norm >= 65 ? "B" : ss001_norm >= 50 ? "C" : "D";

  return { ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val,
           ss001_total, ss001_norm, ss001_grade, pegVal, pegGate };
}

function buildAllowedAction(score, priceStatus, atr20, dartSummary, flowOk, avgTradingValue5D, spreadPct) {
  if (priceStatus !== "PRICE_OK" || !Number.isFinite(atr20)) return "OBSERVE_ONLY";
  if (dartSummary?.risk) return "HOLD_NO_ADD";
  if (!flowOk) return "NO_ADD";
  if (Number.isFinite(avgTradingValue5D) && avgTradingValue5D < 50) return "NO_ADD";
  if (Number.isFinite(spreadPct) && spreadPct > 0.8) return "NO_ADD";
  if (score >= 70 && dartSummary?.status === "NAVER_NOTICE_EMPTY") return "HOLD";
  if (score >= 50) return "CONDITIONAL_HOLD";
  return "SELL_ALLOWED";
}

function calcCoreCandidateQualityGrade_(ctx) {
  const score = parseFloat(ctx.rotationScore);
  const flowOk = String(ctx.flowOk ?? "") === "Y" || ctx.flowOk === true;
  const priceStatus = String(ctx.priceStatus ?? "");
  const liquidityStatus = String(ctx.liquidityStatus ?? "");
  const dartRisk = String(ctx.dartRisk ?? "").trim();
  const missing = String(ctx.missingFields ?? "").trim();
  if (priceStatus !== "PRICE_OK" || missing || dartRisk || ["LOW", "DATA_MISSING"].includes(liquidityStatus)) return "D";
  if (Number.isFinite(score) && score >= 80 && flowOk) return "A";
  if (Number.isFinite(score) && score >= 65 && flowOk) return "B";
  if (Number.isFinite(score) && score >= 50) return "C";
  return "D";
}

function calcT1ForcedSellRisk_(ctx) {
  let score = 0;
  const reasons = [];
  const sellAction = String(ctx.sellAction ?? "");
  const sellValidation = String(ctx.sellValidation ?? "");
  const timingExit = parseFloat(ctx.timingScoreExit);
  const rwPartial = parseFloat(ctx.rwPartial);
  const rsi14 = parseFloat(ctx.rsi14);
  const disparity = parseFloat(ctx.disparity);
  const valSurge = parseFloat(ctx.valSurgePct);
  const ret5D = parseFloat(ctx.ret5D);
  const dartRisk = String(ctx.dartRisk ?? "").trim();
  const lateChase = parseFloat(ctx.lateChaseRiskScore);
  const distribution = parseFloat(ctx.distributionRiskScore);

  if (sellAction && sellAction !== "HOLD" && sellValidation !== "NO_SELL_ACTION") {
    score += 40;
    reasons.push("SELL_ACTION_ACTIVE");
  }
  if (Number.isFinite(timingExit) && timingExit >= 50) {
    score += 25;
    reasons.push("TIMING_EXIT>=50");
  }
  if (Number.isFinite(rwPartial) && rwPartial >= 2) {
    score += 25;
    reasons.push("RW>=2");
  }
  if (Number.isFinite(distribution) && distribution >= 70) {
    score += 30;
    reasons.push("DISTRIBUTION>=70");
  }
  if (Number.isFinite(lateChase) && lateChase >= 70) {
    score += 25;
    reasons.push("LATE_CHASE>=70");
  }
  if ((Number.isFinite(rsi14) && rsi14 > 75) || (Number.isFinite(disparity) && disparity > 12)) {
    score += 20;
    reasons.push("OVERHEATED");
  }
  if (Number.isFinite(valSurge) && valSurge >= 40 && Number.isFinite(ret5D) && ret5D > 8) {
    score += 15;
    reasons.push("SURGE_AFTER_RUNUP");
  }
  if (dartRisk) {
    score += 30;
    reasons.push("DART_RISK");
  }
  score = Math.max(0, Math.min(100, Math.round(score)));
  const state = score >= 70 ? "BUY_BLOCKED_T1_EXIT_RISK" : score >= 50 ? "WATCH_ONLY_T1_RISK" : "PASS";
  return { score, state, reason: reasons.join("|") || "PASS" };
}

function calcSellConflictScore_(ctx) {
  let score = 0;
  const reasons = [];
  const sellFinal = String(ctx.sellFinal ?? "");
  const sellAction = String(ctx.sellAction ?? "");
  const cashStyle = String(ctx.cashPreserveStyle ?? "");
  const allowedAction = String(ctx.allowedAction ?? "");
  if (["SELL_READY", "EXIT_SIGNAL", "EXIT_REVIEW"].includes(sellFinal) || (sellAction && sellAction !== "HOLD")) {
    score += 55;
    reasons.push("SELL_SIGNAL_ACTIVE");
  }
  if (cashStyle && cashStyle !== "NONE") {
    score += 20;
    reasons.push("CASH_PRESERVE_ACTIVE");
  }
  if (["NO_ADD", "HOLD_NO_ADD", "OBSERVE_ONLY"].includes(allowedAction)) {
    score += 20;
    reasons.push("NO_ADD_GATE");
  }
  score = Math.max(0, Math.min(100, Math.round(score)));
  const state = score >= 70 ? "BUY_BLOCKED_SELL_CONFLICT" : score >= 40 ? "SELL_OR_TRIM_FIRST" : "PASS";
  return { score, state, reason: reasons.join("|") || "PASS" };
}

function calcCoreSatelliteExecutionState_(ctx) {
  const quality = String(ctx.candidateQualityGrade ?? "");
  const timingAction = String(ctx.timingAction ?? "");
  const entryGate = String(ctx.entryModeGate ?? "");
  const t1State = String(ctx.t1State ?? "");
  const sellConflictState = String(ctx.sellConflictState ?? "");
  const allowedAction = String(ctx.allowedAction ?? "");
  if (sellConflictState === "BUY_BLOCKED_SELL_CONFLICT" || sellConflictState === "SELL_OR_TRIM_FIRST") return sellConflictState;
  if (t1State === "BUY_BLOCKED_T1_EXIT_RISK" || t1State === "WATCH_ONLY_T1_RISK") return t1State;
  if (["NO_ADD", "HOLD_NO_ADD", "OBSERVE_ONLY"].includes(allowedAction)) return "BUY_BLOCKED_PORTFOLIO_GUARD";
  if (quality === "A" && entryGate === "PASS" && ["BUY_STAGE1_READY", "BUY_BREAKOUT_PILOT_ONLY"].includes(timingAction)) return "BUY_PILOT_ALLOWED";
  if (quality === "A" || quality === "B") {
    if (entryGate === "PASS") return "WATCH_BREAKOUT_RETEST";
    return "WATCH_PULLBACK";
  }
  return "CANDIDATE_ONLY";
}

function calcApexTradePlan_(h, df, h1, alphaRow, ftRow, distRow, priceRow, orderRow, sq, profitRow, cashShortfallInfo, saqgState) {
  // THIN_ADAPTER: [sizing/normalize] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_position_size
  var buyState = 'BLOCKED';
  var buyReasons = [];
  if (h1.cashFloorStatus !== 'PASS') buyReasons.push('cash_floor_not_pass');
  if (h1.heatGate === 'BLOCK_NEW_BUY') buyReasons.push('heat_block_new_buy');
  if (distRow.anti_distribution_state !== 'PASS') buyReasons.push('distribution_' + distRow.anti_distribution_state);
  if (alphaRow.lead_entry_state === 'PILOT_ALLOWED' && buyReasons.length === 0) buyState = 'ALLOW_PILOT';
  else if (ftRow.follow_through_state === 'CONFIRMED_ADD_ON' && buyReasons.length === 0) buyState = 'ALLOW_ADD_ON';
  else if (buyReasons.length === 0) buyState = 'WATCH';
  if (saqgState === 'EXCLUDED') {
    buyState = 'BLOCKED';
    buyReasons.push('saqg_EXCLUDED');
  } else if (saqgState === 'WATCHLIST_ONLY' && (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON')) {
    buyState = 'WATCH';
    buyReasons.push('saqg_WATCHLIST_ONLY');
  }

  var style = 'URGENT_LIQUIDITY_TRIM';
  if ((df.rsi14 && df.rsi14 < 35) || (df.bbPosition && df.bbPosition < 20) || (df.ma20 && h.close && h.close < df.ma20 * 0.92)) {
    style = 'OVERSOLD_REBOUND_SELL';
  } else if (distRow.anti_distribution_state === 'BLOCK_BUY') {
    style = 'DISTRIBUTION_EXIT';
  } else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20'
      || profitRow.profit_preservation_state === 'PROFIT_LOCK_30'
      || profitRow.profit_preservation_state === 'APEX_TRAILING') {
    style = 'PROFIT_PROTECT_TRIM';
  }

  var baseQty = typeof sq.sell_qty === 'number' ? sq.sell_qty : 0;
  var close = h.close || df.close || 0;
  var prevClose = df.prevClose || close;
  var atr20 = df.atr20 || 0;
  var holdingQty = h.holdingQty || 0;
  var shortfallMin = cashShortfallInfo.cash_shortfall_min_krw || 0;

  var immediateQty;
  var reboundQty;
  var k2Emergency;
  if (style === 'OVERSOLD_REBOUND_SELL') {
    var halfQty = Math.floor(baseQty / 2);
    var halfExpectedKrw = halfQty * close;
    k2Emergency = shortfallMin > 0 && (halfExpectedKrw * 2 < shortfallMin);
    if (k2Emergency) {
      immediateQty = baseQty;
      reboundQty = 0;
    } else {
      immediateQty = halfQty;
      reboundQty = Math.max(0, baseQty - halfQty);
    }
    var overSoldCap = holdingQty;
    if (profitRow.profit_preservation_state === 'PROFIT_LOCK_30' || profitRow.profit_preservation_state === 'APEX_TRAILING') {
      overSoldCap = Math.floor(holdingQty * 0.40);
    } else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20') {
      overSoldCap = Math.floor(holdingQty * 0.35);
    } else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_10') {
      overSoldCap = Math.floor(holdingQty * 0.30);
    } else {
      overSoldCap = Math.floor(holdingQty * 0.50);
    }
    immediateQty = Math.min(immediateQty, overSoldCap);
  } else {
    k2Emergency = false;
    var capPct = 50;
    if (style === 'PROFIT_PROTECT_TRIM') {
      if (profitRow.profit_preservation_state === 'PROFIT_LOCK_30' || profitRow.profit_preservation_state === 'APEX_TRAILING') capPct = 50;
      else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20') capPct = 35;
      else capPct = 25;
    } else if (style === 'DISTRIBUTION_EXIT') {
      capPct = 50;
    }
    immediateQty = Math.min(baseQty, Math.floor(holdingQty * capPct / 100));
    reboundQty = 0;
  }

  var hasPosition = holdingQty > 0;
  var tranchePhase;
  var currentTrancheAllowedPct;
  var nextTrancheCondition;
  if (!hasPosition) {
    if (alphaRow.lead_entry_state === 'PILOT_ALLOWED' && buyState === 'ALLOW_PILOT') {
      tranchePhase = 'TRANCHE_1_PILOT';
      currentTrancheAllowedPct = 30;
      nextTrancheCondition = 'CONFIRMED_ADD_ON';
    } else {
      tranchePhase = 'WAIT_PILOT_SETUP';
      currentTrancheAllowedPct = 0;
      nextTrancheCondition = 'ALPHA_LEAD_SCORE_GTE_75_AND_DISTRIBUTION_PASS';
    }
  } else if (ftRow.follow_through_state === 'CONFIRMED_ADD_ON' && buyState === 'ALLOW_ADD_ON') {
    tranchePhase = 'TRANCHE_2_ADD_ON';
    currentTrancheAllowedPct = 30;
    nextTrancheCondition = 'SECONDARY_PULLBACK_TO_MA20';
  } else if (alphaRow.close_vs_ma20_pct !== null && alphaRow.close_vs_ma20_pct <= 2
             && profitRow.profit_pct > 3 && ftRow.follow_through_state !== 'FAILED_BREAKOUT'
             && buyState === 'ALLOW_ADD_ON') {
    tranchePhase = 'TRANCHE_3_PULLBACK_ADD';
    currentTrancheAllowedPct = 40;
    nextTrancheCondition = 'HOLD_FULL_POSITION';
  } else {
    tranchePhase = 'HOLD_CURRENT';
    currentTrancheAllowedPct = 0;
    nextTrancheCondition = ftRow.follow_through_state === 'FAILED_BREAKOUT'
      ? 'RECOVERY_ABOVE_MA20' : 'CONFIRMED_ADD_ON_OR_PULLBACK';
  }

  var sellRawPrice = null;
  if (close > 0) {
    if (style === 'URGENT_LIQUIDITY_TRIM') {
      sellRawPrice = prevClose > 0 ? Math.min(close, prevClose * 0.998) : close * 0.998;
    } else if (style === 'OVERSOLD_REBOUND_SELL') {
      sellRawPrice = close;
    } else if (style === 'DISTRIBUTION_EXIT') {
      sellRawPrice = atr20 > 0 ? close - 0.25 * atr20 : close * 0.997;
    } else if (style === 'PROFIT_PROTECT_TRIM') {
      var ratchetStop = priceRow.ratchet_stop_price || 0;
      sellRawPrice = ratchetStop > 0 ? Math.max(ratchetStop, close * 0.999) : close * 0.999;
    }
  }
  var buyRawPrice = null;
  if (close > 0) {
    if (buyState === 'ALLOW_PILOT') {
      buyRawPrice = Math.min(close * 1.002, df.ma20 > 0 ? df.ma20 * 1.08 : close * 1.002);
    } else if (buyState === 'ALLOW_ADD_ON') {
      buyRawPrice = prevClose > 0 ? Math.min(close * 1.002, prevClose * 1.01) : close * 1.002;
    }
  }
  var normalizedSellPrice = (sellRawPrice && sellRawPrice > 0) ? tickNormalize_(sellRawPrice) : null;
  var normalizedBuyPrice  = (buyRawPrice  && buyRawPrice  > 0) ? tickNormalize_(buyRawPrice)  : null;
  var htsLimitPrice = orderRow.limit_price_krw
    ? tickNormalize_(orderRow.limit_price_krw)
    : normalizedSellPrice || normalizedBuyPrice;

  return {
    buyState: buyState,
    buyReasons: buyReasons,
    style: style,
    immediateQty: immediateQty,
    reboundQty: reboundQty,
    k2Emergency: k2Emergency,
    tranchePhase: tranchePhase,
    currentTrancheAllowedPct: currentTrancheAllowedPct,
    nextTrancheCondition: nextTrancheCondition,
    normalizedSellPrice: normalizedSellPrice,
    normalizedBuyPrice: normalizedBuyPrice,
    htsLimitPrice: htsLimitPrice,
  };
}

// ── account_snapshot 읽기 → TOTAL_HEAT_V1 계산 ───────────────────────────────
// account_snapshot이 보유수량·평단·선택 손절가의 단일 원장이다.
// stop_price 미입력이면 ATR 기반 추정으로 대체.
// total_asset_krw를 인수로 받아야 정확한 열%를 계산할 수 있음; 미제공 시 null.
function readAccountSnapshotHeat_(total_asset_krw) {
  const UNKNOWN = { total_heat_pct: null, total_heat_krw: null,
                    hf005_status: "UNKNOWN (account_snapshot 없음)", positions_count: 0 };
  try {
    const ss = getSpreadsheet_();
    const snapshot = readAccountSnapshotMap_();
    if (!snapshot.rows_confirmed) return UNKNOWN;

    // data_feed ATR20 읽기 (stop_price 미입력 시 추정용)
    const atrMap = {};
    try {
      const dfSheet = ss.getSheetByName("data_feed");
      if (dfSheet) {
        const dfData = dfSheet.getDataRange().getValues();
        const dfHdr  = dfData[1]?.map(h => String(h).trim()) ?? [];
        const dfTkr  = dfHdr.indexOf("Ticker");
        const dfAtr  = dfHdr.indexOf("ATR20");
        const dfClose= dfHdr.indexOf("Close");
        if (dfTkr >= 0 && dfAtr >= 0 && dfClose >= 0) {
          for (let i = 2; i < dfData.length; i++) {
            const tk = String(dfData[i][dfTkr]).trim();
            const atr = parseFloat(dfData[i][dfAtr]);
            const cls = parseFloat(dfData[i][dfClose]);
            if (tk && Number.isFinite(atr) && Number.isFinite(cls)) atrMap[tk] = { atr20: atr, close: cls };
          }
        }
      }
    } catch(e2) { }

    let totalHeatKrw = 0;
    let posCount = 0;
    let hasEstimate = false;
    const details = [];

    Object.values(snapshot.positions).forEach(pos => {
      const qty = parseInt(pos.quantity, 10);
      if (!Number.isFinite(qty) || qty <= 0) return;
      const entry = parseFloat(pos.average_cost ?? pos.entry_price);
      if (!Number.isFinite(entry) || entry <= 0) return;

      let stop = parseFloat(pos.stop_price);
      if (!Number.isFinite(stop) || stop <= 0) {
        // ATR 기반 추정
        const tk = pos.ticker;
        const atrInfo = atrMap[tk];
        if (atrInfo) {
          stop = entry - atrInfo.atr20 * THRESHOLDS.ATR_TRAILING_MULT;
          hasEstimate = true;
        } else {
          stop = entry * 0.92; // 8% 고정 추정
          hasEstimate = true;
        }
      }
      if (stop >= entry) return; // PS002 위반 행 건너뜀

      const heatKrw = (entry - stop) * qty;
      totalHeatKrw += heatKrw;
      posCount++;
      details.push(`${qty}주×${Math.round(entry-stop)}원`);
    });

    if (posCount === 0) return { total_heat_pct: 0, total_heat_krw: 0,
                                  hf005_status: "PASS (포지션 없음)", positions_count: 0 };

    const estTag = hasEstimate ? "(ATR추정)" : "";
    if (!Number.isFinite(total_asset_krw) || total_asset_krw <= 0) {
      return {
        total_heat_pct: null,
        total_heat_krw: Math.round(totalHeatKrw),
        hf005_status: `UNKNOWN (총자산 미제공)${estTag}`,
        positions_count: posCount,
      };
    }

    const heatPct = (totalHeatKrw / total_asset_krw) * 100;
    const hf005 = heatPct >= 10
      ? `BLOCK (>= 10%: ${heatPct.toFixed(1)}%)${estTag}`
      : `PASS (< 10%: ${heatPct.toFixed(1)}%)${estTag}`;

    return {
      total_heat_pct: parseFloat(heatPct.toFixed(2)),
      total_heat_krw: Math.round(totalHeatKrw),
      hf005_status: hf005,
      positions_count: posCount,
    };
  } catch(e) {
    handleFetchError_("readAccountSnapshotHeat_", e, "WARN");
    return { total_heat_pct: null, total_heat_krw: null,
             hf005_status: "ERROR: " + e.message, positions_count: 0 };
  }
}

// 상승 추세 보존 점수: 높을수록 매도 우선순위를 늦춘다.
function calcReboundHoldbackScore_(ctx) {
  const close = parseFloat(ctx.close);
  const ma20 = parseFloat(ctx.ma20);
  const ma60 = parseFloat(ctx.ma60);
  const ma20Slope = parseFloat(ctx.ma20Slope);
  const rsi14 = parseFloat(ctx.rsi14);
  const bbPosition = parseFloat(ctx.bbPosition);
  const flowCredit = parseFloat(ctx.flowCredit);
  const leaderTotal = parseFloat(ctx.leaderTotal);
  const leaderGate = String(ctx.leaderGate ?? "");
  const bandStatus = String(ctx.bandStatus ?? "");
  const profitPct = parseFloat(ctx.profitPct);
  const isCoreLeader = !!ctx.isCoreLeader;

  let score = 0;
  const reasons = [];
  const aboveMa20 = Number.isFinite(close) && Number.isFinite(ma20) && close >= ma20;
  const aboveMa60 = Number.isFinite(close) && Number.isFinite(ma60) && close >= ma60;

  if (isCoreLeader && aboveMa20 && Number.isFinite(ma20Slope) && ma20Slope > 0) {
    score += 12;
    reasons.push("core_uptrend:+12");
  } else if (aboveMa20 && Number.isFinite(ma20Slope) && ma20Slope > 0) {
    score += 8;
    reasons.push("trend_hold:+8");
  }

  if (Number.isFinite(leaderTotal) && leaderTotal >= 80) {
    score += 6;
    reasons.push("leader_total:+6");
  } else if (leaderGate === "PASS") {
    score += 4;
    reasons.push("leader_pass:+4");
  }

  if (Number.isFinite(flowCredit) && flowCredit >= 0.7) {
    score += 6;
    reasons.push("flow_strong:+6");
  }

  if (Number.isFinite(rsi14)) {
    if (rsi14 <= 62) {
      score += 4;
      reasons.push("rsi_room:+4");
    } else if (rsi14 >= 72) {
      score -= 6;
      reasons.push("rsi_hot:-6");
    }
  }

  if (Number.isFinite(bbPosition) && bbPosition <= 0.7) {
    score += 3;
    reasons.push("bb_room:+3");
  }

  if (bandStatus === "UNDERWEIGHT") {
    score += 3;
    reasons.push("band_under:+3");
  }

  if (Number.isFinite(profitPct) && profitPct >= 0 && aboveMa20 && aboveMa60) {
    score += 3;
    reasons.push("runner:+3");
  }

  return {
    score: Math.max(0, Math.min(30, score)),
    reasons: reasons.join(" | "),
  };
}

// 현금확보 시 반등 보존형 감축 계획.
// score는 sell_priority_score에서 보호 보너스로 쓰고, recommended_ratio는 주문 감축비율로 쓴다.
function calcCashPreservationPlan_(ctx) {
  const cashFloorStatus = String(ctx.cashFloorStatus ?? "");
  const regime = String(ctx.regime ?? "");
  const sellAction = String(ctx.sellAction ?? ctx.action ?? "");
  const isSellLike = /(SELL|TRIM|EXIT)/.test(sellAction);
  const isCoreLeader = !!ctx.isCoreLeader;
  const isEtf = !!ctx.isEtf;
  const liquidityStatus = String(ctx.liquidityStatus ?? "");
  const spreadStatus = String(ctx.spreadStatus ?? "");
  const accountType = String(ctx.accountType ?? "");
  const profitPct = parseFloat(ctx.profitPct);
  const rwPartial = parseInt(ctx.rwPartial, 10) || 0;
  const reboundHoldback = parseFloat(ctx.reboundHoldbackScore);
  const holdbackScore = Number.isFinite(reboundHoldback) ? reboundHoldback : 0;

  let recommendedRatio = isSellLike ? 50 : 0;
  let style = "STEP_50";
  let protectionBonus = 0;
  const reasons = [];

  if (isCoreLeader && holdbackScore >= 12) {
    style = "CORE_LAST";
    recommendedRatio = cashFloorStatus === "TRIM_REQUIRED" ? 25 : 0;
    protectionBonus += 12;
    reasons.push("core_last");
  } else if (holdbackScore >= 18) {
    style = "STEP_25";
    recommendedRatio = 25;
    protectionBonus += 10;
    reasons.push("strong_rebound");
  } else if (holdbackScore >= 10) {
    style = "STEP_33";
    recommendedRatio = 33;
    protectionBonus += 6;
    reasons.push("rebound_preserve");
  }

  if (isEtf && holdbackScore < 10) {
    protectionBonus -= 2;
    reasons.push("etf_cash_raise");
  }

  if (cashFloorStatus === "TRIM_REQUIRED" || /RISK_OFF/.test(regime)) {
    protectionBonus += 2;
    reasons.push("cash_preserve");
  }

  if (liquidityStatus === "LOW" || spreadStatus === "WIDE" || spreadStatus === "BLOCK") {
    protectionBonus += 4;
    reasons.push("impact_avoid");
  }

  if (accountType === "일반계좌" && Number.isFinite(profitPct) && profitPct > 0) {
    protectionBonus += profitPct >= 20 ? 3 : 2;
    reasons.push("tax_drag");
  } else if (accountType === "일반계좌" && Number.isFinite(profitPct) && profitPct < 0) {
    protectionBonus -= 2;
    reasons.push("tax_loss_harvest");
  }

  if (rwPartial >= 3 && !isCoreLeader) {
    recommendedRatio = Math.max(recommendedRatio, 50);
    protectionBonus -= 4;
    reasons.push("rw_force");
  }

  if (cashFloorStatus === "HARD_BLOCK") {
    recommendedRatio = Math.max(recommendedRatio, 50);
    reasons.push("cash_hard_block");
  }

  if (!isSellLike) recommendedRatio = 0;
  recommendedRatio = Math.max(0, Math.min(50, recommendedRatio));

  return {
    style,
    recommended_ratio: recommendedRatio,
    protection_bonus: Math.max(0, Math.round(protectionBonus)),
    reasons: reasons.join(" | "),
  };
}

// ── 메인: 보유 종목 완성도 매트릭스 ─────────────────────────────────────
// data_feed는 보유 종목 원장 + 완성도 매트릭스의 canonical output.
// ── Sell_Priority_Score 산출 헬퍼 ────────────────────────────────────────────
// spec: spec/risk/portfolio_exposure.yaml:sell_priority_engine.candidate_scoring
// 입력: row 배열(data_feed headers 순서), headers 배열, sectorExposureMap(섹터→총비중%)
// 반환: { score, breakdown, priority_level, is_etf, is_core_leader }
// 호출 시점: runDataFeed post-loop(섹터집계 완료 후) & getDailyBrief/runSellPriority
var calcSellSignalSanityScore_ = function(row, headers, sectorExposureMap) {
  const get = (col) => {
    const i = headers.indexOf(col);
    return i >= 0 ? row[i] : undefined;
  };
  const flt = (col) => { const v = parseFloat(get(col)); return Number.isFinite(v) ? v : null; };

  const finalAction = String(get("Final_Action")  ?? "");
  const sellAction  = String(get("Sell_Action")   ?? "");
  const ticker      = String(get("Ticker")        ?? "");
  const name_       = String(get("Name")          ?? "");
  const rwPartial   = parseInt(get("RW_Partial"))  || 0;
  const weightPct   = flt("Weight_Pct") ?? 0;
  const profitPct   = flt("Profit_Pct");
  const close_      = flt("Close");
  const ma20_       = flt("MA20");
  const ma60_       = flt("MA60");
  const ma20Slope_  = flt("MA20_Slope");
  const rsi14_      = flt("RSI14");
  const bbPos_      = flt("BB_Position");
  const flowCredit_ = flt("Flow_Credit");
  const leaderTotal_= flt("Leader_Scan_Total");
  const leaderGate_ = String(get("Leader_Gate") ?? "");
  const bandStatus_ = String(get("Band_Status") ?? "");
  const ss001Grade  = String(get("SS001_Grade")   ?? "");
  const liquidityStatus_ = String(get("Liquidity_Status") ?? "");
  const avgTradeValue5DM_ = flt("AvgTradeValue_5D_M");
  const avgTradeValue5DKrw_= flt("AvgTradeValue_5D_KRW");
  const spreadStatus_ = String(get("Spread_Status") ?? "");
  const accountType_   = String(get("account_type") ?? get("Account_Type") ?? "");
  const taxCostEstimate_ = flt("Tax_Cost_Estimate");

  // ETF 여부: 이름 패턴 기준
  const isEtf = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(name_);
  // 직접 코어 주도주 (삼성전자·SK하이닉스)
  const isCoreLeader = (ticker === "005930" || ticker === "000660");
  // 상승추세 여부
  const inUptrend = Number.isFinite(close_) && Number.isFinite(ma20_) && close_ >= ma20_;
  // 섹터 총노출
  const sector     = TICKER_SECTOR_MAP[ticker] ?? "";
  const sectorExp  = (sectorExposureMap ?? {})[sector] ?? 0;

  let score = 0;
  const breakdown = [];

  // ── 1. hard_precedence_points ─────────────────────────────────────────────
  if (sellAction === "EXIT_100" || finalAction === "EXIT_SIGNAL") {
    score += THRESHOLDS.SP_HARD_STOP;
    breakdown.push(`hard_stop:+${THRESHOLDS.SP_HARD_STOP}`);
  } else if (finalAction === "SELL_READY" ||
             sellAction.includes("TRIM") || sellAction.includes("EXIT")) {
    score += THRESHOLDS.SP_SELL_SIGNAL;
    breakdown.push(`sell_signal:+${THRESHOLDS.SP_SELL_SIGNAL}`);
  } else if (finalAction === "EXIT_REVIEW") {
    score += THRESHOLDS.SP_HOLDINGS_ROTATE;
    breakdown.push(`exit_review:+${THRESHOLDS.SP_HOLDINGS_ROTATE}`);
  } else if (Number.isFinite(profitPct) && profitPct >= 10) {
    score += THRESHOLDS["SP_TAKE_PROFIT"];
    breakdown.push(`take_profit:+${THRESHOLDS["SP_TAKE_PROFIT"]}`);
  }

  // ── 2. duplicate_exposure_points (ETF 중복 노출) ──────────────────────────
  if (isEtf) {
    if (sectorExp >= THRESHOLDS.SP_DUPLICATE_THRESH) {
      score += THRESHOLDS.SP_ETF_DUPLICATE;
      breakdown.push(`etf_dup(${sector}${sectorExp.toFixed(1)}%):+${THRESHOLDS.SP_ETF_DUPLICATE}`);
    } else if (sectorExp >= 10) {
      score += THRESHOLDS.SP_ETF_MODERATE;
      breakdown.push(`etf_moderate:+${THRESHOLDS.SP_ETF_MODERATE}`);
    }
  }

  // ── 3. cash_relief_points (보유 비중 → 현금 회복 기여) ────────────────────
  if (weightPct >= 3) {
    score += THRESHOLDS.SP_CASH_LARGE;
    breakdown.push(`cash_${weightPct.toFixed(1)}%:+${THRESHOLDS.SP_CASH_LARGE}`);
  } else if (weightPct >= 1) {
    score += THRESHOLDS.SP_CASH_MID;
    breakdown.push(`cash_mid:+${THRESHOLDS.SP_CASH_MID}`);
  } else {
    score += THRESHOLDS.SP_CASH_SMALL;
  }

  // ── 4. weakness_points ───────────────────────────────────────────────────
  if (rwPartial >= 4) {
    score += THRESHOLDS.SP_RW4;
    breakdown.push(`rw${rwPartial}:+${THRESHOLDS.SP_RW4}`);
  } else if (rwPartial === 3) {
    score += THRESHOLDS.SP_RW3;
    breakdown.push(`rw3:+${THRESHOLDS.SP_RW3}`);
  } else if (rwPartial === 2) {
    score += THRESHOLDS.SP_RW2;
    breakdown.push(`rw2:+${THRESHOLDS.SP_RW2}`);
  }
  if (Number.isFinite(close_) && Number.isFinite(ma20_) && close_ < ma20_) {
    score += THRESHOLDS.SP_BELOW_MA20;
    breakdown.push(`below_ma20:+${THRESHOLDS.SP_BELOW_MA20}`);
  }
  // 손실 위성: -10% 이하, 비ETF, 비코어리더
  if (!isEtf && !isCoreLeader && Number.isFinite(profitPct) && profitPct <= -10) {
    score += THRESHOLDS.SP_LOSS_SATELLITE;
    breakdown.push(`loss_sat(${profitPct.toFixed(1)}%):+${THRESHOLDS.SP_LOSS_SATELLITE}`);
  }

  // ── 5. overweight_points ─────────────────────────────────────────────────
  const targetW = isEtf ? 7 : (isCoreLeader ? 15 : 7);
  const excess  = weightPct - targetW;
  if (excess >= 5) {
    score += THRESHOLDS.SP_OVERWEIGHT_LARGE;
    breakdown.push(`overweight:+${THRESHOLDS.SP_OVERWEIGHT_LARGE}`);
  } else if (excess >= 2) {
    score += THRESHOLDS.SP_OVERWEIGHT_MID;
    breakdown.push(`overweight:+${THRESHOLDS.SP_OVERWEIGHT_MID}`);
  }

  // ── 6. core_quality_protection_points (음수 패널티) ──────────────────────
  if (isCoreLeader && inUptrend) {
    score += THRESHOLDS.SP_CORE_LEADER;   // -20
    breakdown.push(`core_leader_uptrend:${THRESHOLDS.SP_CORE_LEADER}`);
  }
  if (ss001Grade === "A") {
    score += THRESHOLDS.SP_SS001_A;       // -12
    breakdown.push(`ss001_A:${THRESHOLDS.SP_SS001_A}`);
  }

  const reboundHoldback_ = calcReboundHoldbackScore_({
    close: close_,
    ma20: ma20_,
    ma60: ma60_,
    ma20Slope: ma20Slope_,
    rsi14: rsi14_,
    bbPosition: bbPos_,
    flowCredit: flowCredit_,
    leaderTotal: leaderTotal_,
    leaderGate: leaderGate_,
    bandStatus: bandStatus_,
    profitPct: profitPct,
    isCoreLeader: isCoreLeader,
  });
  if (reboundHoldback_.score > 0) {
    score -= reboundHoldback_.score;
    breakdown.push(`rebound_holdback:-${reboundHoldback_.score}${reboundHoldback_.reasons ? `(${reboundHoldback_.reasons})` : ""}`);
  }

  const preservationPlan_ = calcCashPreservationPlan_({
    sellAction: finalAction,
    cashFloorStatus: String(get("Cash_Floor_Status") ?? ""),
    regime: String(get("Market_Regime") ?? ""),
    isCoreLeader: isCoreLeader,
    isEtf: isEtf,
    liquidityStatus: liquidityStatus_,
    spreadStatus: spreadStatus_,
    accountType: accountType_,
    profitPct: profitPct,
    rwPartial: rwPartial,
    reboundHoldbackScore: reboundHoldback_.score,
  });
  if (preservationPlan_.protection_bonus > 0) {
    score -= preservationPlan_.protection_bonus;
    breakdown.push(`cash_preserve:-${preservationPlan_.protection_bonus}${preservationPlan_.reasons ? `(${preservationPlan_.reasons})` : ""}`);
  }

  if (liquidityStatus_ === "OK" || (Number.isFinite(avgTradeValue5DM_) && avgTradeValue5DM_ >= 1000) || (Number.isFinite(avgTradeValue5DKrw_) && avgTradeValue5DKrw_ >= 1000000000)) {
    score += 5;
    breakdown.push("liquidity_ok:+5");
  } else if (liquidityStatus_ === "LOW" || (Number.isFinite(avgTradeValue5DM_) && avgTradeValue5DM_ > 0 && avgTradeValue5DM_ < 100)) {
    score -= 10;
    breakdown.push("liquidity_low:-10");
  }

  let taxPenalty = 3;
  let taxReason = "tax_unknown";
  if (accountType_ === "ISA" || accountType_ === "연금저축") {
    taxPenalty = 0;
    taxReason = "tax_exempt";
  } else if (Number.isFinite(taxCostEstimate_) && taxCostEstimate_ > 0) {
    taxPenalty = Math.min(10, Math.round(taxCostEstimate_));
    taxReason = "tax_cost_estimate";
  } else if (Number.isFinite(profitPct) && profitPct > 0) {
    taxPenalty = profitPct >= 20 ? 10 : 5;
    taxReason = "tax_drag";
  } else if (Number.isFinite(profitPct) && profitPct < 0) {
    taxPenalty = -5;
    taxReason = "tax_loss_harvest";
  }
  score -= taxPenalty;
  breakdown.push(`tax_penalty:-${taxPenalty}${taxReason ? `(${taxReason})` : ""}`);

  // 우선순위 단계 레이블 (spec: funding_order ①~④)
  let priority_level;
  if (sellAction === "EXIT_100" || finalAction === "EXIT_SIGNAL") {
    priority_level = "1_hard_stop";
  } else if (finalAction === "SELL_READY") {
    priority_level = "2_sell_signal";
  } else if (isEtf && sectorExp >= 10) {
    priority_level = "3_duplicate_etf";
  } else if (!isEtf && !isCoreLeader && Number.isFinite(profitPct) && profitPct <= -10) {
    priority_level = "4_loss_satellite";
  } else if (!isCoreLeader && rwPartial >= 3) {
    priority_level = "5_rw_weakness";
  } else if (Number.isFinite(profitPct) && profitPct >= 10) {
    priority_level = "6_profit_lock";
  } else if (isCoreLeader && inUptrend) {
    priority_level = "9_core_leader_last";
  } else {
    priority_level = "7_general_rebalance";
  }

  return {
    score:               Math.min(100, Math.max(0, score)),
    breakdown:           breakdown.join(" | "),
    priority_level,
    is_etf:              isEtf,
    is_core_leader:      isCoreLeader,
    sector,
    sector_exposure_pct: parseFloat(sectorExp.toFixed(1)),
    rebound_holdback_score: reboundHoldback_.score,
    rebound_holdback_reason: reboundHoldback_.reasons,
    cash_preserve_style: preservationPlan_.style,
    cash_preserve_ratio: preservationPlan_.recommended_ratio,
    cash_preserve_reason: preservationPlan_.reasons,
  };
}

// Backward-compatible thin wrapper.
// Existing data_feed callers still expect calcSellPriorityScore_.
var calcSellPriorityScore_ = function(row, headers, sectorExposureMap) {
  return calcSellSignalSanityScore_(row, headers, sectorExposureMap);
};

// ── sell_priority_engine: 전 보유종목 매도 우선순위 순위표 ──────────────────
// spec: spec/risk/portfolio_exposure.yaml:sell_priority_engine
// doGet: ?view=sell_priority
// 활성화 조건: 현금 < 목표, REGIME_TRIM_50, 또는 SELL/TRIM 후보 2개 이상
// 핵심 보호 원칙: SK하이닉스·삼성전자(코어 주도주)는 hard_stop 없이는 마지막 순위
function runSellPriority() {
  const port     = getPortfolioJson();
  const macro    = getMacroJson();
  const holdings = port.holdings ?? [];
  const regime_  = String(macro.market_regime ?? "");
  const computedAt_ = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd'T'HH:mm:ssXXX");

  // 섹터 노출 집계
  const sectorExpMap_ = {};
  holdings.forEach(h => {
    const sec_ = TICKER_SECTOR_MAP[h.Ticker] ?? "";
    const w_   = parseFloat(h.Weight_Pct);
    if (sec_ && Number.isFinite(w_) && w_ > 0)
      sectorExpMap_[sec_] = (sectorExpMap_[sec_] || 0) + w_;
  });

  const validWeightCount_ = holdings.filter(h => {
    const w = parseFloat(h.Weight_Pct);
    return Number.isFinite(w) && w > 0;
  }).length;
  const missingWeightCount_ = holdings.length - validWeightCount_;
  const asConfirmStats_ = getAccountSnapshotConfirmStats_();

  const rows_ = holdings
    .filter(h => {
      const w = parseFloat(h.Weight_Pct);
      return Number.isFinite(w) && w > 0;
    })
    .map(h => {
      const isEtf_  = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(h.Name);
      const isCL_   = (h.Ticker === "005930" || h.Ticker === "000660");
      const sec_    = TICKER_SECTOR_MAP[h.Ticker] ?? "";
      const sExp_   = sectorExpMap_[sec_] ?? 0;
      const pctP_   = parseFloat(h.Profit_Pct);
      const rw_     = parseInt(h.RW_Partial) || 0;
      const cl_     = parseFloat(h.Close);
      const ma20_   = parseFloat(h.MA20);
      const ma60_   = parseFloat(h.MA60);
      const ma20Slope_ = parseFloat(h.MA20_Slope);
      const rsi14_  = parseFloat(h.RSI14);
      const bbPos_  = parseFloat(h.BB_Position);
      const flowCredit_ = parseFloat(h.Flow_Credit);
      const leaderTotal_ = parseFloat(h.Leader_Scan_Total);
      const leaderGate_  = String(h.Leader_Gate ?? "");
      const bandStatus_  = String(h.Band_Status ?? "");
      const inUp_   = Number.isFinite(cl_) && Number.isFinite(ma20_) && cl_ >= ma20_;

      const precomp = parseFloat(h.Sell_Priority_Score);
      let score_;
      if (Number.isFinite(precomp)) {
        score_ = precomp;
      } else {
        score_ = 0;
        if (h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") score_ += 50;
        else if (h.Final_Action === "SELL_READY") score_ += 40;
        else if (isEtf_ && sExp_ >= THRESHOLDS.SP_DUPLICATE_THRESH) score_ += 20;
        if (rw_ >= 4) score_ += 20; else if (rw_ === 3) score_ += 15; else if (rw_ === 2) score_ += 8;
        if (!isEtf_ && !isCL_ && Number.isFinite(pctP_) && pctP_ <= -10) score_ += 12;
        if (isCL_ && inUp_) score_ -= 20;
        if (h.SS001_Grade === "A") score_ -= 12;
        score_ = Math.max(0, score_);
      }

      const reboundHoldback_ = calcReboundHoldbackScore_({
        close: cl_,
        ma20: ma20_,
        ma60: ma60_,
        ma20Slope: ma20Slope_,
        rsi14: rsi14_,
        bbPosition: bbPos_,
        flowCredit: flowCredit_,
        leaderTotal: leaderTotal_,
        leaderGate: leaderGate_,
        bandStatus: bandStatus_,
        profitPct: pctP_,
        isCoreLeader: isCL_,
      });
      const preservationPlan_ = calcCashPreservationPlan_({
        sellAction: h.Sell_Action,
        cashFloorStatus: String(macro.cash_floor_status ?? ""),
        regime: regime_,
        isCoreLeader: isCL_,
        isEtf: isEtf_,
        liquidityStatus: String(h.Liquidity_Status ?? h.LiquidityStatus ?? ""),
        spreadStatus: String(h.Spread_Status ?? h.SpreadStatus ?? ""),
        accountType: String(h.account_type ?? h.Account_Type ?? ""),
        profitPct: pctP_,
        rwPartial: rw_,
        reboundHoldbackScore: reboundHoldback_.score,
      });
      const netScore_ = Number.isFinite(precomp)
        ? Math.min(100, Math.max(0, score_))
        : Math.min(100, Math.max(0, score_ - reboundHoldback_.score));
      const actionGroup_ =
        (h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") ? "EXIT" :
        String(h.Sell_Action ?? "").startsWith("TRIM") ? "TRIM" :
        String(h.Sell_Action ?? "") === "HOLD" ? "HOLD" : "WATCH";
      const actionGroupOrder_ =
        actionGroup_ === "EXIT" ? 1 :
        actionGroup_ === "TRIM" ? 2 :
        actionGroup_ === "HOLD" ? 3 : 4;

      let tier_, tierLabel_;
      if (h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") {
        tier_ = 1; tierLabel_ = "①하드스탑";
      } else if (h.Final_Action === "SELL_READY") {
        tier_ = 2; tierLabel_ = "②매도신호";
      } else if (isEtf_ && sExp_ >= 10) {
        tier_ = 3; tierLabel_ = "③중복ETF";
      } else if (!isEtf_ && !isCL_ && Number.isFinite(pctP_) && pctP_ <= -10) {
        tier_ = 4; tierLabel_ = "④손실위성";
      } else if (!isCL_ && rw_ >= 3) {
        tier_ = 5; tierLabel_ = "⑤RW약세";
      } else if (!isCL_ && Number.isFinite(pctP_) && pctP_ >= 10) {
        tier_ = 6; tierLabel_ = "⑥익절후보";
      } else if (isCL_ && inUp_) {
        tier_ = 9; tierLabel_ = "⑨코어주도주[마지막]";
      } else {
        tier_ = 7; tierLabel_ = "⑦일반";
      }

      return {
        rank: 0,
        tier: tier_, tier_label: tierLabel_,
        ticker: h.Ticker, name: h.Name,
        weight_pct: h.Weight_Pct, profit_pct: h.Profit_Pct,
        rw_partial: rw_, ss001_grade: h.SS001_Grade,
        sector: sec_, sector_exp_pct: parseFloat(sExp_.toFixed(1)),
        is_etf: isEtf_, is_core_leader: isCL_,
        final_action: h.Final_Action, sell_action: h.Sell_Action,
        action_group: actionGroup_,
        action_group_order: actionGroupOrder_,
        sell_ratio_pct: h.Sell_Ratio_Pct,
        sell_qty: h.Sell_Qty,
        sell_limit_price: h.Sell_Limit_Price,
        sell_validation: h.Sell_Validation,
        action_reason: h.Action_Reason,
        action_params: h.Action_Params ?? "",
        score: netScore_,
        sell_priority_score: netScore_,
        raw_sell_priority_score: score_,
        rebound_holdback_score: reboundHoldback_.score,
        rebound_holdback_reason: reboundHoldback_.reasons,
        cash_preserve_style: preservationPlan_.style,
        cash_preserve_ratio: preservationPlan_.recommended_ratio,
        cash_preserve_reason: preservationPlan_.reasons,
        trim_style: isCL_ && inUp_
          ? "CORE_LAST"
          : reboundHoldback_.score >= 18
            ? "STEP_25"
            : reboundHoldback_.score >= 10
              ? "STEP_33"
              : "STEP_50",
        hold_reason: (isCL_ && inUp_)
          ? "core_leader_uptrend — 매도 마지막(spec:portfolio_exposure.yaml:funding_order④)" : "",
        quantity_note: "매도수량은 HTS 캡처 제공 후 결정. 미제공 시 수량 기재 금지(spec:00_execution_contract.yaml:P1규칙).",
      };
    })
    // Hard-lock sort policy: tier asc -> score desc -> action_group_order asc
    .sort((a, b) => a.tier - b.tier || b.sell_priority_score - a.sell_priority_score || a.action_group_order - b.action_group_order);

  rows_.forEach((r, i) => { r.rank = i + 1; });

  const sheetHeaders_ = [
    "Rank","Tier","Tier_Label","Action_Group","Ticker","Name","Sector","Weight_Pct","Profit_Pct",
    "Final_Action","Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Validation",
    "Sell_Priority_Score","Raw_Sell_Priority_Score","Rebound_Holdback_Score",
    "Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason",
    "Trim_Style","Hold_Reason","Action_Reason","Action_Params",
    "Computed_At","Engine_Version","Sort_Policy_ID","Source_Context_Checksum"
  ];
  const sourceContextChecksum_ = computeStringChecksum_(JSON.stringify({
    market_regime: regime_,
    cash_floor_status: String(macro.cash_floor_status ?? ""),
    holdings_count: rows_.length,
    holdings_keys: rows_.map(r => `${r.ticker}:${r.final_action}:${r.sell_action}:${r.tier}:${r.sell_priority_score}`)
  }));
  let sheetRows_ = rows_.map(r => ([
    r.rank,
    r.tier,
    r.tier_label,
    r.action_group,
    r.ticker,
    r.name,
    r.sector,
    r.weight_pct,
    r.profit_pct,
    r.final_action,
    r.sell_action,
    r.sell_ratio_pct ?? "",
    r.sell_qty ?? "",
    r.sell_limit_price,
    r.sell_validation ?? "",
    r.sell_priority_score,
    r.raw_sell_priority_score,
    r.rebound_holdback_score,
    r.cash_preserve_style,
    r.cash_preserve_ratio,
    r.cash_preserve_reason,
    r.trim_style,
    r.hold_reason,
    r.action_reason ?? "",
    r.action_params,
    computedAt_,
    "sell_priority_engine_v2",
    "SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION",
    sourceContextChecksum_,
  ]));

  // 데이터 준비 미흡 상태를 빈 시트로 숨기지 않고 명시적으로 기록한다.
  if (!sheetRows_.length) {
    sheetRows_ = [[
      0,
      "",
      "DATA_MISSING",
      "WATCH",
      "",
      "매도우선순위 산출 불가",
      "",
      "",
      "",
      "",
      "",
      "",
      "",
      "",
      "DATA_MISSING",
      "",
      "",
      "",
      "",
      "",
      "",
      "",
      "",
      `[SELL_PRIORITY_INPUT_MISSING] holdings=${holdings.length}, valid_weight=${validWeightCount_}, missing_weight=${missingWeightCount_}`,
      "runDataFeed/account_snapshot 갱신 후 재실행 필요"
        + ` | account_snapshot confirmed=${asConfirmStats_.confirmed_rows}/${asConfirmStats_.rows_read}`
        + ` parse_ok_unconfirmed=${asConfirmStats_.parse_ok_unconfirmed}`,
      computedAt_,
      "sell_priority_engine_v2",
      "SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION",
      sourceContextChecksum_,
    ]];
  }
  writeToSheet("sell_priority", sheetHeaders_, sheetRows_);

  const cashPct_ = parseFloat(macro.immediate_cash_pct ?? macro.cash_pct ?? "");

  return {
    engine:           "sell_priority_engine_v2",
    status:           rows_.length ? "READY" : "DATA_MISSING",
    activation_reason: regime_.includes("RISK_OFF") ? "REGIME_TRIM_50"
      : (Number.isFinite(cashPct_) && cashPct_ < 10
        ? `cash_below_floor(${cashPct_.toFixed(1)}%)` : "manual_request"),
    market_regime:    regime_,
    computed_at:      computedAt_,
    engine_version:   "sell_priority_engine_v2",
    sort_policy_id:   "SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION",
    sector_exposure:  sectorExpMap_,
    prohibition: [
      "주도주(SK하이닉스·삼성전자) 매도는 hard_stop 또는 thesis 훼손 근거 필수(tier=9는 마지막).",
      "매도수량은 HTS 캡처 제공 후 결정. 수량 미제공 시 수량 기재 금지(spec:P1규칙).",
    ],
    source_context_checksum: sourceContextChecksum_,
    diagnostics: {
      holdings_count: holdings.length,
      valid_weight_count: validWeightCount_,
      missing_weight_count: missingWeightCount_,
      account_snapshot_rows_read: asConfirmStats_.rows_read,
      account_snapshot_confirmed_rows: asConfirmStats_.confirmed_rows,
      account_snapshot_parse_ok_unconfirmed: asConfirmStats_.parse_ok_unconfirmed,
    },
    sell_priority_checksum: computeStringChecksum_(JSON.stringify(rows_.map(function(r) {
      return {
        rank: r.rank,
        ticker: r.ticker,
        tier: r.tier,
        sell_priority_score: r.sell_priority_score,
        final_action: r.final_action,
        sell_action: r.sell_action
      };
    }))),
    sell_priority_table: rows_,
    candidates:          rows_,  // backward-compat alias
  };
}

function getAccountSnapshotConfirmStats_() {
  var out = { rows_read: 0, confirmed_rows: 0, parse_ok_unconfirmed: 0 };
  try {
    var ss = getSpreadsheet_();
    var sh = ss.getSheetByName("account_snapshot");
    if (!sh) return out;
    var data = sh.getDataRange().getValues();
    if (!data || data.length < 3) return out;
    var hdr = data[1].map(function(h) { return String(h || "").trim(); });
    var statusIdx = hdr.indexOf("parse_status");
    var confIdx = hdr.indexOf("user_confirmed");
    if (statusIdx < 0) return out;
    for (var i = 2; i < data.length; i++) {
      var parseStatus = String(data[i][statusIdx] || "").trim();
      var confirmed = confIdx >= 0 ? String(data[i][confIdx] || "").trim().toUpperCase() : "";
      if (!parseStatus && !confirmed) continue;
      out.rows_read++;
      var isParseOk = parseStatus === "CAPTURE_READ_OK";
      var hasConfirm = confirmed === "Y" || confirmed === "YES" || confirmed === "TRUE" || confirmed === "1";
      if (isParseOk && hasConfirm) out.confirmed_rows++;
      if (isParseOk && !hasConfirm) out.parse_ok_unconfirmed++;
    }
  } catch (e) {}
  return out;
}

// ============================================================================
// INTEGRATED HARNESS
// ============================================================================

/**
 * [HARNESS] gas_data_feed.gs 통합 하네스 — H3 확장판
 *
 * H1: 포트폴리오 가드 (intraday_lock, cash_floor, total_heat)
 * H2: 매도후보 순위 (Sell_Priority_Score 0~100 clamp, tier 배정)
 * H3: 수량 사전산출 (Sell_Qty, POSITION_SIZE_V1 입력값)
 * H4: 가격 사전산출 (STOP_PRICE_CORE_V1 + TICK_NORMALIZER_V1)
 * H5: 결정 상태머신 게이팅 (Final_Action per ticker + gate_trace)
 * H6: Blueprint 무결성 해시 (row_count + CRC32_V1 checksum, LLM 위변조 탐지)
 *
 * 호출: runEventRisk() 완료 후 runHarnessRefresh_() → buildHarnessContext_()
 * 출력: harness_context 시트 (key-value)
 *       → Python converter → blueprint_checksum 검증 → JSON data._harness_context
 *       → LLM: HARNESS_AUTHORITATIVE_V3 지침 (Zero-Adjective + Structured Output)
 *
 * 버전: 2026-05-18-H3
 */

// ── 상수 ─────────────────────────────────────────────────────────────────────
var HARNESS_VERSION    = '2026-05-22-3RD_HARNESS_V1';
var HARNESS_SHEET_NAME = 'harness_context';
var AS_SHEET_NAME      = 'account_snapshot';
var SETTINGS_SHEET_NAME = 'settings';
var DATA_FEED_SHEET_NAME = 'data_feed';

// 헤더 행 위치 (0-indexed)
var AS_HEADER_ROW_IDX = 1;
var DF_HEADER_ROW_IDX = 1;

// 코어 주도주 — tier=9 (마지막 매도 순위) 고정
// spec/risk/portfolio_exposure.yaml:regime_leading_sector_protection
var CORE_TICKERS = ['005930', '000660'];  // 삼성전자, SK하이닉스

// P4: 장중 차단 키워드 (spec/00_execution_contract.yaml:P4.keyword_lock)
var INTRADAY_BLOCKED_KEYWORDS = ['EXIT_100', 'SELL_FULL', 'EXIT_FULL', 'BUY', 'STAGED_BUY'];
var INTRADAY_CUTOFF_MINUTES   = 15 * 60 + 30;  // 15:30 KST

// P4: 장중 허용 액션 목록 — 이 목록 외 모든 매도/매수 액션은 장중 금지
// (차단목록 기반 다운그레이드를 통과한 후 최종 허용 여부를 이중 검증)
var INTRADAY_ALLOWED_ACTIONS = [
  'HOLD', 'WATCH', 'TRIM_25', 'TRIM_33', 'TRIM_50',
  'OBSERVE_DATA_MISSING', 'INSUFFICIENT_DATA', 'NO_BUY_OVERHEATED'
];

// Heat 게이트 (spec/13_formula_registry.yaml:TOTAL_HEAT_V1.gates)
// L3: 국면별 동적 임계값으로 대체 — calcDynamicHeatThresholds_() 참조
var HEAT_HARD_BLOCK_PCT = 10.0;  // fallback (regime unknown)
var HEAT_HALVE_PCT      = 7.0;   // fallback (regime unknown)

/**
 * L3: DYNAMIC_HEAT_GATE_V1
 * 국면에 따라 Heat Gate 임계값을 동적으로 반환한다.
 * spec/13b_harness_formulas.yaml:DYNAMIC_HEAT_GATE_V1
 * @param {string} regime — marketRegime string
 * @return {{hardBlock: number, halve: number}}
 */
var calcHeatThresholdsByRegime_ = function(regime) {
  var r = String(regime || '').toUpperCase();
  if (r.indexOf('EVENT_SHOCK') >= 0)                        return { hardBlock: 5.0,  halve: 3.5 };
  if (r.indexOf('RISK_OFF') >= 0)                           return { hardBlock: 7.0,  halve: 5.0 };
  if (r.indexOf('SECULAR_LEADER') >= 0 && r.indexOf('RISK_ON') >= 0) return { hardBlock: 13.0, halve: 9.0 };
  if (r.indexOf('RISK_ON') >= 0)                            return { hardBlock: 12.0, halve: 8.5 };
  // NEUTRAL or unknown
  return { hardBlock: 10.0, halve: 7.0 };
}

// cash_floor MRS 구간 (spec/risk/portfolio_exposure.yaml:cash_floor.regime_numbers)
var CASH_FLOOR_BY_MRS = [
  { maxMrs: 3,  minPct: 7,  label: 'normal' },
  { maxMrs: 7,  minPct: 10, label: 'overheated_or_event_week' },
  { maxMrs: 10, minPct: 15, label: 'risk_off' }
];

// KRX 호가단위 테이블 (spec/13_formula_registry.yaml:TICK_NORMALIZER_V1)
var TICK_TABLE = [
  { maxPrice: 2000,   tick: 1   },
  { maxPrice: 5000,   tick: 5   },
  { maxPrice: 20000,  tick: 10  },
  { maxPrice: 50000,  tick: 50  },
  { maxPrice: 200000, tick: 100 },
  { maxPrice: 500000, tick: 500 }
  // >= 500000: tick = 1000
];

// Sell_Priority_Score 컴포넌트 가중치
// spec/risk/portfolio_exposure.yaml:candidate_scoring.components
var SP = {
  HARD_STOP_BREACH:    50,
  CASH_FLOOR_TRIM:     40,
  DUPLICATE_ETF:       30,
  HOLDING_TRIM_ROTATE: 20,
  TAKE_PROFIT_BASE:    10,
  DUP_SAME_SECTOR:     20,
  CASH_RELIEF_GE3:     15,
  CASH_RELIEF_1_3:     10,
  CASH_RELIEF_LT1:      3,
  RW_GE4:              20,
  RW_3:                15,
  RW_2:                 8,
  FLOW_NEGATIVE:        8,
  BELOW_MA20:           8,
  OVERWEIGHT_5P:       12,
  OVERWEIGHT_2P:        6,
  LIQUIDITY_OK:         5,
  LIQUIDITY_LOW:       -10,
  TAX_UNKNOWN:          3,
  CORE_LEADER:         20,
  A_GRADE_CORE:        12
};

// POSITION_SIZE_V1 기본 위험예산 (spec/13_formula_registry.yaml:RISK_BUDGET_CASCADE_V1)
var BASE_RISK_BUDGET = 0.007;  // 총자산의 0.7%


// ── 메인 함수 ────────────────────────────────────────────────────────────────

/**
 * buildHarnessContext_
 * GAS 확정값을 harness_context 시트에 기록한다.
 * runEventRisk() 완료 후 runHarnessRefresh_()가 호출한다.
 */
function buildHarnessContext_() {
  var ss  = getSpreadsheet_();
  var now = new Date();

  logHarnessSub_('[HARNESS_LAYER] L0: prepareHarnessContextInputs_');
  var harnessInputs = prepareHarnessContextInputs_(ss) || {};
  var settings = harnessInputs.settings;
  var performance = harnessInputs.performance;
  var totalAsset = harnessInputs.totalAsset;
  var mrsScore = harnessInputs.mrsScore;
  var dfMap = harnessInputs.dfMap;
  var asResult = harnessInputs.asResult || {};
  asResult.holdings = Array.isArray(asResult.holdings) ? asResult.holdings : [];
  var kospiRet5d  = harnessInputs.kospiRet5d;
  var kospiRet20d = harnessInputs.kospiRet20d || 0;
  var sectorFlowRadar = harnessInputs.sectorFlowRadar;
  var harnessState = harnessInputs.harnessState || {};
  var intradayLock = harnessState.intradayLock;
  var capturedAtIso = harnessState.capturedAtIso;
  var snapshotFreshness = harnessState.snapshotFreshness;
  var snapshotGate = harnessState.snapshotGate;
  var cashFloorInfo = harnessState.cashFloorInfo;
  var cashShortfallInfo = harnessState.cashShortfallInfo;
  var drawdownGuard = harnessState.drawdownGuard;
  var winLossStreakGuard = harnessState.winLossStreakGuard;
  var marketRegime = harnessState.marketRegime;
  var regimeTrimGuidance = harnessState.regimeTrimGuidance;
  var regimeTransitionAlert = harnessState.regimeTransitionAlert;
  var regimeSizeScale = harnessState.regimeSizeScale;
  var regimeCashMinPct = harnessState.regimeCashMinPct;
  var heatThresholds = harnessState.heatThresholds;
  var heatGate = harnessState.heatGate;
  var actions = harnessState.actions;
  var h1 = harnessState.h1;
  h1.kospiRet20d = kospiRet20d;
  var settlementCashPct = harnessState.settlementCashPct;
  var totalHeatPct = harnessState.totalHeatPct;
  var buyPowerKrw = harnessState.buyPowerKrw;
  try {
    if (cashFloorInfo && cashFloorInfo.status === 'HARD_BLOCK') {
      writeSettingValue_(ss, 'cash_floor_hard_block_warning',
        '[CASH_FLOOR_HARD_BLOCK] 신규 매수 차단 상태 — 현금 회복(TRIM) 우선');
    } else {
      writeSettingValue_(ss, 'cash_floor_hard_block_warning', '');
    }
  } catch (e) {
    Logger.log('[WARN] cash_floor_hard_block_warning 기록 실패: ' + e.message);
  }
  logHarnessSub_('[HARNESS_LAYER] L1: assembleHarnessCoreLayers_ holdings=' + (asResult.holdings || []).length);
  var coreLayers = assembleHarnessCoreLayers_(
    ss, now, settings, asResult, dfMap, performance, totalAsset, mrsScore, buyPowerKrw,
    settlementCashPct, totalHeatPct, intradayLock, snapshotFreshness, snapshotGate,
    cashFloorInfo, cashShortfallInfo, capturedAtIso, drawdownGuard, winLossStreakGuard, marketRegime,
    regimeTrimGuidance, regimeTransitionAlert, regimeSizeScale, regimeCashMinPct,
    heatThresholds, heatGate, actions, h1, kospiRet5d, sectorFlowRadar
  );
  coreLayers = coreLayers || {};
  var h2 = coreLayers.h2 || {};
  var h3 = coreLayers.h3 || {};
  var h4 = coreLayers.h4 || {};
  var h5 = coreLayers.h5 || {};
  var orderBlueprint = Array.isArray(coreLayers.orderBlueprint) ? coreLayers.orderBlueprint : [];
  logHarnessSub_('[HARNESS_LAYER] L1 done: h2.candidates=' + ((h2 && h2.candidates) ? h2.candidates.length : 0)
    + ' h3.sellQty=' + ((h3 && h3.sellQty) ? h3.sellQty.length : 0)
    + ' h4.prices=' + ((h4 && h4.prices) ? h4.prices.length : 0)
    + ' h5.decisions=' + ((h5 && h5["decisions"]) ? h5["decisions"].length : 0)
    + ' blueprint=' + (orderBlueprint ? orderBlueprint.length : 0));

  logHarnessSub_('[HARNESS_LAYER] L2: assembleHarnessRiskLayers_');
  var riskLayers = assembleHarnessRiskLayers_(
    ss, settings, asResult, dfMap, totalAsset, marketRegime, kospiRet5d, sectorFlowRadar, h4
  );
  riskLayers = riskLayers || {};
  var portfolioBetaGate = riskLayers.portfolioBetaGate;
  var eventRiskRows = riskLayers.eventRiskRows;
  var sectorConcentration = riskLayers.sectorConcentration;
  var tpLadderRows = riskLayers.tpLadderRows;
  var stopAdequacyRows = riskLayers.stopAdequacyRows;
  var staleRows = riskLayers.staleRows;
  var singlePositionWeightCap = riskLayers.singlePositionWeightCap;
  var semiconductorClusterGate = riskLayers.semiconductorClusterGate;
  var portfolioDrawdownGate = riskLayers.portfolioDrawdownGate;
  var positionCountLimit = riskLayers.positionCountLimit;
  var stopBreachAlert = riskLayers.stopBreachAlert;
  var relativeStopSignal = riskLayers.relativeStopSignal;
  var tpTriggerAlert = riskLayers.tpTriggerAlert;
  var heatConcentrationAlert = riskLayers.heatConcentrationAlert;
  var sectorMomentumRows = riskLayers.sectorMomentumRows;
  logHarnessSub_('[HARNESS_LAYER] L2 done');

  logHarnessSub_('[HARNESS_LAYER] L3: assembleHarnessAlphaLayers_');
  var alphaLayers = assembleHarnessAlphaLayers_(
    ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, h2, h3, h4, h5,
    orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, drawdownGuard, snapshotGate,
    cashFloorInfo, portfolioBetaGate, sectorConcentration, portfolioDrawdownGate,
    winLossStreakGuard, positionCountLimit, singlePositionWeightCap, semiconductorClusterGate,
    stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert,
    heatGate
  );
  alphaLayers = alphaLayers || {};
  var hAlpha = alphaLayers.hAlpha;
  var hApex = alphaLayers.hApex;
  var backdataRows = alphaLayers.backdataRows;
  var dfgResult = alphaLayers.dfgResult;
  var claExitJson = alphaLayers.claExitJson;
  var slgRows = alphaLayers.slgRows;
  var pcgResult = alphaLayers.pcgResult;
  var portfolioHealthScore = alphaLayers.portfolioHealthScore;
  hAlpha = hAlpha || {};
  hApex = hApex || {};
  // [PROPOSAL51-FIX] P2-B: portfolio_health_score 숫자형 보장 (Export Gate CHECK_7 연동)
  // portfolioHealthScore 객체를 hApex에 숫자 필드로 주입 (기존엔 hApex에 미등록 → Boolean/undefined)
  if (portfolioHealthScore) {
    var phsVal = portfolioHealthScore.score;
    hApex.portfolio_health_score = (typeof phsVal === 'number' && !isNaN(phsVal)) ? phsVal : 50;
    hApex.portfolio_health_label = portfolioHealthScore.label || 'CAUTION';
    hApex.portfolio_health_json  = portfolioHealthScore;
  }
  if (relativeStopSignal) hApex.relative_stop_signal = relativeStopSignal;
  logHarnessSub_('[HARNESS_LAYER] L3 done');

  logHarnessSub_('[HARNESS_LAYER] L4: finalizeHarnessContextRows_');
  finalizeHarnessContextRows_(
    ss, now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo,
    heatGate, heatThresholds, mrsScore, asResult, dfMap, settlementCashPct, totalHeatPct,
    buyPowerKrw, totalAsset, actions, performance, h2, h3, h4, h5, orderBlueprint, hAlpha,
    regimeTrimGuidance, cashShortfallInfo, hApex, sectorMomentumRows, drawdownGuard,
    portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows, regimeSizeScale,
    regimeCashMinPct, stopAdequacyRows, staleRows, singlePositionWeightCap,
    semiconductorClusterGate, portfolioDrawdownGate, winLossStreakGuard, positionCountLimit,
    stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert,
    portfolioHealthScore
  );
  logHarnessSub_('[HARNESS_LAYER] L4 done');

  var sellCandidatesCount = ((h2 && h2.candidates) ? h2.candidates.length : 0);
  var routeCount = ((h5 && h5["decisions"]) ? h5["decisions"].length : 0);
  Logger.log('[HARNESS H2] 완료'
    + ' | intraday=' + intradayLock
    + ' | cash=' + settlementCashPct + '%'
    + ' | heat=' + totalHeatPct + '%'
    + ' | cashFloor=' + cashFloorInfo.status
    + ' | heatGate=' + heatGate
    + ' | perf=' + performance.bayesian_label + '×' + performance.bayesian_multiplier
    + ' | sellCandidates=' + sellCandidatesCount
    + ' | decisions=' + routeCount
    + ' | alphaShield_critical=' + (hAlpha.critical_alert_count || 0)
    + ' | apex_buy_blocks=' + (hApex.apex_block_count || 0));
  if (routeCount > 0 && sellCandidatesCount === 0) {
    Logger.log('[LOG_METRIC_MISMATCH_WARN] decisions>0 이지만 sellCandidates=0 (정책/입력 상태 점검 필요)');
  }
}

// FORMULA_STUB: MARKET_RISK_SCORE_V1 — 시장리스크 점수 (calcMarketRiskScore_) GAS 미구현, Python pipeline 산출
// FORMULA_STUB: PORTFOLIO_BETA_V1 — 포트폴리오 베타 (calcPortfolioBeta_) GAS 미구현, Python pipeline 산출



