// =========================================================================
// GENERATED BUNDLE - DO NOT EDIT THIS FILE MANUALLY
// Generated At: 2026-06-22 02:21:03 KST
// Source Files: src/gas_adapter_parts/gdf_01_price_metrics.gs, src/gas_adapter_parts/gdf_02_harness_assembly.gs, src/gas_adapter_parts/gdf_03_portfolio_gates.gs, src/gas_adapter_parts/gdf_04_execution_quality.gs, src/gas_adapter_parts/gdf_05_alpha_engines.gs, src/gas_adapter_parts/gdf_06_rebalance.gs
// Source Hash: c050e37c26b87f72eb5b325726163b0cd8570e3823bf058f5464d37cc8200e31
// =========================================================================

// --- Source: src/gas_adapter_parts/gdf_01_price_metrics.gs ---
/**
 * 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 산출





// --- Source: src/gas_adapter_parts/gdf_02_harness_assembly.gs ---
function shouldEmitHarnessVerboseLogs_() {
  try {
    var props = PropertiesService.getScriptProperties();
    var profile = String(props.getProperty('HARNESS_LOG_PROFILE') || '').toUpperCase();
    if (profile === 'DEBUG') return true;
    if (profile === 'NORMAL') return false;
    // Backward compatibility
    var v = props.getProperty('HARNESS_VERBOSE_LOG');
    return String(v || '').toLowerCase() === 'true';
  } catch (e) {
    return false;
  }
}

function logHarnessSub_(msg) {
  if (shouldEmitHarnessVerboseLogs_()) Logger.log(msg);
}

function setHarnessLogProfile_(profile) {
  var p = String(profile || '').toUpperCase();
  if (p !== 'NORMAL' && p !== 'DEBUG') {
    throw new Error("setHarnessLogProfile_: profile must be 'NORMAL' or 'DEBUG'");
  }
  var props = PropertiesService.getScriptProperties();
  props.setProperty('HARNESS_LOG_PROFILE', p);
  if (p === 'DEBUG') props.setProperty('HARNESS_VERBOSE_LOG', 'true');
  if (p === 'NORMAL') props.deleteProperty('HARNESS_VERBOSE_LOG');
  Logger.log('[HARNESS_LOG_PROFILE] set to ' + p);
  return { profile: p, formula_id: 'HARNESS_LOG_PROFILE_V1' };
}

function setHarnessLogProfileNormal_() {
  return setHarnessLogProfile_('NORMAL');
}

function setHarnessLogProfileDebug_() {
  return setHarnessLogProfile_('DEBUG');
}

function getHarnessLogProfile_() {
  var profile = 'NORMAL';
  var verboseFallback = false;
  try {
    var props = PropertiesService.getScriptProperties();
    var p = String(props.getProperty('HARNESS_LOG_PROFILE') || '').toUpperCase();
    if (p === 'NORMAL' || p === 'DEBUG') profile = p;
    verboseFallback = String(props.getProperty('HARNESS_VERBOSE_LOG') || '').toLowerCase() === 'true';
  } catch (e) {}
  return {
    profile: profile,
    verbose_fallback: verboseFallback,
    formula_id: 'HARNESS_LOG_PROFILE_V1'
  };
}


function 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
) {
  // THIN_ADAPTER: [sizing] delegated to Python — src/quant_engine/inject_computed_harness.py:main
  var h2 = calcSellPriority_(asResult.holdings, dfMap, h1);
  var h3 = calcQuantities_(asResult.holdings, dfMap, totalAsset, buyPowerKrw, h1);
  var h4 = calcPrices_(asResult.holdings, dfMap, marketRegime);
  var h5 = runRouteFlow_(asResult.holdings, dfMap, h1);
  var orderBlueprint = buildOrderBlueprint_(asResult.holdings, dfMap, {
    intradayLock: intradayLock,
    heatGate: heatGate,
    cashFloorStatus: cashFloorInfo.status,
    blockedActions: actions.blocked
  }, h3, h4, h5);
  return {
    h2: h2,
    h3: h3,
    h4: h4,
    h5: h5,
    orderBlueprint: orderBlueprint
  };
}


function assembleHarnessRiskLayers_(
  ss, settings, asResult, dfMap, totalAsset, marketRegime, kospiRet5d, sectorFlowRadar, h4
) {
  var portfolioBetaGate = calcPortfolioBetaGate_(asResult.holdings, dfMap, kospiRet5d, marketRegime);
  var eventRiskRows = calcEventRiskHoldGate_(asResult.holdings, dfMap);
  var sectorConcentration = calcSectorConcentrationGate_(asResult.holdings, marketRegime);
  var tpLadderRows = calcTpQuantityLadder_(asResult.holdings, h4);
  var stopAdequacyRows = calcStopAdequacyRows_(asResult.holdings, dfMap);
  var staleRows = calcHoldingStaleReview_(asResult.holdings);
  // KOSPI 반도체 시총 비중 — settings 시트에서만 입력. 미설정 시 0 (DATA_MISSING 처리)
  // 주의: 하드코딩 기본값 금지. 실제 비중은 KRX/FnGuide 시총 데이터에서 수동 입력.
  var kospiSemiWt    = toNumber_(settings['kospi_semi_weight_pct'])    || 0;
  var kospiSamsungWt = toNumber_(settings['kospi_samsung_weight_pct']) || 0;
  var kospiHynixWt   = toNumber_(settings['kospi_hynix_weight_pct'])   || 0;
  var singlePositionWeightCap  = calcSinglePositionWeightCap_(asResult.holdings, marketRegime, kospiSamsungWt, kospiHynixWt);
  var semiconductorClusterGate = calcSemiconductorClusterGate_(asResult.holdings, marketRegime, kospiSemiWt);
  var portfolioDrawdownGate = calcPortfolioDrawdownGate_(totalAsset, ss, settings);
  var positionCountLimit = calcPositionCountLimit_(asResult.holdings, marketRegime);
  var stopBreachAlert = calcStopBreachAlert_(asResult.holdings, dfMap);
  var kospiRet20d_ = readKospiRet20d_(ss);
  var relativeStopSignal = calcRelativeStopSignal_(asResult.holdings, dfMap, kospiRet20d_);
  var tpTriggerAlert = calcTpTriggerAlert_(asResult.holdings, dfMap, h4, tpLadderRows);
  var heatConcentrationAlert = calcHeatConcentrationAlert_(asResult.holdings, asResult.totalHeatKrw);
  var sectorMomentumRows = calcSectorRotationMomentum_(sectorFlowRadar);
  return {
    portfolioBetaGate: portfolioBetaGate,
    eventRiskRows: eventRiskRows,
    sectorConcentration: sectorConcentration,
    tpLadderRows: tpLadderRows,
    stopAdequacyRows: stopAdequacyRows,
    staleRows: staleRows,
    singlePositionWeightCap: singlePositionWeightCap,
    semiconductorClusterGate: semiconductorClusterGate,
    portfolioDrawdownGate: portfolioDrawdownGate,
    positionCountLimit: positionCountLimit,
    stopBreachAlert: stopBreachAlert,
    relativeStopSignal: relativeStopSignal,
    tpTriggerAlert: tpTriggerAlert,
    heatConcentrationAlert: heatConcentrationAlert,
    sectorMomentumRows: sectorMomentumRows
  };
}


function 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
) {
  logHarnessSub_('[HARNESS_SUB] L3-A: assembleHarnessAlphaRadar_');
  var alphaLayer = assembleHarnessAlphaRadar_(asResult, dfMap, kospiRet5d, sectorFlowRadar);
  logHarnessSub_('[HARNESS_SUB] L3-B: assembleHarnessApexLayer_');
  var apexLayer = assembleHarnessApexLayer_(
    ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar,
    h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, cashFloorInfo
  );
  logHarnessSub_('[HARNESS_SUB] L3-C: syncBackdataFeatureBank_');
  var hAlphaResult = alphaLayer.hAlpha;
  var hApexResult = apexLayer.hApex;
  var backdataRows = syncBackdataFeatureBank_(now, asResult.holdings, dfMap, hAlphaResult, hApexResult);
  hApexResult.backdata_feature_bank_json = backdataRows;
  hApexResult.backdata_learning_lock = true;
  var dfgResult = apexLayer.dfgResult;
  var claExitJson = apexLayer.claExitJson;
  var slgRows = apexLayer.slgRows;
  var pcgResult = apexLayer.pcgResult;
  logHarnessSub_('[HARNESS_SUB] L3-D: calcHarnessPortfolioHealthScore_');
  var portfolioHealthScore = calcHarnessPortfolioHealthScore_({
    heat_gate:               heatGate,
    cash_floor_status:       cashFloorInfo.status,
    drawdown_guard_state:    drawdownGuard.state,
    snapshot_execution_gate: snapshotGate.status,
    portfolio_beta_gate:     (portfolioBetaGate || {}).gate_status,
    sector_concentration:    (sectorConcentration || {}).gate_status,
    portfolio_drawdown_gate: (portfolioDrawdownGate || {}).gate,
    win_loss_streak_state:   winLossStreakGuard.state,
    position_count_gate:     (positionCountLimit || {}).gate_status,
    single_position_weight:  (singlePositionWeightCap || {}).gate_status,
    semiconductor_cluster:   (semiconductorClusterGate || {}).gate_status,
    stop_breach_gate:        (stopBreachAlert || {}).gate,
    tp_trigger_gate:         (tpTriggerAlert || {}).gate,
    heat_concentration_gate: (heatConcentrationAlert || {}).gate,
    regime_transition_type:  (regimeTransitionAlert || {}).transition_type
  });
  // [PROPOSAL50] P1-C: M5 V1.1 — 반도체 클러스터 한도 2배 초과 시 4주 의무 감축 계획
  var mandatoryReduction = calcMandatoryReductionPlan_(
    semiconductorClusterGate, asResult.holdings, dfMap, h3, totalAsset
  );
  hApexResult.mandatory_reduction_json = mandatoryReduction;
  if (mandatoryReduction.is_mandatory) {
    Logger.log('[M5_V1.1] MANDATORY_REDUCTION: ' + mandatoryReduction.current_excess_pct
      + '%p 초과 → 주당 ' + mandatoryReduction.weekly_reduction_target_krw + '원 감축 필요');
  }
  return {
    hAlpha: hAlphaResult,
    hApex: hApexResult,
    backdataRows: backdataRows,
    dfgResult: dfgResult,
    claExitJson: claExitJson,
    slgRows: slgRows,
    pcgResult: pcgResult,
    portfolioHealthScore: portfolioHealthScore
  };
}


function assembleHarnessAlphaRadar_(asResult, dfMap, kospiRet5d, sectorFlowRadar) {
  var hAlpha = calcAlphaShield_(asResult.holdings, dfMap, kospiRet5d, sectorFlowRadar);
  return { hAlpha: hAlpha };
}


function assembleHarnessApexLayer_(
  ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar,
  h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, cashFloorInfo
) {
  logHarnessSub_('[HARNESS_SUB] L3-B1: assembleHarnessApexCore_');
  var apexCore = assembleHarnessApexCore_(
    ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar,
    h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso
  );
  logHarnessSub_('[HARNESS_SUB] L3-B2: applyApexProposal46Suite_');
  var hApex = applyApexProposal46Suite_(
    ss, asResult.holdings, dfMap, h2, h3, cashShortfallInfo, asResult, cashFloorInfo, capturedAtIso, now, apexCore.hApex
  );
  hApex = hApex || {};
  orderBlueprint = Array.isArray(orderBlueprint) ? orderBlueprint : [];
  logHarnessSub_('[HARNESS_SUB] L3-B3: buildRoutingTrace_');
  // [PROPOSAL50] P0-2: ROUTING_TRACE_V1 — export_gate 완료 후 라우팅 trace 확정
  var routingTrace = buildRoutingTrace_(
    (h1 && h1.intradayLock) || false, cashFloorInfo, hApex, capturedAtIso
  );
  hApex.routing_trace_json = routingTrace;
  hApex.routing_serving_trace_v2_json = buildRoutingServingTraceV2_(routingTrace, hApex);
  logHarnessSub_('[HARNESS_SUB] L3-B4: buildWatchLedger_');
  // [PROPOSAL50] P0-3: WATCH_LEDGER_V1 — validation_status != PASS 행 물리 분리
  hApex.watch_ledger_json = buildWatchLedger_(orderBlueprint, h4);
  logHarnessSub_('[HARNESS_SUB] L3-B5: buildShadowLedger_');
  // [PROPOSAL50] H10: SHADOW_LEDGER_V1 — BLOCKED 블루프린트 투명 원장
  hApex.shadow_ledger_json = buildShadowLedger_(orderBlueprint, dfMap);
  logHarnessSub_('[HARNESS_SUB] L3-B6: calcTrimPlanMinCash_');
  // [PROPOSAL50] G2: TRIM_PLAN_MIN_CASH_V1 — 최소 현금 TRIM 계획 결정론적 산출
  // h2는 sell_priority 결과 객체; sell_priority_table 배열만 전달
  hApex.trim_plan_to_min_cash_json = calcTrimPlanMinCash_(
    asResult.holdings, dfMap, cashShortfallInfo,
    (h2 && h2.sell_priority_table) ? h2.sell_priority_table : (Array.isArray(h2) ? h2 : [])
  );
  // [PROPOSAL51] P1-C: CRDL-V1 — 현금회복 3분리 표시 잠금 (trim_plan 확정 후)
  hApex.cash_recovery_display_json = calcCashRecoveryDisplayLock_(
    hApex.scrs_v2_json || {},
    hApex.trim_plan_to_min_cash_json || [],
    cashShortfallInfo || {}
  );
  logHarnessSub_('[HARNESS_SUB] L3-B7: calcLlmServingConstraint_');
  // [PROPOSAL50] D2: LLM_SERVING_CONSTRAINT_V1 — 12가지 금지행동 체크 (보고서 조립 직전)
  hApex.llm_serving_constraint_json = calcLlmServingConstraint_(hApex);
  logHarnessSub_('[HARNESS_SUB] L3-B8: calcDeterministicServingLock_');
  // [PROPOSAL50] P2-1: DSLE-V1 — 서빙 잠금 (파이프라인 최종 단계)
  var servingLock = calcDeterministicServingLock_(hApex, capturedAtIso, now);
  hApex.serving_lock_json = servingLock;
  return {
    hApex: hApex,
    dfgResult: apexCore.dfgResult,
    claExitJson: apexCore.claExitJson,
    slgRows: apexCore.slgRows,
    pcgResult: apexCore.pcgResult
  };
}


function assembleHarnessApexCore_(
  ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar,
  h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso
) {
  logHarnessSub_('[HARNESS_SUB] L3-B1a: calcApexExecutionHarness_');
  var hApex = calcApexExecutionHarness_(
    asResult.holdings, dfMap, sectorFlowRadar, kospiRet5d,
    h1, h2, h3, h4, orderBlueprint, cashShortfallInfo, marketRegime
  );
  logHarnessSub_('[HARNESS_SUB] L3-B1b: applyApexPostProcessing_');
  var apexPost = applyApexPostProcessing_(
    ss, now, capturedAtIso, asResult.holdings, dfMap, totalAsset, kospiRet5d, marketRegime, hApex
  );
  return {
    hApex: apexPost.hApex,
    dfgResult: apexPost.dfgResult,
    claExitJson: apexPost.claExitJson,
    slgRows: apexPost.slgRows,
    pcgResult: apexPost.pcgResult
  };
}


function prepareHarnessContextInputs_(ss) {
  // 공통 데이터 읽기와 H1 사전 게이트를 분리해 buildHarnessContext_()를 얇게 유지한다.
  var settings = readSettings_(ss);
  var performance = readPerformanceSheet_();
  var totalAsset = toNumber_(settings['total_asset_krw']);
  var mrsScore = toNumber_(settings['mrs_score'] || settings['MRS'] || 5);
  var dfMap = buildDataFeedMap_(ss);
  var asResult = parseAccountSnapshot_(ss, totalAsset, dfMap);
  var kospiRet5d  = readKospiRet5d_(ss);
  var kospiRet20d = readKospiRet20d_(ss);
  var sectorFlowRadar = readSectorFlowForRadar_(ss);

  if (totalAsset <= 0) totalAsset = asResult.derivedTotalAsset;

  var harnessState = calcHarnessPortfolioGuardState_(
    ss, asResult, settings, performance, totalAsset, mrsScore
  );

  return {
    settings: settings,
    performance: performance,
    totalAsset: totalAsset,
    mrsScore: mrsScore,
    dfMap: dfMap,
    asResult: asResult,
    kospiRet5d: kospiRet5d,
    kospiRet20d: kospiRet20d,
    sectorFlowRadar: sectorFlowRadar,
    harnessState: harnessState
  };
}


function 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
) {
  var rows = buildHarnessRows_(
    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
  );
  assertHarnessRowsComplete_(rows);
  writeHarnessSheet_(ss, rows, now);
  return rows;
}


function calcHarnessPortfolioHealthScore_(gateMap) {
  return calcPortfolioHealthScore_(gateMap);
}


function calcHarnessPortfolioGuardState_(ss, asResult, settings, performance, totalAsset, mrsScore) {
  var settlementCashPct = totalAsset > 0
    ? round2_(asResult.settlementCashD2Krw / totalAsset * 100) : 0;
  var totalHeatPct = totalAsset > 0
    ? round2_(asResult.totalHeatKrw / totalAsset * 100) : 0;
  var buyPowerKrw = asResult.settlementCashD2Krw - asResult.openOrderAmountKrw;

  var intradayLock = calcIntradayLock_(asResult.capturedAt);
  var capturedAtIso = asResult.capturedAt ? formatIso_(asResult.capturedAt) : '';
  var snapshotFreshness = checkAccountSnapshotFreshness_();
  var snapshotGate = snapshotExecutionGate_(snapshotFreshness);
  var cashFloorInfo = calcCashFloor_(mrsScore, settlementCashPct);
  var cashShortfallInfo = calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore);

  var drawdownGuard = calcDrawdownGuard_(performance);
  var winLossStreakGuard = calcWinLossStreakGuard_(performance);
  var marketRegime = readMacroRegime_(ss);
  var regimeTrimGuidance = calcRegimeTrimGuidance_(marketRegime);
  var regimeTransitionAlert = calcRegimeTransitionAlert_(marketRegime, ss, settings);
  var regimeSizeScale = calcRegimeSizeScale_(marketRegime);
  var regimeCashMinPct = calcRegimeCashUplift_(marketRegime, cashFloorInfo.minPct);
  if (regimeCashMinPct > cashFloorInfo.minPct) {
    cashFloorInfo.minPct = regimeCashMinPct;
    cashFloorInfo.status = settlementCashPct >= regimeCashMinPct ? 'OK' : 'BELOW_FLOOR';
    cashShortfallInfo = calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore);
  }
  var heatThresholds = calcHeatThresholdsByRegime_(marketRegime);
  var heatGate = totalHeatPct >= heatThresholds.hardBlock ? 'BLOCK_NEW_BUY'
    : totalHeatPct >= heatThresholds.halve ? 'HALVE_NEW_BUY_QUANTITY'
    : 'ALLOW_CONTINUE';
  var actions = calcActions_(intradayLock, heatGate, cashFloorInfo.status);

  var h1 = {
    intradayLock: intradayLock,
    snapshotExecutionGate: snapshotGate.status,
    snapshotExecutionReason: snapshotGate.reason,
    accountSnapshotFreshness: snapshotFreshness,
    heatGate: heatGate,
    heatGateThresholdPct: heatThresholds.hardBlock,
    drawdownBuyScale: drawdownGuard.buy_scale,
    drawdownGuardState: drawdownGuard.state,
    regimeSizeScale: regimeSizeScale.scale,
    winLossStreakBuyScale: winLossStreakGuard.buy_scale,
    winLossStreakState: winLossStreakGuard.state,
    cashFloorStatus: cashFloorInfo.status,
    cashFloorMinPct: cashFloorInfo.minPct,
    totalAsset: totalAsset,
    buyPowerKrw: buyPowerKrw,
    performanceMultiplier: performance.bayesian_multiplier,
    performanceLabel: performance.bayesian_label,
    performanceBuyBias: calcPerformanceBuyBias_(performance),
  };

  return {
    settlementCashPct: settlementCashPct,
    totalHeatPct: totalHeatPct,
    buyPowerKrw: buyPowerKrw,
    intradayLock: intradayLock,
    capturedAtIso: capturedAtIso,
    snapshotFreshness: snapshotFreshness,
    snapshotGate: snapshotGate,
    cashFloorInfo: cashFloorInfo,
    cashShortfallInfo: cashShortfallInfo,
    drawdownGuard: drawdownGuard,
    winLossStreakGuard: winLossStreakGuard,
    marketRegime: marketRegime,
    regimeTrimGuidance: regimeTrimGuidance,
    regimeTransitionAlert: regimeTransitionAlert,
    regimeSizeScale: regimeSizeScale,
    regimeCashMinPct: regimeCashMinPct,
    heatThresholds: heatThresholds,
    heatGate: heatGate,
    actions: actions,
    h1: h1,
  };
}


function applyApexPostProcessing_(ss, now, capturedAtIso, holdings, dfMap, totalAsset, kospiRet5d, marketRegime, hApex) {
  // ── [2026-05-21_SPRINT_B] Sprint B 게이트 산출 ───────────────────────────────
  logHarnessSub_('[HARNESS_SUB] L3-B1b-1: calcHarnessDataFreshnessGate_');
  var dfgResult = calcHarnessDataFreshnessGate_(capturedAtIso, now);
  logHarnessSub_('[HARNESS_SUB] L3-B1b-2: calcClaRegimeExitCondition_');
  var claExitJson = calcClaRegimeExitCondition_(dfMap, marketRegime);
  logHarnessSub_('[HARNESS_SUB] L3-B1b-3: calcSatelliteLifecycleGate_');
  var slgRows = calcSatelliteLifecycleGate_(
    holdings, dfMap, hApex.alpha_evaluation_window_json || []
  );
  logHarnessSub_('[HARNESS_SUB] L3-B1b-4: calcPortfolioCorrelationGate_');
  var pcgResult = calcPortfolioCorrelationGate_(
    holdings, dfMap, totalAsset, kospiRet5d
  );

  // Direction DFG: STALE_WARN/STALE_BLOCK → SAQG ELIGIBLE 하향
  if (dfgResult.data_freshness_status === 'STALE_WARN'
      || dfgResult.data_freshness_status === 'STALE_BLOCK') {
    (hApex.saqg_json || []).forEach(function(r) {
      if (r.saqg_v1 === 'ELIGIBLE') {
        r.saqg_v1 = 'WATCHLIST_ONLY';
        r.hts_allowed = false;
        r.saqg_downgraded_by = 'DFG_' + dfgResult.data_freshness_status;
      }
    });
  }
  hApex.data_freshness_json = dfgResult;
  hApex.cla_regime_exit_json = claExitJson;
  hApex.satellite_lifecycle_gate_json = slgRows;
  hApex.portfolio_correlation_gate_json = pcgResult;

  // [C-1] AFL: alpha history upsert (T+20/T+60 graduated holdings)
  try {
    appendAlphaHistory_(ss, hApex.alpha_evaluation_window_json || [], holdings, dfMap, marketRegime);
  } catch(e) {
    Logger.log("[AFL] appendAlphaHistory_ error: " + e.message);
  }
  try {
    hApex.alpha_feedback_json = runAlphaFeedbackLoop_();
  } catch(e) {
    Logger.log("[AFL] runAlphaFeedbackLoop_ error: " + e.message);
    hApex.alpha_feedback_json = getAlphaFeedbackJson_();
  }

  // Direction PCG: CORRELATION_BLOCK → 약한 위성 BUY 추가 차단
  if (pcgResult.correlation_gate_status === 'CORRELATION_BLOCK') {
    (hApex.buy_permission_json || []).forEach(function(bp) {
      var h = holdings.find(function(x) { return x.ticker === bp.ticker; });
      if (!h || h.position_type === 'core') return;
      var df = dfMap[bp.ticker] || {};
      var slg = slgRows.find(function(r) { return r.ticker === bp.ticker; });
      var weakSignal = df.rs_verdict === 'LAGGARD' || df.brt_verdict === 'BROKEN'
                     || (slg && (slg.lifecycle_stage === 'REVIEW' || slg.lifecycle_stage === 'EXIT'));
      if (weakSignal && bp.buy_permission_state !== 'BLOCKED') {
        bp.buy_permission_state = 'BLOCKED';
        bp.blocked_reason_codes = (bp.blocked_reason_codes || [])
          .concat(['CORRELATION_BLOCK_WEAK_SATELLITE']);
      }
    });
  }

  return {
    hApex: hApex,
    dfgResult: dfgResult,
    claExitJson: claExitJson,
    slgRows: slgRows,
    pcgResult: pcgResult,
  };
}


function applyApexProposal46Suite_(ss, holdings, dfMap, h2, h3, cashShortfallInfo, asResult, cashFloorInfo, capturedAtIso, now, hApex) {
  logHarnessSub_('[HARNESS_SUB] L3-B2a: applyApexMacroAlphaSuite_');
  hApex = applyApexMacroAlphaSuite_(holdings, dfMap, hApex);
  logHarnessSub_('[HARNESS_SUB] L3-B2b: applyApexProtectionAndFeedbackSuite_');
  hApex = applyApexProtectionAndFeedbackSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex);
  logHarnessSub_('[HARNESS_SUB] L3-B2c: applyApexConsistencySuite_');
  hApex = applyApexConsistencySuite_(hApex, asResult, dfMap, cashFloorInfo, capturedAtIso, now);
  return hApex;
}


function applyApexMacroAlphaSuite_(holdings, dfMap, hApex) {
  return applyApexMacroAlphaSuiteImpl_(holdings, dfMap, hApex);
}


function applyApexMacroEventSuite_(hApex) {
  return applyApexMacroEventSuiteImpl_(hApex);
}


function applyApexPredictiveAlphaSuite_(holdings, dfMap, hApex) {
  return applyApexPredictiveAlphaSuiteImpl_(holdings, dfMap, hApex);
}


function applyApexProtectionAndFeedbackSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex) {
  logHarnessSub_('[HARNESS_SUB] L3-B2b-i: applyApexCashPreservationSuite_');
  hApex = applyApexCashPreservationSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex);
  logHarnessSub_('[HARNESS_SUB] L3-B2b-ii: applyApexFeedbackSignalSuite_');
  hApex = applyApexFeedbackSignalSuite_(holdings, dfMap, hApex);
  return hApex;
}


function applyApexCashPreservationSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex) {
  // THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/inject_computed_harness.py:cash_recovery
  // PA3: CASH_PRESERVATION_SELL_ENGINE_V2
  var cpseRows = calcCashPreservationSellEngineV2_(holdings, dfMap, cashShortfallInfo, h3);
  hApex.cash_preservation_sell_json = cpseRows;
  // [PROPOSAL50] P1-2: SCRS-V2 — 주식가치 보호 + 반등 포착 통합 현금확보 엔진
  var scrsResult = calcSmartCashRecoverySell_(holdings, dfMap, cashShortfallInfo, h2, hApex);
  hApex.scrs_v2_json = scrsResult;
  // [PROPOSAL51] P2-B: PROACTIVE_SELL_RADAR_V2 — 8신호 D-3일 사전 분배 감지
  var ppMap = {};
  ((hApex.profit_preservation_json) || []).forEach(function(pp) {
    var tk = (pp.ticker || pp.ticker_code || '').toString();
    if (tk) ppMap[tk] = pp;
  });
  hApex.proactive_sell_radar_json = calcProactiveSellRadarV2_(holdings, dfMap, ppMap);
  return hApex;
}


function applyApexFeedbackSignalSuite_(holdings, dfMap, hApex) {
  // THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_final_decision
  // anti_late_entry_json set first — watch_breakout uses ALE grade to filter grade-F chasers
  logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-0: anti_late_entry_json');
  hApex.anti_late_entry_json = calcAntiLateEntryGateV2_(holdings, dfMap);
  logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-1: applyApexWatchBreakoutSuite_');
  hApex = applyApexWatchBreakoutSuite_(holdings, dfMap, hApex);
  logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-2: applyApexAntiWhipsawSuite_');
  hApex = applyApexAntiWhipsawSuite_(holdings, dfMap, hApex);
  logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-3: applyApexAlphaHistorySuite_');
  hApex = applyApexAlphaHistorySuite_(hApex);
  return hApex;
}


function applyApexWatchBreakoutSuite_(holdings, dfMap, hApex) {
  return applyApexWatchBreakoutSuiteImpl_(holdings, dfMap, hApex);
}


function applyApexAntiWhipsawSuite_(holdings, dfMap, hApex) {
  // [PROPOSAL48_A3] ANTI_WHIPSAW_REENTRY_GATE_V1
  var awrRows = calcAntiWhipsawReentryGateV1_(
    hApex.sell_candidates_json || [], dfMap, holdings
  );
  hApex.anti_whipsaw_reentry_json = awrRows;
  return hApex;
}


function applyApexAlphaHistorySuite_(hApex) {
  // [PROPOSAL48_C7] alpha_history T20/T60 통계 집계 — T+5 피드백 루프 가시화
  hApex.alpha_history_summary_json = getAlphaHistorySummary_();
  return hApex;
}


function applyApexConsistencySuite_(hApex, asResult, dfMap, cashFloorInfo, capturedAtIso, now) {
  // PA5: CONSISTENCY_VALIDATOR_V2
  var cvResult = calcConsistencyValidatorV2_(hApex, asResult, cashFloorInfo, capturedAtIso, now);
  hApex.consistency_report_json = cvResult;
  hApex.consistency_score       = cvResult.consistency_score;
  hApex.cv_verdict              = cvResult.cv_verdict;
  // [PROPOSAL51] P0-B: SPSV2 — 매도 주문 3중 가격 검증 (Export Gate 전에 실행)
  hApex.order_blueprint_json = calcSellPriceSanityV2_(
    hApex.order_blueprint_json || [],
    hApex.profit_preservation_json || []
  );

  // [PROPOSAL51] P2-D: SEQG-V1 — 매도 실행 품질 채점 (SPSV2 후, Export Gate 전)
  hApex.sell_execution_quality_json = calcSellExecutionQualityGate_(
    hApex.order_blueprint_json || [],
    [],  // holdings은 hApex에 직접 포함되지 않아 PSR 데이터만으로 채점
    hApex.proactive_sell_radar_json || []
  );

  // [PROPOSAL51] P0-C: SEMICONDUCTOR_CLUSTER_SYNC_V1 — cluster gate ↔ mandatory_reduction 정합성
  hApex.cluster_sync_result_json = syncSemiconductorCluster_(hApex);

  // [PROPOSAL51] P0-D: PHL-V1 — 5계층 가격 단일화 잠금 (SPSV2 통과 후)
  hApex.price_hierarchy_json = applyPriceHierarchyLockAll_(hApex);

  // [PROPOSAL51] P1-B: DQG-V2 — 필드 충족률 데이터 완성도 게이트
  hApex.data_quality_gate_v2_json = calcDataQualityGateV2_(hApex);

  // [PROPOSAL53] P0-A: FUNDAMENTAL_QUALITY_GATE_V1
  hApex.fundamental_quality_json = calcFundamentalQualityGateV1_(asResult.holdings || [], dfMap || {});
  // [PROPOSAL53] P0-B: HORIZON_ALLOCATION_LOCK_V1
  hApex.horizon_allocation_json = calcHorizonAllocationLockV1_(asResult.holdings || [], hApex);
  // [PROPOSAL53] P0-C: SMART_MONEY_LIQUIDITY_GATE_V1
  hApex.smart_money_liquidity_json = calcSmartMoneyLiquidityGateV1_(asResult.holdings || [], hApex);
  // [PROPOSAL54] P0.5 확장 하네스
  hApex.fundamental_multifactor_json = calcFundamentalMultiFactorScoreV2_(asResult.holdings || [], dfMap || {});
  hApex.earnings_growth_quality_json = calcEarningsGrowthQualityGateV1_(asResult.holdings || [], dfMap || {});
  hApex.market_share_proxy_json = calcMarketShareMomentumProxyV1_(asResult.holdings || [], dfMap || {}, hApex);
  hApex.cashflow_stability_json = calcCashflowStabilityGateV1_(asResult.holdings || [], dfMap || {});
  hApex.routing_explain_json = calcRoutingExplainLockV1_(hApex);
  hApex.gs_formula_mirror_json = buildGsFormulaMirrorV1_();
  // [PROPOSAL54 P0.6] 신규 5개 하네스 실거래 BUY 차단 연동
  hApex.order_blueprint_json = applyProposal54BuyBlockLocks_(hApex.order_blueprint_json || [], hApex);

  // [PROPOSAL51-FIX-ORDER] calcExportGate_ 호출 전 portfolio_health_score 숫자형 보장.
  // buildHarnessContext_()의 FIX(line 2272)보다 이 함수가 먼저 실행되므로
  // 여기서 재확인하지 않으면 CHECK_7이 항상 undefined를 본다.
  if (typeof hApex.portfolio_health_score !== 'number' || isNaN(hApex.portfolio_health_score)) {
    var _phsJson = hApex.portfolio_health_json;
    var _phsRaw  = _phsJson && _phsJson.score;
    hApex.portfolio_health_score = (typeof _phsRaw === 'number' && !isNaN(_phsRaw)) ? _phsRaw : 0;
  }

  // [PROPOSAL50] P0-1: EXPORT_GATE_V1 — PENDING_EXPORT 원인 자동 진단
  var egResult = calcExportGate_(hApex, asResult, cashFloorInfo);
  hApex.export_gate_json       = egResult;
  hApex.json_validation_status = egResult.json_validation_status;
  hApex.hts_entry_allowed      = egResult.hts_entry_allowed;
  return hApex;
}

/**
 * GS Formula Mirror V1
 * Python 보조 도구로 생성되는 공식들을 GAS 하네스 계층에서도 명시적으로 추적한다.
 * 목적: YAML↔GS 커버리지의 소스오브트루스를 GAS 쪽에 고정.
 */
function buildGsFormulaMirrorV1_() {
  var formulaIds = [
    'BLANK_CELL_AUDIT_V1',
    'CASHFLOW_QUALITY_SIGNAL_V1',
    'EARNINGS_QUALITY_SIGNAL_V1',
    'EJCE_VIEW_RENDERER_V1',
    'FUNDAMENTAL_MULTIFACTOR_V3',
    'FUNDAMENTAL_RAW_INGEST_V1',
    'GROWTH_RATE_SIGNAL_V1',
    'HORIZON_CLASSIFICATION_V1',
    'LIQUIDITY_FLOW_SIGNAL_V1',
    'MARKET_SHARE_SIGNAL_V2',
    'PORTFOLIO_ALPHA_CONFIDENCE_PER_TICKER_V1',
    'RATCHET_TRAILING_GENERAL_V1',
    'ROUTING_EXECUTION_LOG_TABLE_V1',
    'SMART_CASH_RECOVERY_V3',
    'SMART_MONEY_FLOW_SIGNAL_V2',
    'VALUE_PRESERVATION_SCORER_V1'
  ];
  var rows = [];
  for (var i = 0; i < formulaIds.length; i++) {
    rows.push({
      formula_id: formulaIds[i],
      implementation_layer: 'GAS_MIRROR',
      mirror_state: 'DECLARED',
      formula_id_source: 'GS_FORMULA_MIRROR_V1'
    });
  }
  return {
    formula_id: 'GS_FORMULA_MIRROR_V1',
    rows: rows
  };
}

function applyProposal54BuyBlockLocks_(blueprint, hApex) {
  // THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/inject_computed_harness.py:main
  blueprint = Array.isArray(blueprint) ? blueprint : [];
  function toMap_(obj, key, condFn) {
    var m = {};
    var rows = (obj && obj.rows) || [];
    if (!Array.isArray(rows)) return m;
    rows.forEach(function(r) {
      var tk = String((r || {})[key] || '');
      if (!tk) return;
      m[tk] = condFn(r || {});
    });
    return m;
  }
  var fm = (hApex && hApex.fundamental_multifactor_json) || {};
  var egq = (hApex && hApex.earnings_growth_quality_json) || {};
  var msp = (hApex && hApex.market_share_proxy_json) || {};
  var cfs = (hApex && hApex.cashflow_stability_json) || {};

  var fmMap = toMap_(fm, 'ticker', function(r){ return Number(r.score_0_100 || 0) < 60; });
  var egqMap = toMap_(egq, 'ticker', function(r){ return String(r.gate || '') === 'BLOCK_BUY'; });
  var mspMap = toMap_(msp, 'ticker', function(r){ return String(r.proxy_state || '') === 'LOSING'; });
  var cfsMap = toMap_(cfs, 'ticker', function(r){ return String(r.gate || '') === 'BLOCK_BUY'; });

  return blueprint.map(function(row) {
    var r = Object.assign({}, row);
    var orderType = String(r.order_type || '').toUpperCase();
    var isBuy = orderType === 'BUY' || orderType === 'ADD_ON' || orderType === 'STAGED_BUY';
    if (!isBuy) return r;
    var tk = String(r.ticker || '');
    var blocks = [];
    // 충돌 우선순위: Cashflow/Fundamental 계열 > Share/Earnings
    if (cfsMap[tk]) blocks.push('CASHFLOW_STABILITY_GATE_V1');
    if (fmMap[tk]) blocks.push('FUNDAMENTAL_MULTI_FACTOR_SCORE_V2');
    if (mspMap[tk]) blocks.push('MARKET_SHARE_MOMENTUM_PROXY_V1');
    if (egqMap[tk]) blocks.push('EARNINGS_GROWTH_QUALITY_GATE_V1');
    if (blocks.length > 0) {
      r.validation_status = 'BLOCKED';
      r.blocked_by_gate = blocks.join('|');
      r.rationale_code = (r.rationale_code ? String(r.rationale_code) + '|' : '') + 'P054_BUY_BLOCK:' + r.blocked_by_gate;
      if (typeof r.quantity === 'number' && r.quantity > 0) r.quantity = 0;
    }
    return r;
  });
}

function calcFundamentalMultiFactorScoreV2_(holdings, dfMap) {
  holdings = Array.isArray(holdings) ? holdings : [];
  dfMap = dfMap || {};
  var rows = holdings.map(function(h) {
    var tk = String(h.ticker || '');
    var df = dfMap[tk] || {};
    var m = {
      roe: toNumber_(df.roe_pct),
      opm: toNumber_(df.opm_pct),
      rev: toNumber_(df.revenue_growth_pct),
      opg: toNumber_(df.op_income_growth_pct),
      share: toNumber_(df.market_share_proxy_pct),
      ocf: toNumber_(df.operating_cf_krw),
      fcf: toNumber_(df.free_cf_krw),
      debt: toNumber_(df.debt_ratio_pct)
    };
    var score = 0;
    var fail = [];
    if (m.roe !== null && m.roe >= 8) score += 15; else fail.push('ROE');
    if (m.opm !== null && m.opm >= 8) score += 15; else fail.push('OPM');
    if (m.rev !== null && m.rev >= 0) score += 15; else fail.push('REV_GROWTH');
    if (m.opg !== null && m.opg >= 0) score += 15; else fail.push('OP_GROWTH');
    if (m.share !== null && m.share >= 0) score += 10; else fail.push('SHARE_PROXY');
    if (m.ocf !== null && m.ocf > 0) score += 15; else fail.push('OCF');
    if (m.fcf !== null && m.fcf > 0) score += 10; else fail.push('FCF');
    if (m.debt !== null && m.debt <= 200) score += 5; else fail.push('DEBT');
    var grade = score >= 80 ? 'A' : score >= 65 ? 'B' : score >= 50 ? 'C' : 'D';
    return {
      ticker: tk,
      name: h.name || '',
      score_0_100: score,
      grade: grade,
      buy_allowed: score >= 60 && fail.length <= 4,
      fail_reasons: fail
    };
  });
  return { formula_id: 'FUNDAMENTAL_MULTI_FACTOR_SCORE_V2', rows: rows };
}

function calcEarningsGrowthQualityGateV1_(holdings, dfMap) {
  holdings = Array.isArray(holdings) ? holdings : [];
  dfMap = dfMap || {};
  var rows = holdings.map(function(h) {
    var tk = String(h.ticker || '');
    var df = dfMap[tk] || {};
    var q1 = toNumber_(df.eps_growth_qoq_pct);
    var y1 = toNumber_(df.eps_growth_yoy_pct);
    var trend = (q1 !== null && y1 !== null && q1 >= 0 && y1 >= 0) ? 'ACCELERATING' :
                (q1 !== null && y1 !== null && q1 < 0 && y1 < 0) ? 'DECELERATING' : 'MIXED';
    var gate = trend === 'DECELERATING' ? 'BLOCK_BUY' : 'PASS_OR_WATCH';
    return { ticker: tk, name: h.name || '', trend: trend, consistency: (trend === 'MIXED' ? 'LOW' : 'HIGH'), gate: gate };
  });
  return { formula_id: 'EARNINGS_GROWTH_QUALITY_GATE_V1', rows: rows };
}

function calcMarketShareMomentumProxyV1_(holdings, dfMap, hApex) {
  holdings = Array.isArray(holdings) ? holdings : [];
  dfMap = dfMap || {};
  var alphaMap = {};
  ((hApex && hApex.alpha_lead_json) || []).forEach(function(r){ alphaMap[String(r.ticker || '')] = r; });
  var rows = holdings.map(function(h) {
    var tk = String(h.ticker || '');
    var df = dfMap[tk] || {};
    var alpha = alphaMap[tk] || {};
    var rev = toNumber_(df.revenue_growth_pct);
    var rs = toNumber_(alpha.alpha_lead_score);
    var state = (rev !== null && rev < 0) || (rs !== null && rs < 50) ? 'LOSING' :
                (rev !== null && rev > 5 && rs !== null && rs >= 70) ? 'GAINING' : 'NEUTRAL';
    return { ticker: tk, name: h.name || '', proxy_state: state, confidence_band: state === 'NEUTRAL' ? 'MEDIUM' : 'HIGH' };
  });
  return { formula_id: 'MARKET_SHARE_MOMENTUM_PROXY_V1', rows: rows };
}

function calcCashflowStabilityGateV1_(holdings, dfMap) {
  holdings = Array.isArray(holdings) ? holdings : [];
  dfMap = dfMap || {};
  var rows = holdings.map(function(h) {
    var tk = String(h.ticker || '');
    var df = dfMap[tk] || {};
    var ocf = toNumber_(df.operating_cf_krw);
    var fcf = toNumber_(df.free_cf_krw);
    var accrual = toNumber_(df.accrual_ratio_pct);
    var unstable = (ocf !== null && ocf <= 0) || (fcf !== null && fcf <= 0);
    var accrRisk = (accrual !== null && accrual > 10);
    return {
      ticker: tk,
      name: h.name || '',
      stability_state: unstable ? 'UNSTABLE' : 'STABLE',
      accrual_risk_flag: !!accrRisk,
      gate: (unstable && accrRisk) ? 'BLOCK_BUY' : 'PASS_OR_WATCH'
    };
  });
  return { formula_id: 'CASHFLOW_STABILITY_GATE_V1', rows: rows };
}

function calcRoutingExplainLockV1_(hApex) {
  var eg = (hApex && hApex.export_gate_json) || {};
  return {
    formula_id: 'ROUTING_DECISION_EXPLAIN_LOCK_V1',
    gate_path: ['FUNDAMENTAL_MULTI_FACTOR_SCORE_V2','EARNINGS_GROWTH_QUALITY_GATE_V1','MARKET_SHARE_MOMENTUM_PROXY_V1','CASHFLOW_STABILITY_GATE_V1','EXPORT_GATE_V2'],
    blocked_by: eg.hts_entry_allowed ? null : String(eg.json_validation_status || 'REVIEW_ONLY'),
    override_allowed: false
  };
}

// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL53] 신규 P0 하네스 4종
// ═══════════════════════════════════════════════════════════════════════
function calcFundamentalQualityGateV1_(holdings, dfMap) {
  holdings = Array.isArray(holdings) ? holdings : [];
  dfMap = dfMap || {};
  var rows = holdings.map(function(h) {
    var tk = String(h.ticker || '');
    var df = dfMap[tk] || {};
    var roe = toNumber_(df.roe_pct);
    var opGrowth = toNumber_(df.op_income_growth_pct);
    var debt = toNumber_(df.debt_ratio_pct);
    var ocf = toNumber_(df.operating_cf_krw);
    var pe = toNumber_(df.pe_ttm);
    var pass = 0;
    var fail = [];
    if (roe !== null && roe >= 8) pass++; else fail.push('ROE_WEAK_OR_MISSING');
    if (opGrowth !== null && opGrowth >= 0) pass++; else fail.push('OP_GROWTH_WEAK_OR_MISSING');
    if (debt !== null && debt <= 200) pass++; else fail.push('DEBT_RATIO_HIGH_OR_MISSING');
    if (ocf !== null && ocf > 0) pass++; else fail.push('OCF_WEAK_OR_MISSING');
    if (pe !== null && pe > 0 && pe <= 35) pass++; else fail.push('PE_BAND_OUT_OR_MISSING');
    var grade = pass >= 4 ? 'A' : pass >= 3 ? 'B' : pass >= 2 ? 'C' : 'D';
    return {
      ticker: tk,
      name: h.name || '',
      grade: grade,
      score: pass,
      buy_allowed: pass >= 3,
      fail_reasons: fail,
      formula_id: 'FUNDAMENTAL_QUALITY_GATE_V1'
    };
  });
  return {
    formula_id: 'FUNDAMENTAL_QUALITY_GATE_V1',
    rows: rows
  };
}

function calcHorizonAllocationLockV1_(holdings, hApex) {
  holdings = Array.isArray(holdings) ? holdings : [];
  var totalAsset = toNumber_((hApex && hApex.total_asset_krw) || 0) || 0;
  var cap = { SHORT: 25, MID: 45, LONG: 70, UNKNOWN: 0 };
  var bucketSum = { SHORT: 0, MID: 0, LONG: 0, UNKNOWN: 0 };
  var rows = holdings.map(function(h) {
    var bucket = String(h.invest_horizon || h.horizon_bucket || 'UNKNOWN').toUpperCase();
    if (!cap.hasOwnProperty(bucket)) bucket = 'UNKNOWN';
    var v = toNumber_(h.marketValue) || toNumber_(h.market_value_krw) || toNumber_(h.close) * (toNumber_(h.holdingQty) || 0) || 0;
    bucketSum[bucket] += v;
    return { ticker: String(h.ticker || ''), name: h.name || '', bucket: bucket, market_value_krw: v };
  });
  var byBucket = Object.keys(bucketSum).map(function(k) {
    var pct = totalAsset > 0 ? (bucketSum[k] / totalAsset * 100) : 0;
    return {
      bucket: k,
      cap_pct: cap[k],
      current_pct: Math.round(pct * 100) / 100,
      violation: pct > cap[k]
    };
  });
  return {
    formula_id: 'HORIZON_ALLOCATION_LOCK_V1',
    bucket_summary: byBucket,
    rows: rows
  };
}

function calcSmartMoneyLiquidityGateV1_(holdings, hApex) {
  holdings = Array.isArray(holdings) ? holdings : [];
  var radarMap = {};
  ((hApex && hApex.proactive_sell_radar_json) || []).forEach(function(r) {
    radarMap[String(r.ticker || '')] = r;
  });
  var rows = holdings.map(function(h) {
    var tk = String(h.ticker || '');
    var r = radarMap[tk] || {};
    var flowState = Number(r.score || 0) >= 6 ? 'OUTFLOW_RISK' : 'NEUTRAL';
    var liqState = Number(r.liquidity_5d_bn || 0) > 0 && Number(r.liquidity_5d_bn) < 80 ? 'LOW' : 'NORMAL';
    var mode = (flowState === 'OUTFLOW_RISK' && liqState === 'LOW') ? 'SELL_SPLIT_ONLY' : 'NORMAL';
    return {
      ticker: tk,
      name: h.name || '',
      flow_state: flowState,
      liquidity_state: liqState,
      execution_mode: mode,
      buy_allowed: mode === 'NORMAL',
      formula_id: 'SMART_MONEY_LIQUIDITY_GATE_V1'
    };
  });
  return {
    formula_id: 'SMART_MONEY_LIQUIDITY_GATE_V1',
    rows: rows
  };
}

function buildRoutingServingTraceV2_(routingTrace, hApex) {
  var rt = routingTrace || {};
  var eg = (hApex && hApex.export_gate_json) || {};
  return {
    trace_version: 'V2',
    llm_serving_budget: 0,
    request_route: rt.request_route || 'PIPELINE_EOD_BATCH',
    bundle_selected: rt.bundle_selected || 'retirement_portfolio_compact',
    prompt_entrypoint: rt.prompt_entrypoint || 'prompts/analysis_prompt.md',
    gate_path: ['DATA_QUALITY_GATE_V2', 'SELL_PRICE_SANITY_V2', 'EXPORT_GATE_V2'],
    final_block_reason: eg.hts_entry_allowed ? null : String(eg.json_validation_status || 'REVIEW_ONLY'),
    json_validation_status: eg.json_validation_status || rt.json_validation_status || 'PENDING_EXPORT',
    formula_id: 'ROUTING_SERVING_DECISION_TRACE_V2'
  };
}


// ── H1 헬퍼 ──────────────────────────────────────────────────────────────────

/**
 * readMacroRegime_
 * macro 시트의 REGIME_PRELIM 행에서 시장 국면 값 읽기
 * buildHarnessContext_()에서 국면별 감축 가이던스 산출에 사용
 */
function readMacroRegime_(ss) {
  try {
    var sh = ss.getSheetByName('macro');
    if (!sh) return 'UNKNOWN';
    var data = sh.getDataRange().getValues();
    for (var i = 0; i < data.length; i++) {
      if (String(data[i][0] || '') === 'REGIME_PRELIM'
          || String(data[i][1] || '') === 'Market_Regime_Prelim') {
        return String(data[i][3] || 'UNKNOWN');
      }
    }
    return 'UNKNOWN';
  } catch(e) {
    Logger.log('[HARNESS] readMacroRegime_ error: ' + e.message);
    return 'UNKNOWN';
  }
}

/**
 * calcRegimeTrimGuidance_
 * REGIME_TRIM_WEIGHT_V1: 시장 국면별 위성/주도주 감축 비율 결정론적 산출
 * LLM이 "조정기엔 5~10%" 같은 주관적 판단을 내리는 것을 하네스에서 선점
 * spec/13_formula_registry.yaml:REGIME_TRIM_WEIGHT_V1 참조
 */
function calcRegimeTrimGuidance_(regime) {
  switch (regime) {
    case 'SECULAR_LEADER_RISK_ON':
    case 'RISK_ON':
      return {
        phase:                    'ADVANCE',
        satellite_trim_pct_min:   0,
        satellite_trim_pct_max:   5,
        leader_trim_pct_min:      0,
        leader_trim_pct_max:      0,
        priority_order:           'HOLD_ALL > 약한위성_5%이하 > 중복ETF',
        new_buy_gate:             'ALLOWED_IF_HEAT_PASS',
        description:              '상승기: 주도주 보유 극대화. 감축 최소화.'
      };
    case 'LEADER_CONCENTRATION':
    case 'NEUTRAL':
      return {
        phase:                    'PULLBACK_IN_UPTREND',
        satellite_trim_pct_min:   5,
        satellite_trim_pct_max:   10,
        leader_trim_pct_min:      0,
        leader_trim_pct_max:      5,
        priority_order:           '약한위성 > 중복ETF > 주도주_소량헤지',
        new_buy_gate:             'BLOCKED',
        description:              '조정/횡보기: 위성 부분 감축. 주도주 소량 헤지 가능.'
      };
    case 'RISK_OFF_CANDIDATE':
      return {
        phase:                    'DISTRIBUTION',
        satellite_trim_pct_min:   10,
        satellite_trim_pct_max:   25,
        leader_trim_pct_min:      5,
        leader_trim_pct_max:      10,
        priority_order:           '중복ETF > 약한위성 > 주도주_이익잠금',
        new_buy_gate:             'BLOCKED',
        description:              '분배장 경고: 위성 우선 감축. 현금 목표 12% 이상.'
      };
    case 'RISK_OFF':
    case 'EVENT_SHOCK':
      return {
        phase:                    'BREAKDOWN',
        satellite_trim_pct_min:   25,
        satellite_trim_pct_max:   50,
        leader_trim_pct_min:      10,
        leader_trim_pct_max:      25,
        priority_order:           '코어보호해제 > 전종목감축검토',
        new_buy_gate:             'HARD_BLOCKED',
        description:              '추세붕괴/이벤트쇼크: 전면 감축. 코어 예외 없음.'
      };
    default:
      return {
        phase:                    'UNKNOWN',
        satellite_trim_pct_min:   0,
        satellite_trim_pct_max:   0,
        leader_trim_pct_min:      0,
        leader_trim_pct_max:      0,
        priority_order:           'DATA_MISSING_REGIME — 국면 미확인',
        new_buy_gate:             'BLOCKED',
        description:              '국면 미확인: 신규매수 보류. macro 재실행 후 재판정.'
      };
  }
}

function calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore) {
  var targetCashPct = Math.max(5 + (mrsScore / 10) * 15, cashFloorInfo.minPct);
  var d2Krw = asResult.settlementCashD2Krw || 0;
  var asset = Number.isFinite(totalAsset) ? totalAsset : 0;
  return {
    cash_current_pct_d2: asset > 0 ? Math.round(d2Krw / asset * 10000) / 100 : 0,
    cash_target_pct: targetCashPct,
    cash_shortfall_min_krw: Math.max(0, Math.round(asset * cashFloorInfo.minPct / 100 - d2Krw)),
    cash_shortfall_target_krw: Math.max(0, Math.round(asset * targetCashPct / 100 - d2Krw))
  };
}

/**
 * SECULAR_LEADER_REGIME_GATE_V1
 * 삼성전자(005930)·SK하이닉스(000660) secular_leader_profit_lock 결정론적 발동 게이트.
 * spec/exit/take_profit.yaml:secular_leader_profit_lock.activation_required_all 완전 구현.
 * 반환: { active, status, reasons }
 */
function calcSecularLeaderGate_(ticker, marketRegime, df, holdingQty) {
  var SECULAR_TICKERS = ['005930', '000660'];
  var reasons = [];

  if (SECULAR_TICKERS.indexOf(ticker) < 0) {
    return { active: false, status: 'NOT_APPLICABLE', reasons: ['not_secular_leader_ticker'] };
  }

  // ── 비활성 조건 검사 (any one → 즉시 비활성) ────────────────────────────
  var close  = df.close  || 0;
  var ma20   = df.ma20   || 0;
  var frg5d  = typeof df.frg5d  === 'number' ? df.frg5d  : null;
  var inst5d = typeof df.inst5d === 'number' ? df.inst5d : null;
  var acTotal = typeof df.acTotal === 'number' ? df.acTotal : 0;

  var deactivationReasons = [];

  if (marketRegime !== 'SECULAR_LEADER_RISK_ON') {
    deactivationReasons.push('regime_not_secular(' + marketRegime + ')');
  }
  if (close > 0 && ma20 > 0 && close <= ma20) {
    deactivationReasons.push('close(' + close + ')<=MA20(' + ma20 + ')');
  }
  if (acTotal >= 3) {
    deactivationReasons.push('anti_climax_gate>=' + acTotal);
  }
  if (frg5d !== null && inst5d !== null && frg5d < 0 && inst5d < 0) {
    deactivationReasons.push('dual_outflow:frg5d(' + frg5d + ')_inst5d(' + inst5d + ')');
  }

  if (deactivationReasons.length > 0) {
    return {
      active: false,
      status: 'DEACTIVATED',
      reasons: deactivationReasons
    };
  }

  // ── 활성화 조건 검사 (all must pass) ────────────────────────────────────
  var activationFails = [];

  if (!(holdingQty > 0)) {
    activationFails.push('no_holding_quantity');
  }
  if (close <= 0 || ma20 <= 0) {
    activationFails.push('close_or_ma20_missing');
  } else if (close <= ma20) {
    activationFails.push('close_below_ma20');
  }
  var flowOk  = df.flowOk === 'Y';
  var flowPos = (frg5d !== null && frg5d > 0) || (inst5d !== null && inst5d > 0);
  if (!flowOk || !flowPos) {
    activationFails.push('flow_condition_fail(flowOk=' + df.flowOk + ',frg5d=' + frg5d + ',inst5d=' + inst5d + ')');
  }

  if (activationFails.length > 0) {
    return {
      active: false,
      status: 'ACTIVATION_FAIL',
      reasons: activationFails
    };
  }

  return {
    active: true,
    status: 'ACTIVE',
    reasons: ['regime=SECULAR_LEADER_RISK_ON', 'close>MA20', 'flow_ok', 'holding_confirmed']
  };
}

function calcIntradayLock_(capturedAt) {
  if (!capturedAt) return false;
  var d = capturedAt instanceof Date ? capturedAt : new Date(capturedAt);
  if (isNaN(d.getTime())) return false;
  var kstMin = ((d.getUTCHours() + 9) % 24) * 60 + d.getUTCMinutes();
  return kstMin < INTRADAY_CUTOFF_MINUTES;
}

/**
 * N1: POSITION_SIZE_REGIME_SCALE_V1
 * 국면에 따라 atrQty 기반 매수 수량의 스케일 배수를 반환한다.
 * M1(DrawdownGuard) 이후에 추가로 적용되는 독립적 국면 방어층.
 * @param {string} regime
 * @return {{ scale, regime_applied }}
 */
function calcRegimeSizeScale_(regime) {
  var r = String(regime || '').toUpperCase();
  if (r.indexOf('EVENT_SHOCK') >= 0)                        return { scale: 0.25, regime_applied: regime };
  if (r.indexOf('RISK_OFF') >= 0)                           return { scale: 0.50, regime_applied: regime };
  if (r.indexOf('SECULAR_LEADER') >= 0 && r.indexOf('RISK_ON') >= 0) return { scale: 1.2, regime_applied: regime };
  if (r.indexOf('RISK_ON') >= 0)                            return { scale: 1.1, regime_applied: regime };
  return { scale: 1.0, regime_applied: regime };  // NEUTRAL
}

/**
 * N5: REGIME_CASH_UPLIFT_V1
 * 국면에 따라 현금 최소 비율을 상향하는 오버라이드를 반환한다.
 * MRS 기반 calcCashFloor_ 결과보다 높을 때만 적용된다.
 * @param {string} regime
 * @param {number} mrsCashMinPct — 현재 MRS 기반 최소 현금 %
 * @return {number} effectiveMinPct
 */
function calcRegimeCashUplift_(regime, mrsCashMinPct) {
  var r = String(regime || '').toUpperCase();
  var regimeMin = 0;
  if (r.indexOf('EVENT_SHOCK') >= 0)      regimeMin = 20;
  else if (r.indexOf('RISK_OFF') >= 0)    regimeMin = 15;
  else if (r.indexOf('RISK_ON') >= 0)     regimeMin = 5;   // 완화
  // NEUTRAL: regimeMin=0 → MRS값 그대로
  return Math.max(mrsCashMinPct, regimeMin);
}

/**
 * N3: STOP_PRICE_ADEQUACY_V1
 * 보유 종목의 수동 손절가가 ATR 기반 권고 손절가 대비 적정한지 검증한다.
 * manual_stop < recommended_stop × 0.85 → STOP_WIDE (너무 넓어 Heat 과소 반영)
 * manual_stop < recommended_stop × 0.60 → STOP_CRITICAL (손절 의지 없음 수준)
 * @param {Array}  holdings
 * @param {Object} dfMap
 * @return {Array} stop_adequacy rows
 */
function calcStopAdequacyRows_(holdings, dfMap) {
  return holdings.map(function(h) {
    var df   = dfMap[h.ticker] || {};
    var atr20 = typeof df.atr20 === 'number' && df.atr20 > 0 ? df.atr20 : null;
    var close = df.close || h.close || 0;
    var avgCost = h.avgCost || 0;

    var recommendedStop = null;
    if (atr20 && close > 0 && avgCost > 0) {
      var atrMul = (atr20 / avgCost * 100 >= 8) ? 2.0 : 1.5;
      recommendedStop = Math.max(avgCost * 0.92, avgCost - atr20 * atrMul);
      recommendedStop = tickNormalize_(recommendedStop);
    }

    var status = 'PASS';
    var stopGap = null;
    if (recommendedStop !== null && h.stopPrice > 0) {
      stopGap = round2_((recommendedStop - h.stopPrice) / recommendedStop * 100);
      if (h.stopPrice < recommendedStop * 0.60)      status = 'STOP_CRITICAL';
      else if (h.stopPrice < recommendedStop * 0.85) status = 'STOP_WIDE';
    } else if (!atr20) {
      status = 'INSUFFICIENT_DATA';
    }

    return {
      ticker:            h.ticker,
      name:              h.name || '',
      manual_stop:       h.stopPrice || null,
      recommended_stop:  recommendedStop,
      stop_gap_pct:      stopGap,
      adequacy_status:   status,
      stop_price_src:    h.stopPriceSrc || 'UNKNOWN',
      formula_id:        'STOP_PRICE_ADEQUACY_V1'
    };
  });
}

/**
 * N4: HOLDING_STALE_REVIEW_V1
 * 보유 기간이 60일을 초과한 종목에 STALE_POSITION 플래그를 표시한다.
 * account_snapshot의 entry_date 컬럼 기반. 없으면 ENTRY_DATE_MISSING.
 * @param {Array} holdings — entryDate 필드 포함
 * @return {Array} holding_stale rows
 */
function calcHoldingStaleReview_(holdings) {
  var nowMs = Date.now();
  var STALE_DAYS = 60;
  var REVIEW_DAYS = 30;

  return holdings.map(function(h) {
    var entryDateStr = h.entryDate || null;
    var holdingDays = null;
    var status = 'ENTRY_DATE_MISSING';

    if (entryDateStr) {
      var entryMs = new Date(entryDateStr).getTime();
      if (Number.isFinite(entryMs) && entryMs > 0) {
        holdingDays = Math.floor((nowMs - entryMs) / 86400000);
        if (holdingDays > STALE_DAYS)        status = 'STALE_POSITION';
        else if (holdingDays > REVIEW_DAYS)  status = 'REVIEW_SOON';
        else                                 status = 'FRESH';
      }
    }

    return {
      ticker:        h.ticker,
      name:          h.name || '',
      entry_date:    entryDateStr,
      holding_days:  holdingDays,
      stale_status:  status,
      formula_id:    'HOLDING_STALE_REVIEW_V1'
    };
  });
}

/**
 * P1: STOP_BREACH_ALERT_V1
 * 보유 종목 중 close <= stop_price인 종목을 즉시 경보한다.
 * close <= stop_price         → BREACH_IMMEDIATE_EXIT
 * close <= stop_price × 1.03 → STOP_APPROACHING
 * @param {Array}  holdings
 * @param {Object} dfMap
 * @return {{ gate, alerts }}
 */
function calcStopBreachAlert_(holdings, dfMap) {
  // THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/inject_computed_harness.py:calc_stop_breach_alerts
  var gate = 'PASS';
  var alerts = holdings.map(function(h) {
    var df       = dfMap[h.ticker] || {};
    var close    = h.close || df.close || 0;
    var stopPrc  = h.stopPrice || 0;
    var status   = 'PASS';
    var gapPct   = null;
    if (close > 0 && stopPrc > 0) {
      gapPct = round2_((close - stopPrc) / stopPrc * 100);
      if (close <= stopPrc) {
        status = 'BREACH_IMMEDIATE_EXIT';
        gate = 'BREACH';
      } else if (close <= stopPrc * 1.03) {
        status = 'STOP_APPROACHING';
        if (gate === 'PASS') gate = 'APPROACHING';
      }
    } else {
      status = 'INSUFFICIENT_DATA';
    }
    return { ticker: h.ticker, name: h.name || '', close: close, stop_price: stopPrc, stop_src: h.stopPriceSrc || 'UNKNOWN', gap_pct: gapPct, status: status, formula_id: 'STOP_BREACH_ALERT_V1' };
  });
  return { gate: gate, alerts: alerts };
}

/**
 * P1-BIS: RELATIVE_STOP_SIGNAL_V1
 * 시장 베타 보정 후 초과수익(20D) 기반 상대 손절 신호.
 * k=2.0 → threshold = -k × σ_proxy; ABS_FLOOR=-20%; TIME_STOP=60일+음수 초과수익
 * @param {Array}  holdings
 * @param {Object} dfMap
 * @param {number} kospiRet20d — KOSPI 20D 수익률 (%)
 * @return {{ gate, signals }}
 */
function calcRelativeStopSignal_(holdings, dfMap, kospiRet20d) {
  var K         = 2.0;
  var ABS_FLOOR = -20.0;
  var gate      = 'PASS';
  var signals   = holdings.map(function(h) {
    var df        = dfMap[h.ticker] || {};
    var ret20d    = typeof df.ret20d === 'number' ? df.ret20d : parseFloat(df.ret20d);
    var atr20     = typeof df.atr20  === 'number' ? df.atr20  : parseFloat(df.atr20);
    var close     = h.close || df.close || 0;
    var profitPct = typeof h.profitPct === 'number' ? h.profitPct : parseFloat(h.profitPct);
    var holdDays  = typeof h.holdingDays === 'number' ? h.holdingDays : parseInt(h.holdingDays) || 0;

    if (!Number.isFinite(ret20d) || !Number.isFinite(atr20) || close <= 0) {
      return { ticker: h.ticker, name: h.name || '', signal: false,
               signal_type: 'INSUFFICIENT_DATA', details: {}, formula_id: 'RELATIVE_STOP_SIGNAL_V1' };
    }

    var betaProxy = 1.0;
    if (typeof kospiRet20d === 'number' && Math.abs(kospiRet20d) >= 0.5) {
      betaProxy = Math.min(3.0, Math.max(0.3, ret20d / kospiRet20d));
    }
    var excessRet = ret20d - betaProxy * kospiRet20d;
    var sigmaProxy = (atr20 / close * 100) * Math.sqrt(20);
    var threshold  = -K * sigmaProxy;

    var relBreach  = excessRet < threshold;
    var absBreach  = Number.isFinite(profitPct) && profitPct < ABS_FLOOR;
    var timeBreach = holdDays >= 60 && excessRet < 0;
    var triggered  = relBreach || absBreach || timeBreach;
    var signalType = absBreach ? 'ABS_FLOOR' : (relBreach ? 'REL_EXCESS' : (timeBreach ? 'TIME_STOP' : 'PASS'));

    if (triggered && gate === 'PASS') gate = 'TRIGGERED';

    return {
      ticker:     h.ticker,
      name:       h.name || '',
      signal:     triggered,
      signal_type: signalType,
      details: {
        beta_proxy:    round2_(betaProxy),
        excess_ret20d: round2_(excessRet),
        sigma_proxy:   round2_(sigmaProxy),
        threshold:     round2_(threshold),
        profit_pct:    Number.isFinite(profitPct) ? round2_(profitPct) : null,
        hold_days:     holdDays
      },
      formula_id: 'RELATIVE_STOP_SIGNAL_V1'
    };
  });
  return { gate: gate, signals: signals };
}

/**
 * P3: ABSOLUTE_RISK_STOP_V1
 * stop adequacy rows를 절대 리스크 손절 taxonomy에 맞춰 표준화한다.
 * @param {Array} holdings
 * @param {Object} dfMap
 * @return {{ gate, rows }}
 */
function calcAbsoluteRiskStopV1_(holdings, dfMap) {
  // THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_stop_price_core
  var rows = calcStopAdequacyRows_(holdings, dfMap).map(function(r) {
    var stopPrice = Number.isFinite(r.manual_stop) && r.manual_stop > 0
      ? r.manual_stop
      : r.recommended_stop;
    return {
      ticker: r.ticker,
      name: r.name || '',
      stop_price: Number.isFinite(stopPrice) ? round2_(stopPrice) : null,
      stop_quantity: null,
      adequacy_status: r.adequacy_status,
      stop_gap_pct: r.stop_gap_pct,
      formula_id: 'ABSOLUTE_RISK_STOP_V1'
    };
  });
  var gate = rows.some(function(r) { return r.adequacy_status === 'STOP_CRITICAL'; }) ? 'BLOCK' : 'PASS';
  return { gate: gate, rows: rows };
}

/**
 * P3: RELATIVE_UNDERPERF_ALERT_V1
 * 상대약세 경보를 표준 taxonomy로 감싼다.
 * @param {Array} holdings
 * @param {Object} dfMap
 * @param {number} kospiRet20d
 * @return {{ gate, rows }}
 */
function calcRelativeUnderperfAlertV1_(holdings, dfMap, kospiRet20d) {
  var result = calcRelativeStopSignal_(holdings, dfMap, kospiRet20d);
  return {
    gate: result.gate,
    rows: result.signals.map(function(r) {
      return {
        ticker: r.ticker,
        name: r.name || '',
        signal: !!r.signal,
        signal_type: r.signal_type,
        details: r.details || {},
        formula_id: 'RELATIVE_UNDERPERF_ALERT_V1'
      };
    })
  };
}

/**
 * P3: STOP_ACTION_LADDER_V1
 * exit sell action 결과를 손절/익절/시간손절 taxonomy로 표준화한다.
 * @param {Object} ctx
 * @return {{ formula_id, action, ratio_pct, limit_price, price_basis, reason, validation }}
 */
var calcStopActionLadderV1_ = function(ctx) {
  var d = calcExitSellAction_(ctx || {});
  return {
    formula_id: 'STOP_ACTION_LADDER_V1',
    action: d.action,
    ratio_pct: d.ratio_pct,
    limit_price: d.limit_price,
    price_basis: d.price_basis,
    reason: d.reason,
    validation: d.validation,
    order_type: d.order_type,
    price_source: d.price_source
  };
}


/**
 * P2: TP_TRIGGER_ALERT_V1
 * close >= tp1_price / tp2_price인 종목을 감지하고 tp_quantity_ladder_json과 연계한다.
 * 익절 가격 도달 시 즉각 수량을 확정론적으로 제공한다.
 * @param {Array}  holdings
 * @param {Object} dfMap
 * @param {Object} h4 — calcPrices_() 반환값 (h4.prices 배열)
 * @param {Array}  tpLadderRows — calcTpQuantityLadder_() 반환값
 * @return {{ gate, triggered }}
 */
function calcTpTriggerAlert_(holdings, dfMap, h4, tpLadderRows) {
  // THIN_ADAPTER: [take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_tp_validity
  var priceMap  = {};
  (h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; });
  var ladderMap = {};
  (tpLadderRows || []).forEach(function(r) { ladderMap[r.ticker] = r; });

  var gate = 'PASS';
  var triggered = [];
  holdings.forEach(function(h) {
    var df       = dfMap[h.ticker] || {};
    var close    = h.close || df.close || 0;
    var pr       = priceMap[h.ticker]  || {};
    var lr       = ladderMap[h.ticker] || {};
    var tp1      = typeof pr.tp1_price === 'number' ? pr.tp1_price : null;
    var tp2      = typeof pr.tp2_price === 'number' ? pr.tp2_price : null;
    var tp1Hit   = tp1 !== null && close > 0 && close >= tp1;
    var tp2Hit   = tp2 !== null && close > 0 && close >= tp2;
    if (!tp1Hit && !tp2Hit) return;
    if (gate === 'PASS') gate = 'TRIGGERED';
    triggered.push({
      ticker:        h.ticker,
      name:          h.name || '',
      close:         close,
      tp1_price:     tp1,
      tp2_price:     tp2,
      tp1_triggered: tp1Hit,
      tp2_triggered: tp2Hit,
      tp1_qty:       lr.tp1_qty   !== undefined ? lr.tp1_qty   : null,
      tp2_qty:       lr.tp2_qty   !== undefined ? lr.tp2_qty   : null,
      qty_source:    lr.qty_source || 'NO_LADDER',
      formula_id:    'TP_TRIGGER_ALERT_V1'
    });
  });
  return { gate: gate, triggered: triggered };
}

/**
 * P3: HEAT_CONCENTRATION_ALERT_V1
 * 단일 종목이 전체 Total Heat의 50% 이상을 차지하면 HEAT_CONCENTRATED 경보.
 * 해당 종목 급락 시 total_heat_pct가 급변해 게이트가 무력화되는 리스크 차단.
 * @param {Array}  holdings — avgCost, stopPrice, holdingQty 포함
 * @param {number} totalHeatKrw
 * @return {{ gate, by_holding }}
 */
function calcHeatConcentrationAlert_(holdings, totalHeatKrw) {
  if (!totalHeatKrw || totalHeatKrw <= 0) {
    return { gate: 'INSUFFICIENT_DATA', by_holding: [], formula_id: 'HEAT_CONCENTRATION_ALERT_V1' };
  }
  var gate = 'PASS';
  var rows = holdings.map(function(h) {
    var heatI = (h.avgCost > 0 && h.stopPrice > 0 && h.holdingQty > 0)
      ? (h.avgCost - h.stopPrice) * h.holdingQty : 0;
    var sharePct = round2_(heatI / totalHeatKrw * 100);
    var status = sharePct >= 50 ? 'HEAT_CONCENTRATED' : 'PASS';
    if (status === 'HEAT_CONCENTRATED') gate = 'HEAT_CONCENTRATED';
    return { ticker: h.ticker, name: h.name || '', heat_krw: Math.round(heatI), heat_share_pct: sharePct, status: status, formula_id: 'HEAT_CONCENTRATION_ALERT_V1' };
  });
  return { gate: gate, by_holding: rows };
}

/**
 * P4: REGIME_TRANSITION_ALERT_V1
 * settings.prev_market_regime와 현재 국면을 비교해 전환 유형을 산출한다.
 * UPGRADE(완화) / DOWNGRADE(긴축) / LATERAL_SHIFT / NO_CHANGE
 * 실행 후 current regime을 settings에 자동 기록.
 * @param {string} marketRegime
 * @param {Object} ss
 * @param {Object} settings
 * @return {{ transition_type, prev_regime, current_regime, affected_gates }}
 */
function calcRegimeTransitionAlert_(marketRegime, ss, settings) {
  var prevRegime = String(settings['prev_market_regime'] || '').trim();
  var curr = String(marketRegime || '').toUpperCase();
  var prev = prevRegime.toUpperCase();
  writeSettingValue_(ss, 'prev_market_regime', marketRegime);

  if (!prevRegime || prev === curr) {
    return { transition_type: 'NO_CHANGE', prev_regime: prevRegime || null, current_regime: marketRegime, affected_gates: [], formula_id: 'REGIME_TRANSITION_ALERT_V1' };
  }

  var RANK = { 'EVENT_SHOCK': 0, 'RISK_OFF': 1, 'NEUTRAL': 2, 'RISK_ON': 3, 'SECULAR_LEADER': 4 };
  var getRank = function(r) {
    if (r.indexOf('SECULAR_LEADER') >= 0) return 4;
    if (r.indexOf('RISK_ON') >= 0)        return 3;
    if (r.indexOf('NEUTRAL') >= 0)        return 2;
    if (r.indexOf('RISK_OFF') >= 0)       return 1;
    if (r.indexOf('EVENT_SHOCK') >= 0)    return 0;
    return 2;
  };
  var transitionType = getRank(curr) > getRank(prev) ? 'UPGRADE'
    : getRank(curr) < getRank(prev) ? 'DOWNGRADE'
    : 'LATERAL_SHIFT';
  var AFFECTED = [
    'DYNAMIC_HEAT_GATE_V1', 'POSITION_SIZE_REGIME_SCALE_V1', 'REGIME_CASH_UPLIFT_V1',
    'PORTFOLIO_BETA_GATE_V1', 'SECTOR_CONCENTRATION_LIMIT_V1',
    'SEMICONDUCTOR_CLUSTER_GATE_V1', 'SINGLE_POSITION_WEIGHT_CAP_V1', 'POSITION_COUNT_LIMIT_V1'
  ];
  return { transition_type: transitionType, prev_regime: prevRegime, current_regime: marketRegime, affected_gates: AFFECTED, formula_id: 'REGIME_TRANSITION_ALERT_V1' };
}

/**
 * P5: PORTFOLIO_HEALTH_SCORE_V1
 * 모든 게이트 상태를 집계해 HEALTHY/CAUTION/CRITICAL 단일 레이블을 산출한다.
 * CRITICAL 게이트 1개 이상, 또는 CAUTION 게이트 3개 이상 → CRITICAL
 * CAUTION 게이트 1~2개 → CAUTION, 0개 → HEALTHY
 * score = max(0, 100 - critical×30 - caution×10)
 * @param {Object} gateMap — { gate_id: gate_status_string }
 * @return {{ label, score, critical_count, caution_count, blocked_gates }}
 */
function calcPortfolioHealthScore_(gateMap) {
  var CRITICAL = ['BLOCK_NEW_BUY', 'HARD_BLOCK', 'NO_BUY', 'DRAWDOWN_FORCE_RISK_OFF',
                  'POSITION_COUNT_BLOCK', 'CLUSTER_BLOCK', 'BREACH',
                  'OVER_BETA', 'BLOCK_SECTOR', 'STOP_CRITICAL'];
  var CAUTION  = ['HALVE_NEW_BUY_QUANTITY', 'TRIM_REQUIRED', 'REDUCE_BUY', 'CAUTION_BUY',
                  'DRAWDOWN_CAUTION', 'WARN_BETA', 'WARN_TOP2', 'OVERWEIGHT_TRIM',
                  'EDGE_DEGRADED', 'EDGE_WEAK', 'EDGE_CRITICAL', 'APPROACHING',
                  'TRIGGERED', 'HEAT_CONCENTRATED', 'DOWNGRADE'];
  var critCount = 0, warnCount = 0, blocked = [];
  Object.keys(gateMap).forEach(function(name) {
    var val = String(gateMap[name] || '').trim();
    if (CRITICAL.indexOf(val) >= 0) {
      critCount++;
      blocked.push({ gate: name, status: val, severity: 'CRITICAL' });
    } else if (CAUTION.indexOf(val) >= 0) {
      warnCount++;
      blocked.push({ gate: name, status: val, severity: 'CAUTION' });
    }
  });
  var label = (critCount >= 1 || warnCount >= 3) ? 'CRITICAL'
    : warnCount >= 1 ? 'CAUTION'
    : 'HEALTHY';
  return {
    label:              label,
    score:              Math.max(0, 100 - critCount * 30 - warnCount * 10),
    critical_count:     critCount,
    caution_count:      warnCount,
    blocked_gates:      blocked,
    gate_input_count:   Object.keys(gateMap).length,
    formula_id:         'PORTFOLIO_HEALTH_SCORE_V1'
  };
}

/**
 * O1: SINGLE_POSITION_WEIGHT_CAP_V1
 * 개별 종목 비중이 국면별 상한(NEUTRAL:20%, RISK_OFF:15%)을 초과하면 OVERWEIGHT_TRIM.
 * M5(섹터 편중)와 독립적인 종목 단위 비중 하드 캡.
 * @param {Array}  holdings — weightPct 포함
 * @param {string} marketRegime
 * @return {{ gate_status, cap_pct, by_position }}
 */
/**
 * LEADER_POSITION_WEIGHT_CAP_V1
 * 삼성전자(005930), SK하이닉스(000660)에 대해 KOSPI 시총 비중 기반 차등 한도 적용.
 * spec/strategy/semiconductor_concentration_policy.yaml 기준.
 *
 * 배경: 삼성전자 KOSPI 비중 ~23%. 기존 고정 20% 한도는 시장 비중보다 낮아
 * 주도주를 사실상 과소보유 강제. 국면별로 시장 비중 × 배수를 허용한다.
 *
 * @param {Array}  holdings
 * @param {string} marketRegime
 * @param {number} kospiSamsungWeightPct  — settings.kospi_samsung_weight_pct (기본 23)
 * @param {number} kospiHynixWeightPct    — settings.kospi_hynix_weight_pct (기본 12)
 */
function calcSinglePositionWeightCap_(holdings, marketRegime, kospiSamsungWeightPct, kospiHynixWeightPct) {
  var r = String(marketRegime || '').toUpperCase();
  var isEventShock    = r.indexOf('EVENT_SHOCK') >= 0;
  var isRiskOff       = isEventShock || r.indexOf('RISK_OFF') >= 0;
  var isRiskOn        = r.indexOf('RISK_ON') >= 0 && !isRiskOff;
  var isSecularLeader = r.indexOf('SECULAR_LEADER') >= 0;

  // settings에서 KOSPI 개별 종목 비중 읽기 (KRX/FnGuide 시총 데이터 기반 수동 입력)
  // 미입력(0) 시 mktWtProvided=false → 정책 기반 고정 한도만 적용
  var smWt = (Number.isFinite(kospiSamsungWeightPct) && kospiSamsungWeightPct > 0)
    ? kospiSamsungWeightPct : 0;
  var hxWt = (Number.isFinite(kospiHynixWeightPct) && kospiHynixWeightPct > 0)
    ? kospiHynixWeightPct : 0;
  var smWtProvided = smWt > 0;
  var hxWtProvided = hxWt > 0;

  // 일반 종목 한도 (기존 유지)
  var defaultCap = isRiskOff ? 15.0 : (isRiskOn ? 22.0 : 20.0);

  var gate = 'PASS';
  var rows = holdings.map(function(h) {
    var wPct = typeof h.weightPct === 'number' ? h.weightPct : 0;
    var tickerCap;

    if (h.ticker === '005930') {
      // 삼성전자 — 국면별 정책 한도 (EXPERT_PRIOR, calibration_registry 등록)
      // KOSPI 비중 제공 시: 비중×배수 vs 정책 한도 중 큰 값
      // KOSPI 비중 미제공 시: 정책 한도만 (추측값 삽입 금지)
      if (isEventShock)
        tickerCap = 15.0;
      else if (isRiskOff)
        tickerCap = 18.0;
      else if (isSecularLeader)
        tickerCap = smWtProvided ? Math.max(50.0, smWt * 2.20) : 50.0;
      else if (isRiskOn)
        tickerCap = smWtProvided ? Math.max(40.0, smWt * 1.70) : 40.0;
      else  // NEUTRAL
        tickerCap = smWtProvided ? Math.max(28.0, smWt * 1.20) : 28.0;

    } else if (h.ticker === '000660') {
      // SK하이닉스 — 국면별 정책 한도
      if (isEventShock)
        tickerCap = 10.0;
      else if (isRiskOff)
        tickerCap = 12.0;
      else if (isSecularLeader)
        tickerCap = hxWtProvided ? Math.max(28.0, hxWt * 2.50) : 28.0;
      else if (isRiskOn)
        tickerCap = hxWtProvided ? Math.max(22.0, hxWt * 1.80) : 22.0;
      else  // NEUTRAL
        tickerCap = hxWtProvided ? Math.max(15.0, hxWt * 1.20) : 15.0;

    } else {
      tickerCap = defaultCap;
    }

    tickerCap = round2_(tickerCap);
    var status = wPct > tickerCap ? 'OVERWEIGHT_TRIM' : 'PASS';
    if (status === 'OVERWEIGHT_TRIM') gate = 'OVERWEIGHT_TRIM';

    return {
      ticker:      h.ticker,
      name:        h.name || '',
      weight_pct:  wPct,
      cap_pct:     tickerCap,
      status:      status,
      is_leader:   (h.ticker === '005930' || h.ticker === '000660'),
      formula_id:  'LEADER_POSITION_WEIGHT_CAP_V1'
    };
  });

  return {
    gate_status:          gate,
    cap_pct:              defaultCap,
    kospi_samsung_weight: smWtProvided ? round2_(smWt) : 'DATA_MISSING_SET_IN_SETTINGS',
    kospi_hynix_weight:   hxWtProvided ? round2_(hxWt) : 'DATA_MISSING_SET_IN_SETTINGS',
    by_position:          rows,
    formula_id:           'LEADER_POSITION_WEIGHT_CAP_V1'
  };
}

/**
 * O2: SEMICONDUCTOR_CLUSTER_GATE_V1
 * 005930(삼성전자) + 000660(SK하이닉스) 합산 비중이 상한을 초과하면 CLUSTER_BLOCK.
 * 두 종목이 같은 사이클에서 동반 하락하는 상관 리스크 통제.
 * @param {Array}  holdings
 * @param {string} marketRegime
 * @return {{ gate_status, combined_pct, cap_pct, holdings }}
 */
/**
 * MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1
 * 반도체 클러스터 한도를 KOSPI 시총 비중 기반으로 동적 산출한다.
 * spec/strategy/semiconductor_concentration_policy.yaml 기준.
 *
 * 배경: 삼성+하이닉스 KOSPI 비중 ~35%. 기존 고정 25% 한도는 주도장에서
 * 시장 대비 필연적 언더퍼폼을 강제. 시장 비중은 최소 허용해야 한다.
 *
 * @param {Array}  holdings
 * @param {string} marketRegime
 * @param {number} kospiSemiWeightPct  — settings.kospi_semi_weight_pct (기본 35)
 */
function calcSemiconductorClusterGate_(holdings, marketRegime, kospiSemiWeightPct) {
  var r = String(marketRegime || '').toUpperCase();
  var isEventShock = r.indexOf('EVENT_SHOCK') >= 0;
  var isRiskOff    = isEventShock || r.indexOf('RISK_OFF') >= 0;
  var isRiskOn     = r.indexOf('RISK_ON') >= 0 && !isRiskOff;
  var isSecularLeader = r.indexOf('SECULAR_LEADER') >= 0;
  var isCLA = r.indexOf('CONCENTRATED_LEADER_ADVANCE') >= 0 || r === 'CLA';

  // settings에서 KOSPI 반도체 시총 비중 읽기 (사용자가 KRX 데이터 기반으로 직접 입력)
  // 0 또는 미입력이면 DATA_MISSING — 아래 정책 기반 한도만 적용
  var mktWt = (Number.isFinite(kospiSemiWeightPct) && kospiSemiWeightPct > 0)
    ? kospiSemiWeightPct : 0;
  var mktWtProvided = mktWt > 0;

  // 국면별 정책 한도 (EXPERT_PRIOR — calibration_registry.yaml 등록값)
  // 주의: KOSPI 비중은 KRX/FnGuide 시총 데이터 기준으로 settings에서만 입력.
  // 하드코딩 추정치 사용 금지. settings 미입력 시 정책 한도만 적용.
  var capPct, gateMode;
  if (isEventShock) {
    capPct   = mktWtProvided ? Math.max(20.0, mktWt * 0.60) : 20.0;
    gateMode = 'DEFENSIVE_STRICT';
  } else if (isRiskOff) {
    capPct   = mktWtProvided ? Math.max(25.0, mktWt * 0.80) : 25.0;
    gateMode = 'DEFENSIVE';
  } else if (isSecularLeader || isCLA) {
    capPct   = 65.0;
    gateMode = 'SECULAR_LEADER';
  } else if (isRiskOn) {
    capPct   = mktWtProvided ? Math.max(45.0, mktWt * 1.30) : 45.0;
    gateMode = 'RISK_ON_OVERWEIGHT';
  } else {
    capPct   = mktWtProvided ? Math.max(35.0, mktWt * 1.00) : 35.0;
    gateMode = 'MARKET_NEUTRAL';
  }

  // CLA 상태에서는 KODEX 반도체(229200)도 클러스터에 포함
  var SEMI_BASE = ['005930', '000660'];
  var SEMI_CLA  = ['005930', '000660', '229200'];
  var clusterTickers = isCLA ? SEMI_CLA : SEMI_BASE;

  var total = 0;
  var clusterRows = [];
  holdings.forEach(function(h) {
    if (clusterTickers.indexOf(h.ticker) >= 0) {
      var wPct = typeof h.weightPct === 'number' ? h.weightPct : 0;
      total += wPct;
      clusterRows.push({ ticker: h.ticker, name: h.name || '', weight_pct: wPct });
    }
  });

  // 게이트 판정
  // WARN 경계: mktWt 제공 시 mktWt × 0.90, 미제공 시 capPct × 0.80
  var warnThreshold = mktWtProvided ? mktWt * 0.90 : capPct * 0.80;
  var gate, clusterState;
  if (total >= capPct) {
    if (isRiskOff) {
      gate         = 'CLUSTER_BLOCK';
      clusterState = 'CLUSTER_HOLD_ONLY';
    } else {
      gate         = 'CLUSTER_OVERWEIGHT_TRIM';
      clusterState = 'CLUSTER_HOLD_ONLY';
    }
  } else if (total >= warnThreshold) {
    if (isSecularLeader || isCLA) {
      gate         = 'CLUSTER_HOLD_ONLY';
      clusterState = 'CLUSTER_HOLD_ONLY';
    } else {
      gate         = 'CLUSTER_OVERWEIGHT_WARN';
      clusterState = 'CLUSTER_OPEN';
    }
  } else {
    gate         = 'PASS';
    clusterState = 'CLUSTER_OPEN';
  }

  return {
    gate_status:          gate,
    cluster_state:        clusterState,
    cluster_id:           'SEMICONDUCTOR_KR',
    cluster_tickers:      clusterTickers,
    combined_pct:         round2_(total),
    cap_pct:              round2_(capPct),
    kospi_semi_weight:    mktWtProvided ? round2_(mktWt) : 'DATA_MISSING_SET_IN_SETTINGS',
    kospi_weight_provided: mktWtProvided,
    gate_mode:           gateMode,
    holdings:            clusterRows,
    formula_id:          'MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1'
  };
}

/**
 * SATELLITE_FAILURE_GATE_V1
 * 위성 집단 실패 추적 — spec/13_formula_registry.yaml:SATELLITE_FAILURE_GATE_V1
 * @param {Array} satelliteRows — { composite_verdict, rs_verdict, ret20d, excess_ret_10d }
 * @return {{ sfg_v1, sfg_reason, sfg_broken_count, sfg_failure_rate }}
 */
function calcSatelliteFailureGate_(satelliteRows) {
  if (!satelliteRows || satelliteRows.length === 0) {
    return { sfg_v1: 'CLEAR', sfg_reason: 'no_satellite_data',
             sfg_broken_count: 0, sfg_failure_rate: 0,
             formula_id: 'SATELLITE_FAILURE_GATE_V1' };
  }
  var brokenCount = 0, failureCount = 0;
  var totalRet20d = 0, totalExcess = 0, retCount = 0;

  satelliteRows.forEach(function(row) {
    var cv = row.composite_verdict || '';
    var rv = row.rs_verdict || '';
    if (cv === 'CLOSE_POSITION' || rv === 'BROKEN') brokenCount++;
    if (cv === 'REDUCE_CANDIDATE' || cv === 'EXIT_REVIEW' || cv === 'CLOSE_POSITION') failureCount++;
    if (typeof row.ret20d === 'number') { totalRet20d += row.ret20d; retCount++; }
    if (typeof row.excess_ret_10d === 'number') totalExcess += row.excess_ret_10d;
  });

  var n = satelliteRows.length;
  var failureRate   = n > 0 ? failureCount / n : 0;
  var avgRet20d     = retCount > 0 ? totalRet20d / retCount : 0;
  var avgExcess     = n > 0 ? totalExcess / n : 0;

  var condA = brokenCount >= 3;
  var condB = failureRate >= 0.60;
  var condC = avgRet20d <= -10 && avgExcess <= -8;  // ret20d는 % 단위 (e.g. -10.5)
  var triggered = condA || condB || condC;

  return {
    sfg_v1:           triggered ? 'TRIGGERED' : 'CLEAR',
    sfg_reason:       condA ? ('broken_count_' + brokenCount) :
                      condB ? ('failure_rate_' + Math.round(failureRate * 100) + 'pct') :
                      condC ? 'avg_excess_drawdown_breach' : 'clear',
    sfg_broken_count: brokenCount,
    sfg_failure_rate: parseFloat(failureRate.toFixed(3)),
    formula_id:       'SATELLITE_FAILURE_GATE_V1'
  };
}

/**
 * SATELLITE_AGGREGATE_PNL_GATE_V1
 * 위성 합산 손익이 코어 수익을 얼마나 잠식하는지 결정론적으로 산출한다.
 */
function calcSatelliteAggregatePnlGate_(holdings) {
  var corePnl = 0, satellitePnl = 0, coreCount = 0, satelliteCount = 0;
  (holdings || []).forEach(function(h) {
    var pnl = typeof h.profit_loss === 'number' ? h.profit_loss
      : typeof h.unrealizedPnl === 'number' ? h.unrealizedPnl
      : typeof h.unrealized_pnl_krw === 'number' ? h.unrealized_pnl_krw : 0;
    if (h.position_type === 'core') {
      corePnl += pnl; coreCount++;
    } else {
      satellitePnl += pnl; satelliteCount++;
    }
  });
  var ratio = corePnl > 0 ? Math.abs(Math.min(0, satellitePnl)) / corePnl : null;
  var status = ratio === null ? 'INSUFFICIENT_DATA'
    : ratio >= 0.50 ? 'SAPG_CRITICAL'
    : ratio >= 0.25 ? 'SAPG_ALERT'
    : 'PASS';
  return {
    sapg_status: status,
    core_total_pnl_krw: Math.round(corePnl),
    satellite_total_pnl_krw: Math.round(satellitePnl),
    satellite_loss_to_core_gain_ratio: ratio === null ? null : round2_(ratio),
    core_count: coreCount,
    satellite_count: satelliteCount,
    formula_id: 'SATELLITE_AGGREGATE_PNL_GATE_V1'
  };
}

function calcCashCreationPurposeLockRow_(h, df, sfgResult) {
  var cv = df.composite_verdict || null;
  var rv = df.rs_verdict || null;
  var brt = df.brt_verdict || null;
  var excessDrawdown = typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null;
  var rec20 = typeof df.recovery_ratio_20d === 'number' ? df.recovery_ratio_20d : null;
  var valid = false;
  var reasons = [];
  if (['REDUCE_CANDIDATE', 'EXIT_REVIEW', 'CLOSE_POSITION'].includes(cv)) { valid = true; reasons.push('composite_verdict_' + cv); }
  if (rv === 'BROKEN' || brt === 'BROKEN') { valid = true; reasons.push('relative_broken'); }
  if (excessDrawdown !== null && excessDrawdown >= 10 && rec20 !== null && rec20 < 0.50) { valid = true; reasons.push('excess_drawdown_no_recovery'); }
  if (sfgResult && sfgResult.sfg_v1 === 'TRIGGERED' && h.position_type !== 'core') { valid = true; reasons.push('sfg_v1_TRIGGERED'); }
  return {
    ticker: h.ticker,
    name: h.name || df.name || '',
    position_type: h.position_type || 'unknown',
    sell_reason_validity: valid ? 'VALID_SELL_REASON' : 'INVALID_SELL_REASON',
    valid_reason_codes: reasons,
    reinvestment_allowed: false,
    formula_id: 'CASH_CREATION_PURPOSE_LOCK_V1'
  };
}


// ── [2026-05-21_AEW_V1] ALPHA_EVALUATION_WINDOW_V1 ──────────────────────────
// 위성 보유 종목의 진입 이후 경과 영업일을 판단하여 T+20/T+60 알파 게이트를 산출한다.
// 벤치마크: 삼성전자(005930) + SK하이닉스(000660) 평균 ret20D/ret60D (프록시).
// position_type=core 종목은 EXEMPT 처리하여 게이트 판정에서 제외한다.
function calcAlphaEvaluationWindow_(holdings, dfMap) {
  var samsung = dfMap['005930'] || {};
  var hynix   = dfMap['000660'] || {};

  // 코어 벤치마크 수익률 프록시
  var coreRet20Vals = [];
  if (Number.isFinite(samsung.ret20D)) coreRet20Vals.push(samsung.ret20D);
  if (Number.isFinite(hynix.ret20D))   coreRet20Vals.push(hynix.ret20D);
  var coreRet20d = coreRet20Vals.length > 0
    ? coreRet20Vals.reduce(function(s,v){return s+v;},0) / coreRet20Vals.length : null;

  var coreRet60Vals = [];
  if (Number.isFinite(samsung.ret60D)) coreRet60Vals.push(samsung.ret60D);
  if (Number.isFinite(hynix.ret60D))   coreRet60Vals.push(hynix.ret60D);
  var coreRet60d = coreRet60Vals.length > 0
    ? coreRet60Vals.reduce(function(s,v){return s+v;},0) / coreRet60Vals.length : null;

  var aewRows = [];

  holdings.forEach(function(h) {
    if (!h.ticker) return;

    // core 종목 — 알파 게이트 평가 대상 아님
    if (h.position_type === 'core') {
      aewRows.push({
        ticker:              h.ticker,
        name:                h.name || '',
        position_type:       'core',
        entry_date:          h.entry_date || '',
        days_since_entry:    null,
        satellite_return_pct: null,
        core_benchmark_ret20d: coreRet20d,
        core_benchmark_ret60d: coreRet60d,
        t20_reached:         false,
        t20_vs_core_pctp:    null,
        t20_alpha_gate:      'EXEMPT',
        t60_reached:         false,
        t60_vs_core_pctp:    null,
        t60_alpha_gate:      'EXEMPT',
        evaluation_method:   'EXEMPT_CORE',
        formula_id:          'ALPHA_EVALUATION_WINDOW_V1'
      });
      return;
    }

    var daysSinceEntry = h.entry_date ? calcKrxBizDaysDiff_(h.entry_date) : null;
    var satRetPct = typeof h.return_pct === 'number' && Number.isFinite(h.return_pct)
      ? h.return_pct : null;

    // entry_date 없거나 미래 날짜 — 데이터 누락
    var validEntry = daysSinceEntry !== null && daysSinceEntry >= 0;
    var t20Reached = validEntry && daysSinceEntry >= 20;
    var t60Reached = validEntry && daysSinceEntry >= 60;

    var t20VsCorePctp = null;
    var t20AlphaGate  = validEntry ? (t20Reached ? 'DATA_MISSING' : 'NOT_YET') : 'DATA_MISSING';
    var t60VsCorePctp = null;
    var t60AlphaGate  = validEntry ? (t60Reached ? 'DATA_MISSING' : 'NOT_YET') : 'DATA_MISSING';

    // T+20 평가 — 위성 총수익률 vs 코어 20D 수익률 (프록시)
    if (t20Reached && satRetPct !== null && coreRet20d !== null) {
      t20VsCorePctp = round2_(satRetPct - coreRet20d);
      t20AlphaGate  = t20VsCorePctp < -3 ? 'T20_ALPHA_FAIL'
                    : t20VsCorePctp >= 0  ? 'PASS'
                    : 'NEUTRAL';
    }

    // T+60 평가 — 위성 총수익률 vs 코어 60D 수익률 (프록시)
    if (t60Reached && satRetPct !== null && coreRet60d !== null) {
      t60VsCorePctp = round2_(satRetPct - coreRet60d);
      t60AlphaGate  = t60VsCorePctp < -5 ? 'T60_ALPHA_FAIL'
                    : t60VsCorePctp >= 0  ? 'PASS'
                    : 'NEUTRAL';
    }

    aewRows.push({
      ticker:              h.ticker,
      name:                h.name || '',
      position_type:       h.position_type || 'satellite',
      entry_date:          h.entry_date || '',
      days_since_entry:    daysSinceEntry,
      satellite_return_pct: satRetPct,
      core_benchmark_ret20d: coreRet20d,
      core_benchmark_ret60d: coreRet60d,
      t20_reached:         t20Reached,
      t20_vs_core_pctp:    t20VsCorePctp,
      t20_alpha_gate:      t20AlphaGate,
      t60_reached:         t60Reached,
      t60_vs_core_pctp:    t60VsCorePctp,
      t60_alpha_gate:      t60AlphaGate,
      // PROXY 경고: satRetPct는 진입~현재 총수익률; 코어 벤치마크는 20D/60D rolling
      // 동일 기간 비교가 아니므로 진입 시점이 20~60일 이내인 경우 오차 있음
      evaluation_method:   'PROXY_FROM_RETURN_PCT_VS_CORE_ROLLING',
      formula_id:          'ALPHA_EVALUATION_WINDOW_V1'
    });
  });

  return aewRows;
}


// ─────────────────────────────────────────────────────────────────────────────
// [2026-05-21_SPRINT_B] Sprint B — 4개 하네스 게이트
// ─────────────────────────────────────────────────────────────────────────────

// ── B-1: HARNESS_DATA_FRESHNESS_GATE_V1 ─────────────────────────────────────
// account_snapshot capturedAt 기준으로 영업일 신선도를 판정한다.
// STALE_BLOCK(5일+) → 주문표 생성 차단. STALE_WARN(3-4일) → SAQG ELIGIBLE 하향.
function calcHarnessDataFreshnessGate_(capturedAtIso, now) {
  // capturedAtIso: "yyyy-MM-dd HH:mm:ss" or "yyyy-MM-dd" — 날짜만 추출
  var marketDateStr = capturedAtIso ? String(capturedAtIso).substring(0, 10) : null;
  if (!marketDateStr || !/^\d{4}-\d{2}-\d{2}$/.test(marketDateStr)) {
    return {
      data_freshness_status:    'UNKNOWN',
      data_age_business_days:   null,
      market_date:              null,
      freshness_degraded_gates: ['ALL_GATES_UNCERTAIN'],
      formula_id:               'HARNESS_DATA_FRESHNESS_GATE_V1'
    };
  }

  var ageDays = calcKrxBizDaysDiff_(marketDateStr);
  var status  = ageDays <= 1 ? 'FRESH'
              : ageDays === 2 ? 'STALE_1D'
              : ageDays <= 4  ? 'STALE_WARN'
              : 'STALE_BLOCK';
  var degraded = [];
  if (status === 'STALE_WARN')  degraded = ['BRT_RELIABILITY_LOW', 'SAQG_ELIGIBLE_DOWNGRADE'];
  if (status === 'STALE_BLOCK') degraded = ['BRT_BLOCKED', 'SAQG_BLOCKED', 'ORDER_GENERATION_BLOCKED'];

  return {
    data_freshness_status:    status,
    data_age_business_days:   ageDays,
    market_date:              marketDateStr,
    freshness_degraded_gates: degraded,
    formula_id:               'HARNESS_DATA_FRESHNESS_GATE_V1'
  };
}

// ── B-2: SATELLITE_LIFECYCLE_GATE_V1 ────────────────────────────────────────
// 위성 종목에 WATCH/PILOT/CONFIRMED/REVIEW/EXIT 5단계 라이프사이클을 부여한다.
// brt_verdict, composite_verdict, excess_drawdown_pctp, AEW t20_alpha_gate를 조합해
// 현재 상태에서 가장 적절한 단계를 결정론적으로 산출한다.
function calcSatelliteLifecycleGate_(holdings, dfMap, aewRows) {
  var aewMap = {};
  (aewRows || []).forEach(function(r) { if (r.ticker) aewMap[r.ticker] = r; });

  return holdings.map(function(h) {
    if (h.position_type === 'core') {
      return {
        ticker:                    h.ticker,
        name:                      h.name || '',
        position_type:             'core',
        lifecycle_stage:           'CORE_EXEMPT',
        lifecycle_transition_reason: 'core_position',
        lifecycle_days_in_stage:   null,
        review_warning:            null,
        formula_id:                'SATELLITE_LIFECYCLE_GATE_V1'
      };
    }

    var df   = dfMap[h.ticker] || {};
    var aew  = aewMap[h.ticker] || {};
    var cv   = df.composite_verdict || 'UNKNOWN';
    var brt  = df.brt_verdict       || 'UNKNOWN';
    var exDd = typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null;
    var t20g = aew.t20_alpha_gate   || 'NOT_YET';
    var t20v = typeof aew.t20_vs_core_pctp === 'number' ? aew.t20_vs_core_pctp : null;
    var daysEntry = h.entry_date ? calcKrxBizDaysDiff_(h.entry_date) : null;

    var stage  = 'PILOT';
    var reason = 'default_pilot';

    // ── EXIT 조건 (최우선) ─────────────────────────────────────────────────
    if (brt === 'BROKEN') {
      stage = 'EXIT'; reason = 'brt_BROKEN';
    } else if (cv === 'CLOSE_POSITION') {
      stage = 'EXIT'; reason = 'composite_CLOSE_POSITION';
    } else if (exDd !== null && exDd >= 15) {
      stage = 'EXIT'; reason = 'excess_drawdown_15pct';
    } else if (t20g === 'T20_ALPHA_FAIL' && t20v !== null && t20v < -10) {
      stage = 'EXIT'; reason = 'T20_ALPHA_FAIL_severe';

    // ── REVIEW 조건 ──────────────────────────────────────────────────────
    } else if (brt === 'LAGGARD') {
      stage = 'REVIEW'; reason = 'brt_LAGGARD';
    } else if (cv === 'REDUCE_CANDIDATE') {
      stage = 'REVIEW'; reason = 'composite_REDUCE';
    } else if (t20g === 'T20_ALPHA_FAIL') {
      stage = 'REVIEW'; reason = 'T20_ALPHA_FAIL';
    } else if (exDd !== null && exDd >= 8) {
      stage = 'REVIEW'; reason = 'excess_drawdown_8pct';

    // ── CONFIRMED 조건 ─────────────────────────────────────────────────
    } else if (daysEntry !== null && daysEntry >= 20
               && t20g === 'PASS'
               && (cv === 'PRIME_CANDIDATE' || cv === 'WATCH_CANDIDATE')
               && (brt === 'LEADER' || brt === 'MARKET')) {
      stage = 'CONFIRMED'; reason = 't20_pass_market_or_leader';

    // ── PILOT 조건 (기본) ───────────────────────────────────────────────
    } else if (daysEntry !== null && daysEntry < 20) {
      stage = 'PILOT'; reason = 'within_20d_of_entry';
    } else {
      stage = 'PILOT'; reason = 'pending_t20_evaluation';
    }

    // 4주 REVIEW 경보 (Direction SLG)
    var reviewWarn = (stage === 'REVIEW' && daysEntry !== null && daysEntry >= 20)
      ? '4주_REVIEW_비중50%_감축검토' : null;

    return {
      ticker:                    h.ticker,
      name:                      h.name || df.name || '',
      position_type:             h.position_type || 'satellite',
      lifecycle_stage:           stage,
      lifecycle_transition_reason: reason,
      lifecycle_days_in_stage:   daysEntry,
      review_warning:            reviewWarn,
      composite_verdict:         cv,
      brt_verdict:               brt,
      excess_drawdown_pctp:      exDd,
      t20_alpha_gate:            t20g,
      formula_id:                'SATELLITE_LIFECYCLE_GATE_V1'
    };
  });
}

// ── B-3: CLA_REGIME_EXIT_CONDITION_V1 ───────────────────────────────────────
// CONCENTRATED_LEADER_ADVANCE 국면의 종료 조건을 탐지한다.
// 삼성전자(005930) + SK하이닉스(000660)를 대상으로 5개 신호를 평가하고
// 가중치 합산으로 CLA_ACTIVE / CLA_EXIT_WARNING / CLA_EXIT_CONFIRMED를 결정한다.
/**
 * SECULAR_LEADER_AUTO_DETECT_V1
 * spec/strategy/semiconductor_concentration_policy.yaml 조건 기반
 * 반도체 주도주 자동 감지 → SECULAR_LEADER_RISK_ON 국면 진입 권고.
 *
 * 감지 조건 (가중치 합산 ≥ 6 → is_secular_leader=true):
 *   SL1 (w=3): 삼성 또는 하이닉스 RS_Ratio ≥ 1.5 (5일 연속)
 *   SL2 (w=2): 외인+기관 동반순매수 3일 이상
 *   SL3 (w=2): 반도체 섹터 5일 수익률 KOSPI 대비 +5%p 이상 초과
 *   SL4 (w=1): 반도체 섹터 5D 거래대금 > 20D 거래대금 × 1.3
 *
 * @param {Object} dfMap — buildDataFeedMap_() 반환값
 * @param {string} marketRegime
 * @param {number} kospiRet5d — KOSPI 5일 수익률
 * @return {{ is_secular_leader, score, signals, recommendation, formula_id }}
 */
function calcSecularLeaderAutoDetect_(dfMap, marketRegime, kospiRet5d) {
  var SECULAR_TICKERS = ['005930', '000660'];
  var THRESHOLD = 6;
  var score = 0;
  var signals = [];
  var kospiRet = typeof kospiRet5d === 'number' ? kospiRet5d : 0;

  // SL1: RS_Ratio ≥ 1.5 — 삼성 또는 하이닉스
  var sl1Hit = SECULAR_TICKERS.some(function(tk) {
    var df = dfMap[tk] || {};
    var rsRatio = typeof df.rsRatio === 'number' ? df.rsRatio
      : (typeof df.rs_ratio === 'number' ? df.rs_ratio : null);
    return rsRatio !== null && rsRatio >= 1.5;
  });
  if (sl1Hit) { score += 3; signals.push('SL1_RS_RATIO_GTE_1.5(w=3)'); }

  // SL2: 외인+기관 동반순매수 3일 이상 — 양 종목 중 하나
  var sl2Hit = SECULAR_TICKERS.some(function(tk) {
    var df = dfMap[tk] || {};
    var frg = typeof df.frg5d  === 'number' ? df.frg5d  : -1;
    var ins = typeof df.inst5d === 'number' ? df.inst5d : -1;
    return frg > 0 && ins > 0;  // 5일 누적 동반순매수 = 3일 이상 추정
  });
  if (sl2Hit) { score += 2; signals.push('SL2_FRG_INST_CO_BUY(w=2)'); }

  // SL3: 반도체 섹터 5일 수익률 KOSPI 대비 +5%p 초과 (대표 종목 프록시)
  var semiRet5d = null;
  SECULAR_TICKERS.forEach(function(tk) {
    var df = dfMap[tk] || {};
    if (typeof df.ret5d === 'number' && (semiRet5d === null || df.ret5d > semiRet5d)) {
      semiRet5d = df.ret5d;
    }
  });
  if (semiRet5d !== null && semiRet5d - kospiRet >= 5.0) {
    score += 2;
    signals.push('SL3_SECTOR_OUTPERFORM_5PCT(w=2)');
  }

  // SL4: 반도체 섹터 거래대금 급증 (대표 종목 avgTradeVal5d/20d 프록시)
  var sl4Hit = SECULAR_TICKERS.some(function(tk) {
    var df = dfMap[tk] || {};
    var val5  = toNumber_(df.avg_trade_val_5d  || df.avgTradeVal5d)  || 0;
    var val20 = toNumber_(df.avg_trade_val_20d || df.avgTradeVal20d) || 0;
    return val5 > 0 && val20 > 0 && val5 > val20 * 1.3;
  });
  if (sl4Hit) { score += 1; signals.push('SL4_TRADE_VALUE_SURGE(w=1)'); }

  var isSecularLeader = score >= THRESHOLD;
  var currentRegime   = String(marketRegime || '').toUpperCase();
  var alreadyActive   = currentRegime.indexOf('SECULAR_LEADER') >= 0;

  // 종료 조건: RS_Ratio < 1.0 3일 or 외인+기관 동반순매도 5일
  var exitSignals = [];
  SECULAR_TICKERS.forEach(function(tk) {
    var df = dfMap[tk] || {};
    var rsRatio = typeof df.rsRatio === 'number' ? df.rsRatio
      : (typeof df.rs_ratio === 'number' ? df.rs_ratio : null);
    if (rsRatio !== null && rsRatio < 1.0) exitSignals.push(tk + '_RS_BELOW_1.0');
    var frg = typeof df.frg5d  === 'number' ? df.frg5d  : 0;
    var ins = typeof df.inst5d === 'number' ? df.inst5d : 0;
    if (frg < 0 && ins < 0) exitSignals.push(tk + '_CO_SELL');
  });

  return {
    is_secular_leader:      isSecularLeader,
    score:                  score,
    threshold:              THRESHOLD,
    signals:                signals,
    exit_signals:           exitSignals,
    already_active:         alreadyActive,
    recommendation:         isSecularLeader && !alreadyActive
      ? 'UPGRADE_TO_SECULAR_LEADER_RISK_ON'
      : (alreadyActive && exitSignals.length >= 2 ? 'EXIT_SECULAR_LEADER' : 'MAINTAIN'),
    formula_id:             'SECULAR_LEADER_AUTO_DETECT_V1'
  };
}




// --- Source: src/gas_adapter_parts/gdf_03_portfolio_gates.gs ---
function calcClaRegimeExitCondition_(dfMap, marketRegime) {
  var regime = String(marketRegime || '').toUpperCase();
  if (regime.indexOf('CONCENTRATED_LEADER') < 0 && regime.indexOf('CLA') < 0) {
    return {
      cla_exit_status:           'NOT_APPLICABLE',
      cla_exit_signals_triggered: [],
      cla_exit_total_weight:     0,
      note:                      'marketRegime not CLA',
      formula_id:                'CLA_REGIME_EXIT_CONDITION_V1'
    };
  }

  var sam = dfMap['005930'] || {};
  var hyn = dfMap['000660'] || {};
  var signals = [];
  var w = 0;

  // S1: RS 약화 — 삼성 또는 하이닉스 rs_verdict = LAGGARD (weight 3)
  if (sam.rs_verdict === 'LAGGARD' || sam.rs_verdict === 'BROKEN'
      || hyn.rs_verdict === 'LAGGARD' || hyn.rs_verdict === 'BROKEN') {
    signals.push('S1_rs_degradation'); w += 3;
  }

  // S2: KOSPI 기여도 하락 프록시 — 두 종목 모두 LEADER 아님 (weight 2)
  if (sam.brt_verdict !== 'LEADER' && hyn.brt_verdict !== 'LEADER'
      && sam.brt_verdict !== 'UNKNOWN' && hyn.brt_verdict !== 'UNKNOWN') {
    signals.push('S2_kospi_contribution_drop_proxy'); w += 2;
  }

  // S3: 외국인 동반 순매도 — frg5d < 0 두 종목 (weight 2)
  var samFrgNeg = Number.isFinite(sam.frg5d) && sam.frg5d < 0;
  var hynFrgNeg = Number.isFinite(hyn.frg5d) && hyn.frg5d < 0;
  if (samFrgNeg && hynFrgNeg) {
    signals.push('S3_foreign_flow_reversal'); w += 2;
  }

  // S4: 거래 에너지 소진 — volume < avgVolume5d*0.6 두 종목 (weight 1)
  var samVolLow = Number.isFinite(sam.volume) && Number.isFinite(sam.avgVolume5d)
                  && sam.avgVolume5d > 0 && sam.volume < sam.avgVolume5d * 0.6;
  var hynVolLow = Number.isFinite(hyn.volume) && Number.isFinite(hyn.avgVolume5d)
                  && hyn.avgVolume5d > 0 && hyn.volume < hyn.avgVolume5d * 0.6;
  if (samVolLow && hynVolLow) {
    signals.push('S4_volume_exhaustion'); w += 1;
  }

  // S5: BRT 약화 — 두 종목 모두 brt_verdict = MARKET (LEADER에서 하락) (weight 2)
  if (sam.brt_verdict === 'MARKET' && hyn.brt_verdict === 'MARKET') {
    signals.push('S5_brt_degradation_from_leader'); w += 2;
  }

  var status = w >= 5 ? 'CLA_EXIT_CONFIRMED'
             : w >= 3 ? 'CLA_EXIT_WARNING'
             : 'CLA_ACTIVE';

  return {
    cla_exit_status:            status,
    cla_exit_signals_triggered: signals,
    cla_exit_total_weight:      w,
    samsung_rs:     sam.rs_verdict  || 'UNKNOWN',
    samsung_brt:    sam.brt_verdict || 'UNKNOWN',
    hynix_rs:       hyn.rs_verdict  || 'UNKNOWN',
    hynix_brt:      hyn.brt_verdict || 'UNKNOWN',
    formula_id:                 'CLA_REGIME_EXIT_CONDITION_V1'
  };
}

// ── B-4: PORTFOLIO_CORRELATION_GATE_V1 ──────────────────────────────────────
// 위성 포지션 간 ret20d 기반 프록시 상관관계를 산출하고,
// 상관관계 조정 실질 포트폴리오 베타(satellite_cluster_beta)를 계산한다.
// 20일 수익률 배열이 없으므로 방향 일치도로 상관관계를 추정(PROXY).
function calcPortfolioCorrelationGate_(holdings, dfMap, totalAsset, kospiRet5d) {
  var satHoldings = holdings.filter(function(h) { return h.position_type !== 'core'; });
  if (satHoldings.length === 0) {
    return {
      satellite_cluster_beta:   0,
      effective_portfolio_beta: 0,
      high_corr_pairs:          [],
      correlation_gate_status:  'CORRELATION_PASS',
      note:                     'no_satellite_holdings',
      formula_id:               'PORTFOLIO_CORRELATION_GATE_V1'
    };
  }

  // 각 위성의 beta_proxy 및 weight_pct 계산
  var satItems = satHoldings.map(function(h) {
    var df = dfMap[h.ticker] || {};
    var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null;
    var ret20d = typeof df.ret20d === 'number' ? df.ret20d : null;
    // beta_proxy: ret5d / kospiRet5d if both available, else 1.0
    var beta = 1.0;
    if (ret5d !== null && typeof kospiRet5d === 'number' && Math.abs(kospiRet5d) > 0.3) {
      beta = Math.max(0, Math.min(3.0, ret5d / kospiRet5d));
    }
    // weight_pct: from h.weightPct (set by calcPortfolioBetaGate pipeline) or derived
    var mv = typeof h.market_value === 'number' ? h.market_value : 0;
    var wPct = (totalAsset > 0 && mv > 0) ? mv / totalAsset * 100 : 0;
    if (typeof h.weightPct === 'number' && h.weightPct > 0) wPct = h.weightPct;
    return {
      ticker:   h.ticker,
      name:     h.name || df.name || '',
      beta:     round2_(beta),
      wPct:     round2_(wPct),
      w:        wPct / 100,  // fraction
      ret20d:   ret20d,
      rs:       df.rs_verdict || 'UNKNOWN',
      brt:      df.brt_verdict || 'UNKNOWN'
    };
  });

  // 프록시 상관관계: ret20d 방향 일치 + BRT 동방향 기반
  function proxyCorrPair(a, b) {
    if (a.ret20d !== null && b.ret20d !== null) {
      var sameDir = (a.ret20d >= 0) === (b.ret20d >= 0);
      var bothNeg = a.ret20d < 0 && b.ret20d < 0;
      if (bothNeg) return 0.80;   // 동반 하락 — 가장 강한 동조 신호
      if (sameDir) return 0.65;   // 같은 방향 수익
      return 0.15;                // 반대 방향 — 분산 효과
    }
    // 데이터 없으면 동업종 같은 BRT 상태이면 보수적으로 중간값
    if (a.brt === b.brt && a.brt !== 'UNKNOWN') return 0.60;
    return 0.35;
  }

  var highCorrPairs = [];
  var totalSatW = satItems.reduce(function(s, x) { return s + x.w; }, 0);
  if (totalSatW <= 0) totalSatW = 1;

  // 정규화된 위성 비중 (위성 합산=1)
  var satNorm = satItems.map(function(x) {
    return Object.assign({}, x, { wn: x.w / totalSatW });
  });

  // 상관관계 행렬 및 satellite_cluster_beta (quadratic form → sqrt)
  var quadForm = 0;
  for (var i = 0; i < satNorm.length; i++) {
    for (var j = 0; j < satNorm.length; j++) {
      var corr = i === j ? 1.0 : proxyCorrPair(satNorm[i], satNorm[j]);
      quadForm += satNorm[i].wn * satNorm[j].wn * satNorm[i].beta * satNorm[j].beta * corr;
      if (i < j && corr >= 0.70) {
        highCorrPairs.push({
          ticker1:    satNorm[i].ticker,
          ticker2:    satNorm[j].ticker,
          corr_proxy: round2_(corr),
          both_negative: satNorm[i].ret20d !== null && satNorm[j].ret20d !== null
                         && satNorm[i].ret20d < 0 && satNorm[j].ret20d < 0
        });
      }
    }
  }
  var satClusterBeta = round2_(Math.sqrt(Math.max(0, quadForm)));

  // 코어 단순 가중 베타
  var coreHoldings = holdings.filter(function(h) { return h.position_type === 'core'; });
  var coreWBetaSum = 0, coreWSum = 0;
  coreHoldings.forEach(function(h) {
    var df = dfMap[h.ticker] || {};
    var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null;
    var beta = 1.0;
    if (ret5d !== null && typeof kospiRet5d === 'number' && Math.abs(kospiRet5d) > 0.3) {
      beta = Math.max(0, Math.min(3.0, ret5d / kospiRet5d));
    }
    var mv = typeof h.market_value === 'number' ? h.market_value : 0;
    var w = (totalAsset > 0 && mv > 0) ? mv / totalAsset : 0;
    if (typeof h.weightPct === 'number') w = h.weightPct / 100;
    coreWBetaSum += w * beta;
    coreWSum += w;
  });
  var coreBeta = coreWSum > 0 ? round2_(coreWBetaSum / coreWSum * (coreWSum / 1.0)) : 0;
  // effective = core_weighted_contribution + satellite_cluster_beta * sat_weight_fraction
  var effectiveBeta = round2_(coreBeta + satClusterBeta * totalSatW);

  // 게이트 판정
  var gateStatus = (satClusterBeta > 1.5 && highCorrPairs.length >= 2) ? 'CORRELATION_BLOCK'
                 : (satClusterBeta > 1.2 || highCorrPairs.length >= 1) ? 'CORRELATION_WARN'
                 : 'CORRELATION_PASS';

  return {
    satellite_cluster_beta:    satClusterBeta,
    effective_portfolio_beta:  effectiveBeta,
    high_corr_pairs:           highCorrPairs,
    correlation_gate_status:   gateStatus,
    satellite_count:           satHoldings.length,
    evaluation_method:         'PROXY_FROM_RET20D_DIRECTION',
    formula_id:                'PORTFOLIO_CORRELATION_GATE_V1'
  };
}

function pickReferenceBenchmarkRet5d_(df, fallbackKospiRet5d) {
  var keys = [
    ['nasdaq_ret5d', 'NASDAQ'],
    ['nasdaqRet5d', 'NASDAQ'],
    ['kosdaq_ret5d', 'KOSDAQ'],
    ['kosdaqRet5d', 'KOSDAQ'],
    ['benchmark_ret5d', 'BENCHMARK'],
    ['benchmarkRet5d', 'BENCHMARK'],
    ['kospi_ret5d', 'KOSPI'],
    ['kospiRet5d', 'KOSPI']
  ];
  for (var i = 0; i < keys.length; i++) {
    var key = keys[i][0];
    if (typeof (df || {})[key] === 'number') {
      return { benchmark_ret5d: df[key], benchmark_used: keys[i][1] };
    }
  }
  if (typeof fallbackKospiRet5d === 'number') {
    return { benchmark_ret5d: fallbackKospiRet5d, benchmark_used: 'KOSPI' };
  }
  return { benchmark_ret5d: null, benchmark_used: 'UNKNOWN' };
}

function calcIndexRelativeHealthGate_(h, df, kospiRet5d) {
  var stockRet5d = typeof df.ret5d === 'number' ? df.ret5d : null;
  var bench = pickReferenceBenchmarkRet5d_(df, kospiRet5d);
  var benchmarkRet5d = bench.benchmark_ret5d;
  var benchmarkUsed = bench.benchmark_used;
  var reasons = [];
  var state = 'INSUFFICIENT_DATA';
  var directionMatch = null;
  var retGapPctp = null;
  var magnitudeExcessPctp = null;

  if (stockRet5d !== null && benchmarkRet5d !== null) {
    directionMatch = (stockRet5d >= 0) === (benchmarkRet5d >= 0);
    retGapPctp = round2_(stockRet5d - benchmarkRet5d);
    magnitudeExcessPctp = round2_(Math.max(0, Math.abs(stockRet5d) - Math.abs(benchmarkRet5d) - 2));
    var benchmarkAbs = Math.abs(benchmarkRet5d);
    var stockAbs = Math.abs(stockRet5d);

    if (!directionMatch && benchmarkAbs >= 1) {
      state = 'DECOUPLED';
      reasons.push('direction_mismatch');
    } else if (stockRet5d < benchmarkRet5d - 3) {
      state = 'UNDERPERFORMING';
      reasons.push('underperform_vs_benchmark');
    } else if (magnitudeExcessPctp >= 3 || (stockAbs >= benchmarkAbs + 4 && benchmarkAbs >= 1)) {
      state = 'OVER_EXTENDED';
      reasons.push('magnitude_excess');
    } else {
      state = 'HEALTHY';
    }
  } else {
    reasons.push('insufficient_benchmark_data');
  }

  return {
    ticker: h.ticker,
    name: h.name || df.name || '',
    benchmark_used: benchmarkUsed,
    stock_ret5d: stockRet5d,
    benchmark_ret5d: benchmarkRet5d,
    ret_gap_pctp: retGapPctp,
    magnitude_excess_pctp: magnitudeExcessPctp,
    direction_match: directionMatch,
    relative_health_state: state,
    reason_codes: reasons,
    formula_id: 'INDEX_RELATIVE_HEALTH_GATE_V1'
  };
}

/**
 * O3: PORTFOLIO_DRAWDOWN_GATE_V1
 * 총자산 역대 고점(settings.portfolio_peak_krw) 대비 낙폭을 산출한다.
 * -15% → DRAWDOWN_CAUTION, -20% → DRAWDOWN_FORCE_RISK_OFF.
 * 현재 자산이 고점 초과 시 settings에 새 고점을 자동 기록.
 * @param {number} totalAsset
 * @param {Object} ss — Spreadsheet
 * @param {Object} settings — readSettings_() 반환값
 * @return {{ gate, drawdown_pct, peak_krw, current_krw }}
 */
function calcPortfolioDrawdownGate_(totalAsset, ss, settings) {
  var peakKrw = toNumber_(settings['portfolio_peak_krw'] || 0);
  if (totalAsset > 0 && totalAsset > peakKrw) {
    peakKrw = totalAsset;
    writeSettingValue_(ss, 'portfolio_peak_krw', totalAsset);
  }
  if (peakKrw <= 0 || totalAsset <= 0) {
    return { gate: 'INSUFFICIENT_DATA', drawdown_pct: null, peak_krw: peakKrw || null, current_krw: Math.round(totalAsset || 0), formula_id: 'PORTFOLIO_DRAWDOWN_GATE_V1' };
  }
  var drawdownPct = round2_((peakKrw - totalAsset) / peakKrw * 100);
  drawdownPct = Math.max(0, drawdownPct);
  var gate = drawdownPct >= 20 ? 'DRAWDOWN_FORCE_RISK_OFF'
    : drawdownPct >= 15 ? 'DRAWDOWN_CAUTION'
    : 'PASS';
  return { gate: gate, drawdown_pct: drawdownPct, peak_krw: Math.round(peakKrw), current_krw: Math.round(totalAsset), formula_id: 'PORTFOLIO_DRAWDOWN_GATE_V1' };
}

/**
 * O4: WIN_LOSS_STREAK_GUARD_V1
 * 최근 30거래 승률이 임계값 이하로 하락하면 신규 매수 비중을 축소한다.
 * M1(연속 손절 횟수)과 독립적인 전체 승률 축 방어층.
 * EDGE_CRITICAL(<30%): scale=0.25, EDGE_DEGRADED(<40%): scale=0.50,
 * EDGE_WEAK(<45%): scale=0.75, EDGE_OK(>=45%): scale=1.0
 * @param {Object} performance — readPerformanceSheet_() 반환값
 * @return {{ state, win_rate_pct, trades_used, buy_scale }}
 */
function calcWinLossStreakGuard_(performance) {
  var winRate    = (performance && Number.isFinite(performance.win_rate_30))  ? performance.win_rate_30  : null;
  var tradesUsed = (performance && Number.isFinite(performance.trades_used))  ? performance.trades_used  : 0;
  if (winRate === null || tradesUsed < 10) {
    return { state: 'INSUFFICIENT_HISTORY', win_rate_pct: winRate !== null ? round2_(winRate * 100) : null, trades_used: tradesUsed, buy_scale: 1.0, formula_id: 'WIN_LOSS_STREAK_GUARD_V1' };
  }
  var state, scale;
  if      (winRate < 0.30) { state = 'EDGE_CRITICAL';  scale = 0.25; }
  else if (winRate < 0.40) { state = 'EDGE_DEGRADED';  scale = 0.50; }
  else if (winRate < 0.45) { state = 'EDGE_WEAK';      scale = 0.75; }
  else                     { state = 'EDGE_OK';         scale = 1.0;  }
  return { state: state, win_rate_pct: round2_(winRate * 100), trades_used: tradesUsed, buy_scale: scale, formula_id: 'WIN_LOSS_STREAK_GUARD_V1' };
}

/**
 * O5: POSITION_COUNT_LIMIT_V1
 * 동시 보유 종목 수가 국면별 상한(NEUTRAL:8, RISK_OFF:6)을 초과하면 POSITION_COUNT_BLOCK.
 * 과다 분산으로 인한 집중 모니터링 불가 및 Total Heat 과소 추정 방지.
 * @param {Array}  holdings
 * @param {string} marketRegime
 * @return {{ gate_status, position_count, max_count, excess_count }}
 */
function calcPositionCountLimit_(holdings, marketRegime) {
  var r = String(marketRegime || '').toUpperCase();
  var isRiskOff = r.indexOf('EVENT_SHOCK') >= 0 || r.indexOf('RISK_OFF') >= 0;
  var maxCount = isRiskOff ? 6 : 8;
  var count = holdings.length;
  return {
    gate_status:    count > maxCount ? 'POSITION_COUNT_BLOCK' : 'PASS',
    position_count: count,
    max_count:      maxCount,
    excess_count:   Math.max(0, count - maxCount),
    formula_id:     'POSITION_COUNT_LIMIT_V1'
  };
}

/**
 * M1: DRAWDOWN_GUARD_V1
 * 연속 손절 횟수에 따라 신규 매수 비중을 자동 축소한다.
 * bayesian_multiplier=0(>=5회 연속 손실) 위에 추가 방어층으로 작동.
 * @param {Object} performance — readPerformanceSheet_() 반환값
 * @return {{ state, buy_scale, consecutive_losses, reason }}
 */
function calcDrawdownGuard_(performance) {
  var consLoss = (performance && Number.isFinite(performance.consecutive_losses))
    ? performance.consecutive_losses : 0;
  var state, scale, reason;
  if (consLoss >= 5) {
    state = 'NO_BUY';       scale = 0.0;  reason = 'consecutive_losses>=5_no_bet';
  } else if (consLoss >= 3) {
    state = 'REDUCE_BUY';   scale = 0.5;  reason = 'consecutive_losses>=3_reduce_50pct';
  } else if (consLoss >= 2) {
    state = 'CAUTION_BUY';  scale = 0.75; reason = 'consecutive_losses>=2_reduce_25pct';
  } else {
    state = 'NORMAL';       scale = 1.0;  reason = 'no_drawdown';
  }
  return { state: state, buy_scale: scale, consecutive_losses: consLoss, reason: reason };
}

/**
 * M2: PORTFOLIO_BETA_GATE_V1
 * 보유 종목 가중평균 베타를 산출하고 국면별 상한과 비교한다.
 * beta_proxy = ret5d / kospiRet5d (단, kospiRet5d <= 0이면 1.0 사용)
 * @param {Array}  holdings — parseAccountSnapshot_ 반환 holdings 배열
 * @param {Object} dfMap    — buildDataFeedMap_() 반환값
 * @param {number} kospiRet5d
 * @param {string} marketRegime
 * @return {{ portfolio_beta, gate_status, beta_limit, per_holding_betas }}
 */
function calcPortfolioBetaGate_(holdings, dfMap, kospiRet5d, marketRegime) {
  var BETA_LIMITS = (function(r) {
    var rU = String(r || '').toUpperCase();
    if (rU.indexOf('EVENT_SHOCK') >= 0)                        return { over: 0.7, warn: 0.5 };
    if (rU.indexOf('RISK_OFF') >= 0)                           return { over: 0.8, warn: 0.6 };
    if (rU.indexOf('SECULAR_LEADER') >= 0 && rU.indexOf('RISK_ON') >= 0) return { over: 1.5, warn: 1.2 };
    if (rU.indexOf('RISK_ON') >= 0)                            return { over: 1.3, warn: 1.0 };
    return { over: 1.0, warn: 0.8 };  // NEUTRAL
  })(marketRegime);

  var totalWeight = 0;
  var weightedBetaSum = 0;
  var perHolding = [];

  holdings.forEach(function(h) {
    var df = dfMap[h.ticker] || {};
    var w  = (typeof h.weightPct === 'number' && h.weightPct > 0) ? h.weightPct : 0;
    var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null;
    var betaProxy = 1.0;
    if (ret5d !== null && typeof kospiRet5d === 'number' && kospiRet5d > 0.5) {
      betaProxy = Math.max(0, Math.min(3.0, ret5d / kospiRet5d));
    } else if (ret5d !== null && typeof kospiRet5d === 'number' && kospiRet5d < -0.5) {
      betaProxy = Math.max(0, Math.min(3.0, ret5d / kospiRet5d));
    }
    totalWeight      += w;
    weightedBetaSum  += w * betaProxy;
    perHolding.push({
      ticker:     h.ticker,
      name:       h.name || '',
      weight_pct: w,
      beta_proxy: round2_(betaProxy),
      ret5d:      ret5d
    });
  });

  var portfolioBeta = totalWeight > 0 ? round2_(weightedBetaSum / totalWeight) : null;
  var gateStatus = portfolioBeta === null ? 'INSUFFICIENT_DATA'
    : portfolioBeta > BETA_LIMITS.over ? 'OVER_BETA'
    : portfolioBeta > BETA_LIMITS.warn ? 'WARN_BETA'
    : 'PASS';

  return {
    portfolio_beta:    portfolioBeta,
    gate_status:       gateStatus,
    beta_limit_over:   BETA_LIMITS.over,
    beta_limit_warn:   BETA_LIMITS.warn,
    regime_applied:    marketRegime || 'UNKNOWN',
    per_holding_betas: perHolding
  };
}

/**
 * M5: SECTOR_CONCENTRATION_LIMIT_V1
 * 단일 섹터 ≥40% 시 BLOCK_SECTOR, 상위2 합산 ≥65% 시 HALVE_SECTOR.
 * @param {Array}  holdings
 * @param {string} marketRegime
 * @return {{ gate_status, by_sector, sector_concentration_json }}
 */
function calcSectorConcentrationGate_(holdings, marketRegime) {
  var sectorWeight = {};
  holdings.forEach(function(h) {
    var sec = TICKER_SECTOR_MAP[h.ticker] || 'UNKNOWN';
    var w   = (typeof h.weightPct === 'number' && h.weightPct > 0) ? h.weightPct : 0;
    sectorWeight[sec] = (sectorWeight[sec] || 0) + w;
  });

  var sectors = Object.keys(sectorWeight).map(function(s) {
    return { sector: s, weight_pct: round2_(sectorWeight[s]) };
  });
  sectors.sort(function(a, b) { return b.weight_pct - a.weight_pct; });

  // 임계값 — RISK_OFF/EVENT_SHOCK에서는 더 엄격
  var rU = String(marketRegime || '').toUpperCase();
  var blockThresh = (rU.indexOf('EVENT_SHOCK') >= 0 || rU.indexOf('RISK_OFF') >= 0) ? 35 : 40;
  var halveThresh = (rU.indexOf('EVENT_SHOCK') >= 0 || rU.indexOf('RISK_OFF') >= 0) ? 55 : 65;

  var top2Sum = sectors.slice(0, 2).reduce(function(s, r) { return s + r.weight_pct; }, 0);
  var overallGate = 'PASS';

  sectors.forEach(function(r) {
    if (r.weight_pct >= blockThresh)      r.gate = 'BLOCK_NEW_BUY_THIS_SECTOR';
    else if (r.weight_pct >= halveThresh * 0.6) r.gate = 'WARN_CONCENTRATION';
    else                                  r.gate = 'PASS';
    if (r.gate === 'BLOCK_NEW_BUY_THIS_SECTOR') overallGate = 'BLOCK_SECTOR';
  });
  if (overallGate === 'PASS' && top2Sum >= halveThresh) overallGate = 'WARN_TOP2';

  return {
    gate_status:      overallGate,
    top2_weight_sum:  round2_(top2Sum),
    block_threshold:  blockThresh,
    by_sector:        sectors
  };
}

/**
 * M4: EVENT_RISK_HOLD_GATE_V1
 * DART 리스크 및 이벤트 홀드 기간 중인 종목에 신규 매수 홀드 게이트 적용.
 * df.eventHoldDays (Event_Hold_Days 컬럼) <= 5이면 EVENT_HOLD.
 * 컬럼 없으면 df.dartRiskStatus !== 'OK' 를 대체 기준으로 사용.
 * @param {Array}  holdings
 * @param {Object} dfMap
 * @return {Array} event_risk rows
 */
function calcEventRiskHoldGate_(holdings, dfMap) {
  return holdings.map(function(h) {
    var df = dfMap[h.ticker] || {};
    var holdDays   = typeof df.eventHoldDays === 'number' ? df.eventHoldDays : null;
    var dartRisk   = (typeof df.dartRiskStatus === 'string' && df.dartRiskStatus !== 'OK')
                     || String(df.dartRisk || '').toUpperCase() === 'Y';

    var gateStatus, reason;
    if (holdDays !== null && holdDays >= 0 && holdDays <= 5) {
      gateStatus = 'EVENT_HOLD';
      reason     = 'event_hold_days_le5:' + holdDays;
    } else if (dartRisk) {
      gateStatus = 'EVENT_HOLD';
      reason     = 'dart_risk';
    } else {
      gateStatus = 'PASS';
      reason     = 'no_event_risk';
    }
    return {
      ticker:           h.ticker,
      name:             h.name || '',
      event_hold_gate:  gateStatus,
      event_hold_days:  holdDays,
      dart_risk:        dartRisk,
      reason:           reason
    };
  });
}

/**
 * M3: TP_QUANTITY_LADDER_V1
 * prices_json의 TP1/TP2/TP3 가격 유효성 기반으로 분할 익절 수량을 자동 산출.
 * 계좌 snapshot에 수동 입력(tp1_qty>0)이 있으면 우선 사용.
 * @param {Array}  holdings
 * @param {Object} h4 — calcPrices_() 반환값 (.prices 배열)
 * @return {Array} tp_quantity_ladder rows
 */
function calcTpQuantityLadder_(holdings, h4) {
  // THIN_ADAPTER: [sizing/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_position_size
  var priceMap = {};
  (h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; });

  return holdings.map(function(h) {
    var priceRow = priceMap[h.ticker] || {};
    var qty = h.holdingQty || 0;

    // 수동 입력 tp_qty 있으면 우선 사용
    var tp1Manual = typeof priceRow.tp1_qty === 'number' && priceRow.tp1_qty > 0 ? priceRow.tp1_qty : 0;
    var tp2Manual = typeof priceRow.tp2_qty === 'number' && priceRow.tp2_qty > 0 ? priceRow.tp2_qty : 0;
    var tp3Manual = typeof priceRow.tp3_qty === 'number' && priceRow.tp3_qty > 0 ? priceRow.tp3_qty : 0;

    var tp1Q, tp2Q, tp3Q, source;
    if (tp1Manual > 0 && tp2Manual > 0) {
      tp1Q   = tp1Manual;
      tp2Q   = tp2Manual;
      tp3Q   = tp3Manual > 0 ? tp3Manual : Math.max(0, qty - tp1Q - tp2Q);
      source = 'MANUAL';
    } else if (qty > 0) {
      tp1Q   = Math.floor(qty * 0.33);
      tp2Q   = Math.floor(qty * 0.33);
      tp3Q   = Math.max(0, qty - tp1Q - tp2Q);
      source = 'AUTO_33PCT';
    } else {
      tp1Q = tp2Q = tp3Q = 0;
      source = 'NO_HOLDING';
    }

    return {
      ticker:        h.ticker,
      name:          h.name || '',
      holding_qty:   qty,
      tp1_price:     priceRow.tp1_price  || null,
      tp1_state:     priceRow.tp1_state  || null,
      tp1_qty:       tp1Q,
      tp2_price:     priceRow.tp2_price  || null,
      tp2_state:     priceRow.tp2_state  || null,
      tp2_qty:       tp2Q,
      tp3_qty:       tp3Q,
      qty_source:    source,
      formula_id:    'TP_QUANTITY_LADDER_V1'
    };
  });
}

function calcCashFloor_(mrsScore, settlementCashPct) {
  var minPct = 10;
  var regime = 'overheated_or_event_week';
  for (var k = 0; k < CASH_FLOOR_BY_MRS.length; k++) {
    if (mrsScore <= CASH_FLOOR_BY_MRS[k].maxMrs) {
      minPct = CASH_FLOOR_BY_MRS[k].minPct;
      regime = CASH_FLOOR_BY_MRS[k].label;
      break;
    }
  }
  var status = settlementCashPct >= minPct ? 'PASS'
    : settlementCashPct >= minPct * 0.7    ? 'TRIM_REQUIRED'
    :                                        'HARD_BLOCK';
  return { minPct: minPct, regime: regime, status: status };
}

function calcActions_(intradayLock, heatGate, cashFloorStatus) {
  var blocked = [];
  var allowed = ['TRIM_25', 'TRIM_33', 'TRIM_50', 'HOLD', 'WATCH'];
  if (intradayLock) {
    blocked.push('EXIT_100', 'SELL_FULL', 'EXIT_FULL', 'BUY', 'STAGED_BUY');
  } else {
    allowed.push('EXIT_100', 'SELL_FULL');
    if (heatGate === 'BLOCK_NEW_BUY' || cashFloorStatus !== 'PASS') {
      blocked.push('BUY', 'STAGED_BUY');
    } else {
      allowed.push('BUY', 'STAGED_BUY');
    }
  }
  return { allowed: allowed, blocked: blocked };
}


// ── H2: 매도후보 순위 하네스 ─────────────────────────────────────────────────

/**
 * calcSellPriority_
 * 보유 종목별 Sell_Priority_Score(0~100 clamp) + tier 배정, tier ASC / score DESC 정렬
 * spec/risk/portfolio_exposure.yaml:candidate_scoring
 */
function calcSellPriority_(holdings, dfMap, h1) {
  var candidates = [];

  holdings.forEach(function(h) {
    var df  = dfMap[h.ticker] || {};
    var raw = scoreSellCandidate_(h, df, h1);

    // 코어 주도주 tier=9 고정
    var isCoreLeader = indexOfArr_(CORE_TICKERS, h.ticker) >= 0;
    var tier = isCoreLeader ? 9 : raw.tier;

    var clamped = Math.min(Math.max(raw.rawScore, 0), 100);

    candidates.push({
      rank:         0,  // 정렬 후 부여
      ticker:       h.ticker,
      name:         h.name || df.name || '',
      account:      h.account || '',
      tier:         tier,
      score:        clamped,
      raw_score:    raw.rawScore,
      rebound_holdback_score: raw.reboundHoldback || 0,
      rebound_holdback_reason: raw.reboundReason || '',
      cash_preserve_style: raw.cashPreserveStyle || '',
      cash_preserve_ratio: raw.cashPreserveRatio || 0,
      cash_preserve_reason: raw.cashPreserveReason || '',
      trim_style:   isCoreLeader && df.close > 0 && df.ma20 > 0 && df.close >= df.ma20
        ? 'CORE_LAST'
        : (raw.reboundHoldback || 0) >= 18 ? 'STEP_25' : (raw.reboundHoldback || 0) >= 10 ? 'STEP_33' : 'STEP_50',
      clamp_applied: raw.rawScore !== clamped,
      clamp_label:  raw.rawScore !== clamped
        ? ('[CLAMP 발동: raw=' + raw.rawScore + ' → ' + clamped + ']') : '',
      reason:       isCoreLeader ? '코어주도주보호(tier=9 고정)' : raw.reason,
      stop_breach:  h.stopBreach,
      weight_pct:   h.weightPct,
      final_action: df.finalAction || ''
    });
  });

  // tier ASC, score DESC 정렬
  candidates.sort(function(a, b) {
    if (a.tier !== b.tier) return a.tier - b.tier;
    return b.score - a.score;
  });
  candidates.forEach(function(c, idx) { c.rank = idx + 1; });

  return { candidates: candidates, lock: true };
}

/**
 * scoreSellCandidate_
 * 단일 종목 원시점수 + tier 계산
 * spec/risk/portfolio_exposure.yaml:candidate_scoring.components
 */
function scoreSellCandidate_(h, df, h1) {
  // THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/inject_computed_harness.py:check_sanity
  var pts     = 0;
  var reasons = [];
  var tier    = 7;  // 기본: 단순 수익실현
  var reboundHoldback = calcReboundHoldbackScore_({
    close:        h.close,
    ma20:         df.ma20,
    ma60:         df.ma60,
    ma20Slope:    df.ma20Slope,
    rsi14:        df.rsi14,
    bbPosition:   df.bbPosition,
    flowCredit:   df.flowCredit,
    leaderTotal:   df.leaderTotal,
    leaderGate:    df.leaderGate,
    bandStatus:    df.bandStatus,
    profitPct:     df.profitPct,
    isCoreLeader:  indexOfArr_(CORE_TICKERS, h.ticker) >= 0,
  });

  // ── 1. hard_precedence ────────────────────────────────────────────────────
  if (h.stopBreach) {
    pts += SP.HARD_STOP_BREACH;
    tier = Math.min(tier, 2);
    reasons.push('stop_breach(' + SP.HARD_STOP_BREACH + ')');
  } else if (h1.cashFloorStatus === 'TRIM_REQUIRED' || h1.cashFloorStatus === 'HARD_BLOCK') {
    pts += SP.CASH_FLOOR_TRIM;
    tier = Math.min(tier, 3);
    reasons.push('cash_floor_trim(' + SP.CASH_FLOOR_TRIM + ')');
  } else if (df.isDuplicateEtf) {
    pts += SP.DUPLICATE_ETF;
    tier = Math.min(tier, 4);
    reasons.push('duplicate_etf(' + SP.DUPLICATE_ETF + ')');
  } else {
    var fa = (df.finalAction || '').toUpperCase();
    if (fa.indexOf('TRIM') >= 0 || fa.indexOf('ROTATE') >= 0
        || fa.indexOf('SELL') >= 0 || fa.indexOf('EXIT') >= 0) {
      pts += SP.HOLDING_TRIM_ROTATE;
      tier = Math.min(tier, 5);
      reasons.push('holding_trim(' + SP.HOLDING_TRIM_ROTATE + ')');
    } else {
      var profitLockBase = SP["TAKE_PROFIT_BASE"];
      pts += profitLockBase;
      tier = Math.min(tier, 6);
      reasons.push('profit_lock_base(' + profitLockBase + ')');
    }
  }

  // ── 2. duplicate_exposure_points ─────────────────────────────────────────
  if (df.isDuplicateEtf) {
    pts += SP.DUP_SAME_SECTOR;
    reasons.push('dup_sector_etf(' + SP.DUP_SAME_SECTOR + ')');
  }

  // ── 3. cash_relief_points ─────────────────────────────────────────────────
  if (h1.totalAsset > 0 && h.marketValue > 0) {
    var reliefPct = h.marketValue / h1.totalAsset * 100;
    if (reliefPct >= 3) {
      pts += SP.CASH_RELIEF_GE3;
      reasons.push('cash_relief>=3%(' + SP.CASH_RELIEF_GE3 + ')');
    } else if (reliefPct >= 1) {
      pts += SP.CASH_RELIEF_1_3;
      reasons.push('cash_relief1~3%(' + SP.CASH_RELIEF_1_3 + ')');
    } else {
      pts += SP.CASH_RELIEF_LT1;
      reasons.push('cash_relief<1%(' + SP.CASH_RELIEF_LT1 + ')');
    }
  }

  // ── 4. weakness_points ────────────────────────────────────────────────────
  var rw = df.rwPartial || 0;
  if (rw >= 4)      { pts += SP.RW_GE4; reasons.push('RW>=' + rw + '(' + SP.RW_GE4 + ')'); }
  else if (rw === 3) { pts += SP.RW_3;   reasons.push('RW=3(' + SP.RW_3 + ')'); }
  else if (rw === 2) { pts += SP.RW_2;   reasons.push('RW=2(' + SP.RW_2 + ')'); }

  var flowOk = (df.flowOk || '').toUpperCase();
  var flowCr = df.flowCredit;
  if ((typeof flowCr === 'number' && flowCr < 0.5)
      || flowOk === 'N' || flowOk === 'FALSE' || flowOk === '0') {
    pts += SP.FLOW_NEGATIVE;
    reasons.push('flow_neg(' + SP.FLOW_NEGATIVE + ')');
  }

  if (h.close > 0 && df.ma20 > 0 && h.close < df.ma20) {
    pts += SP.BELOW_MA20;
    reasons.push('below_MA20(' + SP.BELOW_MA20 + ')');
  }

  // ── 5. overweight_points ──────────────────────────────────────────────────
  if (df.weightTargetPct > 0 && h.weightPct > 0) {
    var overPct = h.weightPct - df.weightTargetPct;
    if (overPct >= 5)      { pts += SP.OVERWEIGHT_5P; reasons.push('overweight>5p(' + SP.OVERWEIGHT_5P + ')'); }
    else if (overPct >= 2) { pts += SP.OVERWEIGHT_2P; reasons.push('overweight>2p(' + SP.OVERWEIGHT_2P + ')'); }
  }

  // ── 6. liquidity_points ───────────────────────────────────────────────────
  var atv = df.avgTradeVal5d || 0;
  if (atv >= 1000) {        // 10억원 이상 (단위: 백만원)
    pts += SP.LIQUIDITY_OK;  reasons.push('liq_ok(' + SP.LIQUIDITY_OK + ')');
  } else if (atv > 0 && atv < 100) {  // 1억원 미만
    pts += SP.LIQUIDITY_LOW; reasons.push('liq_low(' + SP.LIQUIDITY_LOW + ')');
  }

  // ── 7. tax_penalty_points (미확인 기본) ────────────────────────────────────
  pts -= SP.TAX_UNKNOWN;
  reasons.push('tax_unknown(-' + SP.TAX_UNKNOWN + ')');

  // ── 8. core_quality_protection_points (감점) ──────────────────────────────
  if (indexOfArr_(CORE_TICKERS, h.ticker) >= 0) {
    pts -= SP.CORE_LEADER;
    reasons.push('core_leader(-' + SP.CORE_LEADER + ')');
  } else if ((df.grade || '').toUpperCase() === 'A') {
    pts -= SP.A_GRADE_CORE;
    reasons.push('A_grade(-' + SP.A_GRADE_CORE + ')');
  }

  if (reboundHoldback.score > 0) {
    pts -= reboundHoldback.score;
    reasons.push('rebound_holdback(-' + reboundHoldback.score + (reboundHoldback.reasons ? ' [' + reboundHoldback.reasons + ']' : '') + ')');
  }

  var cashPreservePlan = calcCashPreservationPlan_({
    sellAction: h.finalAction || df.finalAction || '',
    cashFloorStatus: h1.cashFloorStatus || '',
    regime: h1.regime || df.regime || '',
    isCoreLeader: indexOfArr_(CORE_TICKERS, h.ticker) >= 0,
    isEtf: !!df.isDuplicateEtf,
    liquidityStatus: String(df.liquidityStatus || df.Liquidity_Status || ''),
    spreadStatus: String(df.spreadStatus || df.Spread_Status || ''),
    accountType: String(h.account_type || h.accountType || ''),
    profitPct: h.profitPct,
    rwPartial: rw,
    reboundHoldbackScore: reboundHoldback.score,
  });
  if (cashPreservePlan.protection_bonus > 0) {
    pts -= cashPreservePlan.protection_bonus;
    reasons.push('cash_preserve(-' + cashPreservePlan.protection_bonus + (cashPreservePlan.reasons ? ' [' + cashPreservePlan.reasons + ']' : '') + ')');
  }

  return {
    rawScore: Math.round(Math.min(100, Math.max(0, pts))),
    tier: tier,
    reason: reasons.join(', '),
    reboundHoldback: reboundHoldback.score,
    reboundReason: reboundHoldback.reasons,
    cashPreserveStyle: cashPreservePlan.style,
    cashPreserveRatio: cashPreservePlan.recommended_ratio,
    cashPreserveReason: cashPreservePlan.reasons,
  };
}


// ── H3: 수량 하네스 ──────────────────────────────────────────────────────────

/**
 * calcQuantities_
 * Sell_Qty = floor(Sell_Ratio_Pct/100 × holding_quantity) — CAPTURE_READ_OK만
 * Buy_Qty: POSITION_SIZE_V1 atr_qty·cash_limit_qty 중간값 산출
 */
function calcQuantities_(holdings, dfMap, totalAsset, buyPowerKrw, h1) {
  var sellQty      = [];
  var buyQtyInputs = [];
  var perfMult = Number.isFinite(h1.performanceMultiplier) ? h1.performanceMultiplier : 0.5;
  var perfBias = h1.performanceBuyBias || calcPerformanceBuyBias_({ bayesian_multiplier: perfMult });

  holdings.forEach(function(h) {
    var df        = dfMap[h.ticker] || {};
    var sellRatio = df.sellRatioPct || 0;
    var fa        = (df.finalAction || '').toUpperCase();
    var hasSellSignal = fa === 'SELL_READY' || fa === 'EXIT_100' || fa === 'EXIT_SIGNAL'
      || fa === 'EXIT_REVIEW' || fa.indexOf('TRIM') >= 0 || fa === 'TRAILING_STOP_BREACH';
    var sellQtyValue;

    // ── Sell_Qty (M3: 선행 계산된 Sell_Qty 컬럼 우선 사용) ───────────────
    if (h.holdingQty > 0 && hasSellSignal) {
      if (df.sellQty > 0) {
        // 데이터 피드에 이미 계산된 정수 수량 사용 (CAPTURE_READ_OK 기반)
        sellQtyValue = Math.floor(df.sellQty);
      } else if (sellRatio > 0) {
        sellQtyValue = Math.floor(sellRatio / 100 * h.holdingQty);
      } else {
        sellQtyValue = 'CAPTURE_REQUIRED';  // 매도신호 있으나 수량 산출 불가
      }
    } else if (h.holdingQty > 0) {
      sellQtyValue = null;  // 매도신호 없음 — CAPTURE_REQUIRED 오남용 방지
    } else {
      sellQtyValue = 'NO_HOLDING';
    }
    sellQty.push({
      ticker: h.ticker,
      account: h.account || '',
      name: h.name || df.name || '',
      holding_qty: h.holdingQty || 0,
      sell_ratio_pct: sellRatio || 0,
      sell_qty: sellQtyValue
    });

    // ── Buy_Qty (BUY 후보이고 gates 통과 시만 산출) ───────────────────────
    var fa = (df.finalAction || '').toUpperCase();
    var isBuyCandidate = fa.indexOf('BUY') >= 0 || fa.indexOf('STAGED') >= 0;
    var buyBlocked = h1.heatGate === 'BLOCK_NEW_BUY'
      || h1.cashFloorStatus !== 'PASS'
      || h1.intradayLock
      || perfBias.entry_block;

    if (!isBuyCandidate || buyBlocked) return;

    var atr20 = df.atr20 || 0;
    var close = h.close || df.close || 0;

    if (atr20 > 0 && close > 0 && totalAsset > 0) {
      var riskKrw  = totalAsset * BASE_RISK_BUDGET * perfMult;
      riskKrw = riskKrw * perfBias.quantity_multiplier;
      var atrQty   = Math.floor(riskKrw / (atr20 * 1.5));
      var cashQty  = Math.floor(buyPowerKrw / close);
      var halve    = h1.heatGate === 'HALVE_NEW_BUY_QUANTITY';
      if (halve) atrQty = Math.floor(atrQty / 2);
      // M1: DRAWDOWN_GUARD_V1 추가 축소
      var dgScale = (h1.drawdownBuyScale !== undefined && h1.drawdownBuyScale < 1.0)
        ? h1.drawdownBuyScale : 1.0;
      if (dgScale < 1.0) atrQty = Math.floor(atrQty * dgScale);
      // N1: POSITION_SIZE_REGIME_SCALE_V1 국면 스케일
      var rssScale = (typeof h1.regimeSizeScale === 'number') ? h1.regimeSizeScale : 1.0;
      if (rssScale !== 1.0) atrQty = Math.floor(atrQty * rssScale);
      // O4: WIN_LOSS_STREAK_GUARD_V1 승률 하락 시 추가 축소
      var wlScale = (typeof h1.winLossStreakBuyScale === 'number') ? h1.winLossStreakBuyScale : 1.0;
      if (wlScale < 1.0) atrQty = Math.floor(atrQty * wlScale);

      buyQtyInputs.push({
        ticker:         h.ticker,
        account:        h.account || '',
        name:           h.name || df.name || '',
        atr_qty:        atrQty,
        cash_limit_qty: cashQty,
        final_qty:      Math.min(atrQty, cashQty),
        atr20:          atr20,
        close:          close,
        halve_applied:  halve,
        perf_bias_reason: perfBias.reason,
        perf_bias_mult:   perfBias.quantity_multiplier,
        missing:        []
      });
    } else {
      var missing = [];
      if (!atr20) missing.push('ATR20');
      if (!close) missing.push('Close_Price');
      if (!totalAsset) missing.push('total_asset');
      buyQtyInputs.push({
        ticker: h.ticker,
        account: h.account || '',
        name: h.name || df.name || '',
        final_qty: 'NO_BUY_QUANTITY',
        missing: missing
      });
    }
  });

  return { sellQty: sellQty, buyQtyInputs: buyQtyInputs };
}


// ── H4: 가격 하네스 ──────────────────────────────────────────────────────────

/**
 * calcPrices_
 * 보유 종목별:
 *   STOP_PRICE_CORE_V1 → TICK_NORMALIZER_V1
 *   TAKE_PROFIT_LADDER_V2 (tier1/tier2) → TICK_NORMALIZER_V1
 */
function calcPrices_(holdings, dfMap, marketRegime) {
  // THIN_ADAPTER: [stop_loss/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_stop_price_core
  var prices = [];

  holdings.forEach(function(h) {
    var df      = dfMap[h.ticker] || {};
    var atr20   = df.atr20 || 0;
    var close   = h.close || df.close || 0;
    var avgCost = h.avgCost || 0;
    var qty     = h.holdingQty || 0;

    if (avgCost <= 0) {
      prices.push({
        ticker: h.ticker,
        account: h.account || '',
        name: h.name || df.name || '',
        error: 'NO_AVG_COST'
      });
      return;
    }

    var posClass = (df.positionClass || '').toLowerCase();
    var isCore   = posClass === 'core' || posClass === 'core_leader'
      || indexOfArr_(CORE_TICKERS, h.ticker) >= 0;

    // ── STOP_PRICE_CORE_V1 ────────────────────────────────────────────────
    // max(avgCost * 0.92, avgCost - ATR20 * atr_multiplier)
    // atr_multiplier = 2.0 if atr20/close*100 >= 8, else 1.5
    var atrMul  = 1.5;
    var stopRaw;
    if (atr20 > 0 && close > 0) {
      atrMul  = (atr20 / close * 100) >= 8 ? 2.0 : 1.5;
      stopRaw = Math.max(avgCost * 0.92, avgCost - atr20 * atrMul);
    } else {
      stopRaw = avgCost * 0.92;
    }
    var stopTick = tickNormalize_(stopRaw);

    // ── X4: ATR Ratchet (atr_early_ratchet + atr_trailing_universal) ─────────
    // highest_price_since_entry 우선 사용 (account_snapshot 컬럼).
    // 미입력 시 close 로 폴백 (일일 마감 기준 보수적 처리).
    var maxPriceRef = (h.highestPriceSinceEntry && h.highestPriceSinceEntry > close)
      ? h.highestPriceSinceEntry : close;
    var ratchetApplied = 'NONE';
    var ratchetNote    = '';
    var ratchetSrc     = h.highestPriceSinceEntry ? 'highest_price_since_entry' : 'close_fallback';
    if (atr20 > 0 && maxPriceRef > 0 && avgCost > 0) {
      var earlyTrigger     = avgCost + atr20 * 1.0;
      var trailingStopRaw  = Math.max(maxPriceRef - atr20 * 2.0, 0);
      var trailingStopTick = tickNormalize_(trailingStopRaw);
      var breakevenTick    = tickNormalize_(avgCost);

      if (maxPriceRef >= earlyTrigger) {
        // 조기 본절 발동: stop >= breakeven
        var ratchetedStop = Math.max(stopTick, trailingStopTick, breakevenTick);
        if (ratchetedStop > stopTick) {
          ratchetNote  = 'early_ratchet[' + ratchetSrc + ']: max(' + maxPriceRef + ')>=avgCost+ATR(' + Math.round(earlyTrigger)
                         + ') → stop_floor=breakeven(' + breakevenTick + ')'
                         + ' | trailing=' + trailingStopTick;
          stopTick     = ratchetedStop;
          ratchetApplied = 'EARLY_RATCHET+TRAILING';
        } else {
          ratchetNote    = 'early_ratchet_inactive: stop already>=' + stopTick;
          ratchetApplied = 'EARLY_RATCHET_INACTIVE';
        }
      } else if (trailingStopTick > stopTick) {
        // 조기 본절 미발동, 트레일링만 적용
        ratchetNote    = 'trailing_only[' + ratchetSrc + ']: max-ATR*2=' + trailingStopTick + '>stop_core=' + stopTick;
        stopTick       = trailingStopTick;
        ratchetApplied = 'TRAILING_ONLY';
      } else {
        ratchetApplied = 'PASS (stop_core >= trailing)';
        ratchetNote    = 'trailing=' + trailingStopTick + ' <= stop_core=' + stopTick;
      }
    } else {
      ratchetApplied = 'SKIP (atr/close/avgCost 부재)';
    }

    // ── TAKE_PROFIT_LADDER_V2 ─────────────────────────────────────────────
    // tier_1: max(avgCost * pct1, avgCost + ATR20 * 1.5)
    // tier_2: max(avgCost * pct2, avgCost + ATR20 * 3.0)
    var pct1  = isCore ? 1.15 : 1.10;
    var pct2  = isCore ? 1.25 : 1.20;
    var tp1Raw, tp2Raw, ladderVer;

    if (atr20 > 0) {
      tp1Raw    = Math.max(avgCost * pct1, avgCost + atr20 * 1.5);
      tp2Raw    = Math.max(avgCost * pct2, avgCost + atr20 * 3.0);
      ladderVer = 'V2_ATR';
    } else {
      tp1Raw    = avgCost * pct1;
      tp2Raw    = avgCost * pct2;
      ladderVer = 'V1_FALLBACK';
    }
    var tp1Tick = tickNormalize_(tp1Raw);
    var tp2Tick = tickNormalize_(tp2Raw);

    // ── PROFIT_LOCK_STAGE_CLASSIFIER_V1 ──────────────────────────────────────
    // spec/exit/take_profit.yaml:profit_lock_ratchet.ratchet_table 기준
    // 수익률 구간별 단계 분류 — LLM이 임의 판정하는 것을 하네스에서 선점 (Direction Q 준수)
    var profitPct = (close > 0 && avgCost > 0) ? (close - avgCost) / avgCost * 100 : 0;
    // spec/13_formula_registry.yaml:PROFIT_LOCK_STAGE_V1 단계명 기준 (B06 정정 2026-05-30)
    var profitLockStage, ratchetStopOverride, ratchetPartialQty;
    if (profitPct >= 60) {
      profitLockStage     = 'APEX_SUPER';
      ratchetStopOverride = tickNormalize_(
        Math.max(avgCost * 1.40, atr20 > 0 ? close - atr20 * 1.2 : avgCost * 1.40)
      );
      ratchetPartialQty   = qty > 0 ? Math.floor(qty * 0.50) : 0;
    } else if (profitPct >= 40) {
      profitLockStage     = 'APEX_TRAILING';
      ratchetStopOverride = tickNormalize_(
        Math.max(avgCost * 1.35, atr20 > 0 ? close - atr20 * 1.5 : avgCost * 1.35)
      );
      ratchetPartialQty   = qty > 0 ? Math.floor(qty * 0.40) : 0;
    } else if (profitPct >= 30) {
      profitLockStage     = 'PROFIT_LOCK_30';
      ratchetStopOverride = tickNormalize_(avgCost * 1.20);
      ratchetPartialQty   = qty > 0 ? Math.floor(qty * 0.35) : 0;
    } else if (profitPct >= 20) {
      profitLockStage     = 'PROFIT_LOCK_20';
      ratchetStopOverride = tickNormalize_(avgCost * 1.10);
      ratchetPartialQty   = qty > 0 ? Math.floor(qty * 0.25) : 0;
    } else if (profitPct >= 10) {
      profitLockStage     = 'PROFIT_LOCK_10';
      ratchetStopOverride = tickNormalize_(avgCost * 1.00);
      ratchetPartialQty   = 0;
    } else if (profitPct >= 0) {
      profitLockStage     = 'BREAKEVEN_RATCHET';
      ratchetStopOverride = tickNormalize_(avgCost);
      ratchetPartialQty   = 0;
    } else {
      profitLockStage     = 'NORMAL';
      ratchetStopOverride = null;
      ratchetPartialQty   = 0;
    }
    // profit_lock_ratchet 손절선이 기존 손절선보다 높으면 적용 (PROFIT_LOCK_RATCHET_V1)
    if (ratchetStopOverride && ratchetStopOverride > stopTick) {
      stopTick = ratchetStopOverride;
      if (ratchetApplied === 'NONE' || ratchetApplied === 'SKIP (atr/close/avgCost 부재)') {
        ratchetApplied = 'PROFIT_LOCK_RATCHET';
        ratchetNote    = 'profit_lock_stage=' + profitLockStage + ' → stop→' + stopTick;
      }
    }

    // ── TP_VALIDITY_CHECK_V1: 현재가 이하 TP는 무효화 (HS009) ─────────────────
    // tp_price <= close 이면 INVALID_TP_STALE — LLM에 null 전달하여 오표기 원천 차단
    var tp1State, tp2State;
    if (close > 0) {
      tp1State = tp1Tick > close ? 'PENDING' : 'TP1_ALREADY_TRIGGERED';
      tp2State = tp2Tick > close ? 'PENDING' : 'TP2_ALREADY_TRIGGERED';
      if (tp1State !== 'PENDING') tp1Tick = null;
      if (tp2State !== 'PENDING') tp2Tick = null;
    } else {
      tp1State = 'UNKNOWN_NO_CLOSE';
      tp2State = 'UNKNOWN_NO_CLOSE';
    }

    // ── SECULAR_LEADER_REGIME_GATE_V1 (H3) ───────────────────────────────────
    // 삼성전자·SK하이닉스의 secular_leader_profit_lock 발동 여부를 결정론적 판정
    var slGate = calcSecularLeaderGate_(h.ticker, marketRegime || 'UNKNOWN', df, qty);

    // secular_leader_gate 활성 시 tp1 표시 조정 (profit_lock 구간별 차등)
    if (slGate.active) {
      if (profitLockStage === 'PROFIT_LOCK_10') {
        // +10%: tier_1 부분익절 보류 — trailing_stop(본절) 상향만
        tp1State = 'DEFERRED_SECULAR_LEADER';
        tp1Tick  = null;
      } else if (profitLockStage === 'PROFIT_LOCK_20') {
        // +20%: 과열신호 2개 미만이면 부분익절 보류
        var overheatSignals = 0;
        if (typeof df.acTotal === 'number' && df.acTotal >= 2) overheatSignals++;
        if (typeof df.frg5d === 'number' && df.frg5d < 0 &&
            typeof df.inst5d === 'number' && df.inst5d < 0) overheatSignals++;
        if (typeof df.rsi14 === 'number' && df.rsi14 >= 80) overheatSignals++;
        // H6: 거래대금 급증 과열신호 — AVG_TRADE_VALUE_SIGNAL_V1
        var atvSig = calcAvgTradeValueSignal_(h.ticker, df);
        if (atvSig.overheat_triggered) overheatSignals++;
        df._avg_trade_val_signal = atvSig;
        if (overheatSignals < 2) {
          tp1State = 'DEFERRED_SECULAR_LEADER_OVERHEAT_PENDING';
          tp1Tick  = null;
        }
      } else if (profitLockStage === 'PROFIT_LOCK_30'
              || profitLockStage === 'APEX_TRAILING'
              || profitLockStage === 'APEX_SUPER') {
        // +30%/APEX: trailing_stop 기반 관리로 전환 — 래칫 stop 우선 (TP는 참고용만)
        tp1State = 'TRAILING_STOP_PRIORITY_SECULAR_LEADER';
        // tp1Tick 유지 — 참고용 유지하되 HTS 주문 표기는 별도 주석으로 처리
      }
    }

    // TP 무효화 시 수량도 0 (무효 TP에 수량 기재 금지)
    var tp1Q = (tp1Tick && qty > 0) ? Math.floor(qty * (isCore ? 0.25 : 0.33)) : 0;
    var tp2Q = (tp2Tick && qty > 0) ? Math.floor((qty - tp1Q) * (isCore ? 0.40 : 0.50)) : 0;
    var tp3Q = qty - tp1Q - tp2Q;

    prices.push({
      ticker:              h.ticker,
      account:             h.account || '',
      name:                h.name || df.name || '',
      position_class:      isCore ? 'core' : 'satellite',
      atr_mul_used:        atrMul,
      tick_size:           getTickSize_(stopRaw),
      ladder_version:      ladderVer,
      stop_price_raw:      Math.round(stopRaw),
      stop_price:          stopTick,
      tp1_price_raw:       Math.round(tp1Raw),
      tp1_price:           tp1Tick,      // null = TP1 이미 통과 (TP_VALIDITY_CHECK_V1)
      tp1_state:           tp1State,
      tp1_qty:             tp1Q,
      tp2_price_raw:       Math.round(tp2Raw),
      tp2_price:           tp2Tick,      // null = TP2 이미 통과
      tp2_state:           tp2State,
      tp2_qty:             tp2Q,
      tp3_qty:             tp3Q,
      profit_pct:          Math.round(profitPct * 10) / 10,
      profit_lock_stage:   profitLockStage,
      ratchet_partial_qty: ratchetPartialQty,
      atr20:               atr20,
      avg_cost:            avgCost,
      ratchet_applied:     ratchetApplied,
      ratchet_note:        ratchetNote,
      ratchet_price_src:   ratchetSrc,
      highest_price_since_entry: h.highestPriceSinceEntry || null,
      secular_leader_gate_active: slGate.active,
      secular_leader_gate_status: slGate.status,
      secular_leader_gate_reasons: slGate.reasons
    });
  });

  return { prices: prices };
}


// ── H5: 결정 상태머신 게이팅 ─────────────────────────────────────────────────

/**
 * runRouteFlow_
 * data_feed.Final_Action → H1 게이트 적용 → 확정 final_action + gate_trace
 * 구현 게이트: STOP_BREACH → INTRADAY_LOCK → HEAT_GATE → CASH_FLOOR → EXIT_POLICY
 * spec/09_decision_flow.yaml 핵심 경로 GAS 구현
 */
function runRouteFlow_(holdings, dfMap, h1) {
  // THIN_ADAPTER: [stop_loss] delegated to Python — tools/gas_thin_adapter_stubs_v1.py:stub_run_route_flow
  var routes = [];
  var traces = [];

  holdings.forEach(function(h) {
    var df      = dfMap[h.ticker] || {};
    var baseFa  = (df.finalAction || 'INSUFFICIENT_DATA').toUpperCase();
    var trace   = [];
    var finalFa = baseFa;

    // ── Gate 1a: Stop_Price Breach 감지 ──────────────────────────────────
    if (h.stopBreach) {
      if (h1.intradayLock) {
        finalFa = 'TRIM_50';  // P4: 장중은 EXIT_100 금지 → TRIM_50 완화
        trace.push({ gate: 'STOP_BREACH', result: 'DOWNGRADE_P4',
                     reason: '장중(P4): stop_breach→TRIM_50 완화' });
      } else {
        finalFa = 'EXIT_100';
        trace.push({ gate: 'STOP_BREACH', result: 'FORCE_EXIT',
                     reason: 'close(' + h.close + ')<=stop(' + h.stopPrice + ')' });
      }
    } else {
      trace.push({ gate: 'STOP_BREACH', result: 'PASS', reason: 'no_breach' });
    }

    // ── Gate 1a-bis: Relative Stop — 시장 베타 보정 손절 (TRIM_50) ─────────
    if (finalFa !== 'EXIT_100') {
      var rsDf      = df;
      var rsRet20d  = typeof rsDf.ret20d === 'number' ? rsDf.ret20d : parseFloat(rsDf.ret20d);
      var rsAtr20   = typeof rsDf.atr20  === 'number' ? rsDf.atr20  : parseFloat(rsDf.atr20);
      var rsClose   = h.close || rsDf.close || 0;
      var rsPft     = typeof h.profitPct === 'number' ? h.profitPct : parseFloat(h.profitPct);
      var rsHdays   = typeof h.holdingDays === 'number' ? h.holdingDays : parseInt(h.holdingDays) || 0;
      var rsKospi   = typeof h1.kospiRet20d === 'number' ? h1.kospiRet20d : 0;

      if (Number.isFinite(rsRet20d) && Number.isFinite(rsAtr20) && rsClose > 0) {
        var rsBeta    = (Math.abs(rsKospi) >= 0.5) ? Math.min(3.0, Math.max(0.3, rsRet20d / rsKospi)) : 1.0;
        var rsExcess  = rsRet20d - rsBeta * rsKospi;
        var rsSigma   = (rsAtr20 / rsClose * 100) * Math.sqrt(20);
        var rsThresh  = -2.0 * rsSigma;
        var rsAbsFl   = Number.isFinite(rsPft) && rsPft < -20.0;
        var rsTimeSt  = rsHdays >= 60 && rsExcess < 0;
        var rsRelBr   = rsExcess < rsThresh;

        if (rsAbsFl || rsRelBr || rsTimeSt) {
          var rsType = rsAbsFl ? 'ABS_FLOOR' : (rsRelBr ? 'REL_EXCESS' : 'TIME_STOP');
          trace.push({ gate: 'RELATIVE_STOP', result: 'TRIM_50',
                       reason: rsType + ': excess=' + round2_(rsExcess) + ' thr=' + round2_(rsThresh) });
          if (finalFa === 'HOLD' || finalFa.indexOf('BUY') >= 0) finalFa = 'TRIM_50';
        } else {
          trace.push({ gate: 'RELATIVE_STOP', result: 'PASS',
                       reason: 'excess=' + round2_(rsExcess) + ' thr=' + round2_(rsThresh) });
        }
      } else {
        trace.push({ gate: 'RELATIVE_STOP', result: 'SKIP', reason: 'insufficient_data' });
      }
    } else {
      trace.push({ gate: 'RELATIVE_STOP', result: 'INACTIVE', reason: 'stop_breach_exit_100' });
    }

    // ── Gate 1b: Intraday_Lock — 차단목록 다운그레이드 + 허용목록 이중검증 ──
    if (h1.intradayLock) {
      // 1단계: 차단 키워드 다운그레이드
      if (indexOfArr_(INTRADAY_BLOCKED_KEYWORDS, finalFa) >= 0) {
        var downgraded = finalFa.indexOf('BUY') >= 0 ? 'WATCH' : 'TRIM_50';
        trace.push({ gate: 'INTRADAY_LOCK', result: 'DOWNGRADE',
                     reason: 'P4: ' + finalFa + '→' + downgraded });
        finalFa = downgraded;
      }
      // 2단계: 허용목록 이중검증 — 다운그레이드 후에도 허용 목록 외 액션 강제 WATCH
      if (indexOfArr_(INTRADAY_ALLOWED_ACTIONS, finalFa) < 0) {
        trace.push({ gate: 'INTRADAY_LOCK', result: 'FORCE_WATCH',
                     reason: 'P4_ALLOWLIST: ' + finalFa + ' not in allowed list→WATCH' });
        finalFa = 'WATCH';
      } else {
        trace.push({ gate: 'INTRADAY_LOCK', result: 'PASS', reason: 'action_in_allowlist' });
      }
    } else {
      trace.push({ gate: 'INTRADAY_LOCK', result: 'INACTIVE', reason: 'post_market' });
    }

    // ── Gate 1c: Heat Gate — BUY 차단/감량 ────────────────────────────────
    if (h1.heatGate === 'BLOCK_NEW_BUY' && finalFa.indexOf('BUY') >= 0) {
      trace.push({ gate: 'HEAT_GATE', result: 'BLOCK_BUY',
                   reason: 'total_heat>=10%: BUY→WATCH' });
      finalFa = 'WATCH';
    } else if (h1.heatGate === 'HALVE_NEW_BUY_QUANTITY' && finalFa.indexOf('BUY') >= 0) {
      trace.push({ gate: 'HEAT_GATE', result: 'HALVE_QTY',
                   reason: 'total_heat>=7%: 수량 50% 감량 적용' });
    } else {
      trace.push({ gate: 'HEAT_GATE', result: 'PASS', reason: h1.heatGate });
    }

    // ── Gate 1d: Mean Reversion Gate — 이격 과대 BUY 차단 (MRG001) ──────────
    if (finalFa.indexOf('BUY') >= 0) {
      var mrgClose = df.close || 0;
      var mrgMa20  = df.ma20  || 0;
      if (mrgClose > 0 && mrgMa20 > 0) {
        var devRatio = round2_(mrgClose / mrgMa20);
        if (devRatio >= 1.15) {
          trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'BUY_HARD_BLOCK',
                       reason: 'MRG001: deviation_ratio(' + devRatio + ')>=1.15→BUY_HARD_BLOCK' });
          finalFa = 'WATCH';
        } else {
          trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'PASS',
                       reason: 'deviation_ratio=' + devRatio + '<1.15' });
        }
      } else {
        trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'SKIP',
                     reason: 'close/ma20 missing' });
      }
    } else {
      trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'INACTIVE',
                   reason: 'action_not_BUY' });
    }

    // ── Gate 2: Cash Floor — BUY 차단, HOLD → TRIM 넛지 ───────────────────
    if (h1.cashFloorStatus === 'HARD_BLOCK' && finalFa.indexOf('BUY') >= 0) {
      trace.push({ gate: 'CASH_FLOOR', result: 'HARD_BLOCK',
                   reason: 'immediate_cash<floor: BUY→WATCH' });
      finalFa = 'WATCH';
    } else if (h1.cashFloorStatus === 'TRIM_REQUIRED' && finalFa.indexOf('BUY') >= 0) {
      trace.push({ gate: 'CASH_FLOOR', result: 'BUY_BLOCKED',
                   reason: 'TRIM_REQUIRED: BUY→WATCH' });
      finalFa = 'WATCH';
    } else if (h1.cashFloorStatus === 'TRIM_REQUIRED' && finalFa === 'HOLD') {
      trace.push({ gate: 'CASH_FLOOR', result: 'NUDGE_TRIM',
                   reason: 'TRIM_REQUIRED: HOLD→TRIM_33 권고' });
      finalFa = 'TRIM_33';
    } else {
      trace.push({ gate: 'CASH_FLOOR', result: 'PASS', reason: h1.cashFloorStatus });
    }

    // ── Gate 3: Exit Policy — Sell_Signal 확인 ────────────────────────────
    var ss = (df.sellSignal || '').toUpperCase();
    if (ss === 'SIGNAL_CONFIRMED' || ss.indexOf('STOP') >= 0
        || ss.indexOf('EXIT') >= 0) {
      trace.push({ gate: 'EXIT_POLICY', result: 'SELL_SIGNAL',
                   reason: 'data_feed.Sell_Signal=' + df.sellSignal });
    } else {
      trace.push({ gate: 'EXIT_POLICY', result: 'PASS', reason: 'no_exit_signal' });
    }

    routes.push({
      ticker:       h.ticker,
      account:      h.account || '',
      name:         h.name || df.name || '',
      base_action:  baseFa,
      final_action: finalFa,
      gate_changed: baseFa !== finalFa,
      gate_trace:   trace,
      rs_verdict:   df.rs_verdict || null
    });

    for (var t = 0; t < trace.length; t++) {
      traces.push({
        ticker: h.ticker,
        account: h.account || '',
        state: trace[t].gate,
        check_id: 'H5_' + trace[t].gate,
        rule_ref: 'gas_data_feed.gs:' + trace[t].gate,
        inputs_used: {
          base_action: baseFa,
          close: h.close,
          stop_price: h.stopPrice,
          intraday_lock: h1.intradayLock,
          heat_gate_status: h1.heatGate,
          cash_floor_status: h1.cashFloorStatus
        },
        result: trace[t].result,
        selected_action: finalFa,
        blocked_actions: h1.blockedActions || [],
        missing_inputs: [],
        tie_breaker_applied: null,
        reason: trace[t].reason
      });
    }
  });

  return { ["decisions"]: routes, traces: traces, lock: true };
}

function findPriceRow_(priceRows, ticker) {
  for (var i = 0; i < priceRows.length; i++) {
    if (priceRows[i].ticker === ticker) return priceRows[i];
  }
  return null;
}

function findSellQtyRow_(sellRows, ticker) {
  for (var i = 0; i < sellRows.length; i++) {
    if (sellRows[i].ticker === ticker) return sellRows[i];
  }
  return null;
}

function findBuyQtyRow_(buyRows, ticker) {
  for (var i = 0; i < buyRows.length; i++) {
    if (buyRows[i].ticker === ticker) return buyRows[i];
  }
  return null;
}

function classifyOrderType_(signalCode, holding) {
  if (holding && holding.stopBreach) return 'STOP_LOSS';
  if (signalCode.indexOf('BUY') >= 0) return 'BUY';
  if (signalCode.indexOf('EXIT') >= 0 || signalCode.indexOf('SELL') >= 0
      || signalCode.indexOf('TRIM') >= 0 || signalCode.indexOf('ROTATE') >= 0) {
    return 'SELL';
  }
  if (signalCode === 'HOLD') return 'HOLD';
  return 'WATCH';
}

function computeTrimQuantity_(finalAction, holdingQty, sellQtyValue) {
  if (finalAction === 'TRIM_25') return Math.floor(holdingQty * 0.25);
  if (finalAction === 'TRIM_33') return Math.floor(holdingQty * 0.33);
  if (finalAction === 'TRIM_50') return Math.floor(holdingQty * 0.50);
  if (typeof sellQtyValue === 'number') return sellQtyValue;
  return null;
}

function buildOrderBlueprint_(holdings, dfMap, h1, h3, h4, h5) {
  // THIN_ADAPTER: [stop_loss/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:main (order_blueprint_json)
  var blueprint = [];

  var h5RouteRows_ = (h5 && h5["decisions"]) ? h5["decisions"] : [];
  for (var i = 0; i < h5RouteRows_.length; i++) {
    var routeRow = h5RouteRows_[i];
    var ticker = routeRow.ticker;
    var finalAction = (routeRow.final_action || '').toUpperCase();
    var holding = null;
    for (var j = 0; j < holdings.length; j++) {
      if (holdings[j].ticker === ticker) {
        holding = holdings[j];
        break;
      }
    }
    if (!holding) continue;

    var df = dfMap[ticker] || {};
    var priceRow = findPriceRow_(h4.prices, ticker) || {};
    var sellRow = findSellQtyRow_(h3.sellQty, ticker) || {};
    var buyRow = findBuyQtyRow_(h3.buyQtyInputs, ticker) || {};
    var orderType = classifyOrderType_(finalAction, holding);
    var limitPrice = null;
    var quantity = null;
    var validation = 'MANUAL_CHECK_REQUIRED';
    var rationaleCode = 'FINAL_ACTION:' + finalAction;

    // [Phase 1] NO_MERCY_JUDGMENT_GATE_V2: 손절가 이탈 시 절대 매도 강제 (LLM 개입 원천 차단)
    var _closePrice = holding.close || df.close || 0;
    var _stopPrice = priceRow.stop_price || holding.stopPrice || 0;
    if (_closePrice > 0 && _stopPrice > 0 && _closePrice < _stopPrice) {
      orderType = 'SELL';
      finalAction = 'EXIT_100';
      quantity = holding.holdingQty || 0;
      limitPrice = tickNormalize_(_closePrice);
      validation = (quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA';
      rationaleCode = 'EMERGENCY_SELL:NO_MERCY_JUDGMENT_GATE_V2';
    } else if (orderType === 'BUY') {
      if (indexOfArr_(h1.blockedActions || [], 'BUY') >= 0
          || indexOfArr_(h1.blockedActions || [], 'STAGED_BUY') >= 0) {
        validation = 'BLOCKED';
        rationaleCode = 'BLOCKED_ACTION:' + finalAction;
      } else if (typeof buyRow.final_qty === 'number' && buyRow.final_qty > 0) {
        limitPrice = tickNormalize_(holding.close || df.close || 0);
        quantity = buyRow.final_qty;
        validation = limitPrice > 0 ? 'PASS' : 'INSUFFICIENT_DATA';
        rationaleCode = 'POSITION_SIZE_V1:' + quantity;
      } else {
        validation = 'INSUFFICIENT_DATA';
        rationaleCode = 'NO_BUY_QUANTITY';
      }
    } else if (indexOfArr_(h1.blockedActions || [], orderType) >= 0) {
      validation = 'BLOCKED';
      rationaleCode = 'BLOCKED_ACTION:' + orderType;
    } else if (orderType === 'STOP_LOSS') {
      limitPrice = priceRow.stop_price || tickNormalize_(holding.stopPrice || 0);
      quantity = holding.holdingQty || null;
      validation = (limitPrice > 0 && quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA';
      rationaleCode = 'STOP_PRICE_CORE_V1:' + limitPrice;
    } else if (orderType === 'SELL') {
      if (finalAction === 'EXIT_100' || finalAction === 'SELL_FULL' || finalAction === 'EXIT_FULL') {
        quantity = holding.holdingQty || null;
      } else {
        quantity = computeTrimQuantity_(finalAction, holding.holdingQty || 0, sellRow.sell_qty);
      }
      limitPrice = df.sellLimitPrice > 0
        ? tickNormalize_(df.sellLimitPrice)
        : tickNormalize_(holding.close || df.close || 0);
      validation = (limitPrice > 0 && quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA';
      rationaleCode = 'SELL_RULE:' + finalAction;
    } else {
      validation = 'BLOCKED';
      rationaleCode = 'NO_EXECUTION:' + finalAction;
    }

    blueprint.push({
      account: holding.account || '일반계좌',
      ticker: ticker,
      name: holding.name || df.name || '',
      current_holding_quantity: holding.holdingQty || 0,
      average_cost_krw: holding.avgCost ? Math.round(holding.avgCost) : null,
      current_price_krw: holding.close ? Math.round(holding.close) : null,
      order_type: orderType,
      mode: orderType === 'BUY' ? 'lead' : 'none',
      limit_price_krw: limitPrice > 0 ? Math.round(limitPrice) : null,
      quantity: typeof quantity === 'number' ? quantity : null,
      // HS010: WATCH/BLOCKED/INSUFFICIENT_DATA 상태에서 가격·수량 null 강제
      // 사용자가 감시값을 HTS 주문으로 오인 입력하는 것을 원천 차단
      stop_price_krw:       validation === 'PASS' ? (priceRow.stop_price || null) : null,
      stop_quantity:        validation === 'PASS' && orderType === 'BUY' && typeof quantity === 'number' ? quantity : null,
      ["take_profit_price_krw"]: validation === 'PASS' ? (priceRow.tp1_price || null) : null,
      ["take_profit_quantity"]: validation === 'PASS' ? (priceRow.tp1_qty || null) : null,
      order_amount_krw: (limitPrice > 0 && typeof quantity === 'number') ? Math.round(limitPrice * quantity) : null,
      validation_status: validation,
      rationale_code: rationaleCode
    });
  }

  return blueprint;
}

/**
 * SELL_PRICE_SANITY_V2 (SPSV2) — 매도 주문 3중 가격 검증
 * CHECK_1: limit_price < final_stop → INVALID_PRICE_INVERSION
 * CHECK_2: stop_price < auto_trailing_stop → INVALID_TRAILING_STOP_BREACH
 * CHECK_3: limit_price == 0 → INVALID_ZERO_PRICE
 * validation_status를 인라인 재기록해 EXPORT_GATE가 자동 차단
 * @param {Array} blueprint — buildOrderBlueprint_ 반환값
 * @param {Array} profitPreservJson — profit_preservation_json (auto_trailing_stop 포함)
 * @return {Array} blueprint with spsv2_verdict 필드 추가
 */
function calcSellPriceSanityV2_(blueprint, profitPreservJson) {
  var ppMap = {};
  (profitPreservJson || []).forEach(function(pp) {
    var tk = (pp.ticker || pp.ticker_code || '').toString();
    if (tk) ppMap[tk] = pp;
  });

  return (blueprint || []).map(function(row) {
    var ot = (row.order_type || '').toString().toUpperCase();
    if (ot !== 'SELL' && ot !== 'STOP_LOSS') {
      return Object.assign({}, row, { spsv2_verdict: 'NOT_SELL_SKIP' });
    }
    if ((row.validation_status || '').toString() !== 'PASS') {
      return Object.assign({}, row, { spsv2_verdict: 'SPSV2_SKIP_NOT_PASS' });
    }

    var limitPrice   = Number(row.limit_price_krw  || 0);
    var stopPrice    = Number(row.stop_price_krw    || 0);
    var pp           = ppMap[(row.ticker || row.ticker_code || '').toString()] || {};
    var autoTrailing = Number(pp.auto_trailing_stop || 0);
    var finalStop    = (autoTrailing > 0 && autoTrailing > stopPrice) ? autoTrailing : stopPrice;

    var check1 = (limitPrice > 0 && finalStop > 0 && limitPrice < finalStop)
                 ? 'INVALID_PRICE_INVERSION' : 'PASS';
    var check2 = (autoTrailing > 0 && stopPrice > 0 && stopPrice < autoTrailing)
                 ? 'INVALID_TRAILING_STOP_BREACH' : 'PASS';
    var check3 = (limitPrice > 0) ? 'PASS' : 'INVALID_ZERO_PRICE';

    var verdict;
    if      (check1 !== 'PASS') verdict = check1;
    else if (check2 !== 'PASS') verdict = check2;
    else if (check3 !== 'PASS') verdict = check3;
    else                        verdict = 'SPSV2_PASS';

    var newValidation = (verdict === 'SPSV2_PASS') ? row.validation_status : verdict;
    return Object.assign({}, row, {
      spsv2_verdict:          verdict,
      final_stop_price:       finalStop  || stopPrice  || null,
      auto_trailing_stop_ref: autoTrailing || null,
      validation_status:      newValidation
    });
  });
}

// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P0-D: PRICE_HIERARCHY_LOCK_V1 (PHL-V1)
// 5계층 가격 단일화 잠금 — 표간 가격 혼재 완전 차단
// LAYER_1(주문가) / LAYER_2(손절/익절) / LAYER_3(트레일링보정) /
// LAYER_4(반등트리거) / LAYER_5(참고방어가)
// ═══════════════════════════════════════════════════════════════════════

/**
 * calcPriceHierarchyLock_
 * 단일 종목의 5계층 가격 분리 잠금. LAYER_5가 LAYER_1 위치에 나타나면 INVALID_LAYER_VIOLATION.
 */
function calcPriceHierarchyLock_(ticker, blueprintRow, priceRow, ppRow, scrsRow, propRefRow) {
  var bp  = blueprintRow || {};
  var pr  = priceRow     || {};
  var pp  = ppRow        || {};
  var sc  = scrsRow      || {};
  var ref = propRefRow   || {};

  var layer1         = toNumber_(bp.limit_price_krw)            || null;
  var layer2Stop     = toNumber_(pr.stop_price)                 || null;
  var layer2Tp1      = toNumber_(pr.tp1_price)                  || null;
  var layer2Tp2      = toNumber_(pr.tp2_price)                  || null;
  var layer3Trailing = toNumber_(pp.auto_trailing_stop)         || null;
  var layer4Rebound  = toNumber_(sc.rebound_trigger_price)      || null;
  var layer5RefDef   = toNumber_(ref.reference_defense_price)   || null;

  var finalStop = (layer3Trailing !== null && layer2Stop !== null)
    ? Math.max(layer2Stop, layer3Trailing)
    : (layer2Stop || layer3Trailing || null);

  var violations = [];
  if (layer5RefDef !== null && layer1 !== null && layer5RefDef === layer1) {
    violations.push({ ticker: ticker, type: 'INVALID_LAYER_VIOLATION',
      detail: 'LAYER_5(ref=' + layer5RefDef + ')==LAYER_1(order=' + layer1 + ') — 참고방어가가 주문가로 오인됨' });
  }
  if (layer4Rebound !== null && layer2Stop !== null && layer4Rebound === layer2Stop) {
    violations.push({ ticker: ticker, type: 'INVALID_LAYER_VIOLATION',
      detail: 'LAYER_4(rebound=' + layer4Rebound + ')==LAYER_2(stop=' + layer2Stop + ') — 반등트리거가 손절가로 오인됨' });
  }
  if (layer5RefDef !== null && layer1 !== null) {
    var diffPct = Math.abs(layer5RefDef - layer1) / layer1 * 100;
    if (diffPct < 5) {
      violations.push({ ticker: ticker, type: 'LAYER_PROXIMITY_WARNING',
        detail: 'LAYER_5(ref=' + layer5RefDef + ')과 LAYER_1(order=' + layer1 + ') ' + diffPct.toFixed(1) + '% 근접 — 혼동 위험' });
    }
  }

  return {
    formula_id:             'PRICE_HIERARCHY_LOCK_V1',
    ticker:                 ticker,
    layer1_limit_price:     layer1,
    layer2_stop_price:      layer2Stop,
    layer2_tp1_price:       layer2Tp1,
    layer2_tp2_price:       layer2Tp2,
    layer3_auto_trailing:   layer3Trailing,
    layer4_rebound_trigger: layer4Rebound,
    layer5_reference_defense: layer5RefDef,
    final_stop_price:       finalStop,
    layer_violations:       violations,
    violation_count:        violations.filter(function(v) { return v.type === 'INVALID_LAYER_VIOLATION'; }).length
  };
}

/**
 * applyPriceHierarchyLockAll_
 * 전 종목 PHL-V1 일괄 실행 — hApex 내 모든 소스 참조
 */
function applyPriceHierarchyLockAll_(hApex) {
  var blueprints = (hApex && hApex.order_blueprint_json) || [];
  var pricesJson = (hApex && hApex.prices_json) || [];
  var ppJson     = (hApex && hApex.profit_preservation_json) || [];
  var scrsCombo  = ((hApex && hApex.scrs_v2_json) || {}).selected_combo || [];
  var propRef    = (hApex && hApex.proposal_reference_json) || [];

  var priceMap = {}; pricesJson.forEach(function(r) { priceMap[(r.ticker||r.ticker_code||'').toString()] = r; });
  var ppMap    = {}; ppJson.forEach(function(r) { ppMap[(r.ticker||r.ticker_code||'').toString()] = r; });
  var scrsMap  = {}; scrsCombo.forEach(function(r) { scrsMap[(r.ticker||'').toString()] = r; });
  var refMap   = {}; propRef.forEach(function(r) { refMap[(r.ticker||'').toString()] = r; });

  var tickers = {};
  blueprints.forEach(function(bp) { tickers[(bp.ticker||bp.ticker_code||'')] = 1; });

  return Object.keys(tickers).filter(Boolean).map(function(tk) {
    var bp = blueprints.find(function(r) { return (r.ticker||r.ticker_code||'').toString() === tk; }) || {};
    return calcPriceHierarchyLock_(tk, bp, priceMap[tk], ppMap[tk], scrsMap[tk], refMap[tk]);
  });
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P2-D: SELL_EXECUTION_QUALITY_GATE_V1 (SEQG-V1)
// 매도 실행 품질 채점 — 가격/수량/타이밍 3축 평가
// ═══════════════════════════════════════════════════════════════════════

/**
 * calcSellExecutionQualityGate_
 * 매도 주문의 실행 품질을 3축(가격/수량/타이밍)으로 채점.
 * - 가격축: limit_price vs stop_price 간격 충분성
 * - 수량축: 보유수량 대비 매도비율 적정성 (5~70% 범위)
 * - 타이밍축: PSR-V2 신호 없을 때 매도 = 불필요 매도 위험
 * @param {Array} blueprint  — order_blueprint_json (SPSV2 적용 후)
 * @param {Array} holdings
 * @param {Array} psrRows    — proactive_sell_radar_json
 * @return {Array} SEQG-V1 rows
 */
function calcSellExecutionQualityGate_(blueprint, holdings, psrRows) {
  var holdMap = {};
  (holdings || []).forEach(function(h) { holdMap[h.ticker] = h; });
  var psrMap = {};
  (psrRows || []).forEach(function(p) { psrMap[p.ticker] = p; });

  return (blueprint || []).filter(function(row) {
    return (row.order_type || '').toString().toUpperCase() === 'SELL'
        || (row.order_type || '').toString().toUpperCase() === 'STOP_LOSS';
  }).map(function(row) {
    var h        = holdMap[(row.ticker || '').toString()] || {};
    var psr      = psrMap[(row.ticker || '').toString()]  || {};
    var limitPx  = toNumber_(row.limit_price_krw)  || 0;
    var stopPx   = toNumber_(row.stop_price_krw)   || 0;
    var qty      = toNumber_(row.order_quantity)   || 0;
    var holdQty  = toNumber_(h.holdingQty)         || 1;

    // 가격축: stop과 limit 간격이 ATR20의 0.5배 이상
    var close = toNumber_(h.close) || limitPx || 1;
    var priceSpread = (limitPx > 0 && stopPx > 0) ? (limitPx - stopPx) / close * 100 : 0;
    var priceScore  = priceSpread >= 2.0 ? 100 : priceSpread >= 1.0 ? 70 : 40;

    // 수량축: 보유량 대비 5%~70%
    var sellRatio = holdQty > 0 ? qty / holdQty * 100 : 0;
    var qtyScore  = (sellRatio >= 5 && sellRatio <= 70) ? 100
                  : (sellRatio > 0 && sellRatio < 5)    ? 60
                  : sellRatio > 70                      ? 50 : 0;

    // 타이밍축: PSR-V2 CRITICAL/WARNING 있으면 타이밍 좋음
    var radarLevel = psr.radar_level || 'CLEAR';
    var timingScore = radarLevel === 'CRITICAL' ? 100
                    : radarLevel === 'WARNING'  ? 80
                    : radarLevel === 'WATCH'    ? 60 : 40;

    var totalScore = Math.round((priceScore + qtyScore + timingScore) / 3);
    var grade = totalScore >= 80 ? 'A' : totalScore >= 65 ? 'B' : totalScore >= 50 ? 'C' : 'D';

    return {
      ticker:           row.ticker,
      name:             row.name || (h.name || ''),
      order_type:       row.order_type,
      price_score:      priceScore,
      quantity_score:   qtyScore,
      timing_score:     timingScore,
      total_score:      totalScore,
      execution_grade:  grade,
      sell_ratio_pct:   Math.round(sellRatio * 10) / 10,
      price_spread_pct: Math.round(priceSpread * 10) / 10,
      radar_level_ref:  radarLevel,
      formula_id:       'SELL_EXECUTION_QUALITY_GATE_V1'
    };
  });
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P2-B: PROACTIVE_SELL_RADAR_V2 — 8신호 사전 분배 감지 (D-3일)
// 분배 전 3일 이내 조기 경보 → CRITICAL/WARNING/WATCH 단계 분류
// ═══════════════════════════════════════════════════════════════════════

/**
 * calcProactiveSellRadarV2_
 * 8가지 사전 분배 감지 신호: 고가 근접+수축, 기관 순매도 전환, 개인 집중유입,
 * 옵션 풋/콜 역전, 뉴스 감성 급락, 거래량 이상, RSI 다이버전스, 수익 보호 트리거.
 * @param {Array}  holdings
 * @param {Object} dfMap
 * @param {Object} profitPreservMap  ticker→profitPreservRow 맵
 * @return {Array} PSR-V2 rows
 */
function calcProactiveSellRadarV2_(holdings, dfMap, profitPreservMap) {
  var ppMap = profitPreservMap || {};
  return (holdings || []).map(function(h) {
    var df       = dfMap[h.ticker] || {};
    var close    = toNumber_(h.close || df.close) || 0;
    var high52w  = toNumber_(df.high52w || df['High52W']) || 0;
    var volume   = toNumber_(df.volume) || 0;
    var avgVol5d = toNumber_(df.avgVolume5d || df.avgVol5d) || 0;
    var rsi14    = toNumber_(df.rsi14 || df['RSI14']) || 50;
    var inst5d   = toNumber_(df.inst5d || df['Inst_5D']) || 0;
    var frg5d    = toNumber_(df.frg5d  || df['FRG_5D'])  || 0;
    var ret5d    = toNumber_(df.ret5d  || df['Ret5D'])   || 0;
    var sentScore = toNumber_(df.sentimentScore || df['Sentiment_Score']) || 0;
    var pp       = ppMap[h.ticker] || {};
    var autoTrail = toNumber_(pp.auto_trailing_stop) || 0;
    var holdQty   = toNumber_(h.holdingQty) || 0;

    var signals = [];

    // SIG_1: 고가 대비 2% 이내 + 거래량 30% 수축 (고점 분배 전형)
    var nearHigh = high52w > 0 && close > 0 && (high52w - close) / high52w <= 0.02;
    var volShrink = avgVol5d > 0 && volume > 0 && volume < avgVol5d * 0.7;
    if (nearHigh && volShrink) signals.push({ id: 'SIG_1_HIGH_SHRINK', weight: 2.0 });

    // SIG_2: 기관 5일 순매도 전환 (inst5d < -음수)
    if (inst5d < 0) signals.push({ id: 'SIG_2_INST_SELL', weight: 2.0 });

    // SIG_3: 개인 집중유입 비율 > 70% (설거지 전형)
    var retailRatio = toNumber_(df.retailBuyRatio5d || df['Retail_Buy_Ratio_5D']) || 0;
    if (retailRatio > 0.70) signals.push({ id: 'SIG_3_RETAIL_INFLOW', weight: 1.5 });

    // SIG_4: 옵션 풋/콜 비율 역전 (put_call_ratio > 1.3)
    var pcRatio = toNumber_(df.putCallRatio || df['Put_Call_Ratio']) || 0;
    if (pcRatio > 1.3) signals.push({ id: 'SIG_4_PUT_CALL_INVERT', weight: 1.5 });

    // SIG_5: 뉴스 감성 점수 급락 (sentiment < -20)
    if (sentScore < -20) signals.push({ id: 'SIG_5_SENTIMENT_DROP', weight: 1.0 });

    // SIG_6: 거래량 이상 급증 (vol > 1.5x 평균) + 음봉
    var open_ = toNumber_(df.open || df['Open']) || close;
    var volSpike = avgVol5d > 0 && volume > avgVol5d * 1.5;
    var bearCandle = close < open_ && close > 0;
    if (volSpike && bearCandle) signals.push({ id: 'SIG_6_VOL_SPIKE_BEAR', weight: 1.5 });

    // SIG_7: RSI 다이버전스 (rsi14 >= 70 + 5일 수익 감소)
    if (rsi14 >= 70 && ret5d < 0) signals.push({ id: 'SIG_7_RSI_DIVERGE', weight: 1.5 });

    // SIG_8: 수익 보호 트리거 근접 (close <= auto_trailing_stop * 1.02)
    if (autoTrail > 0 && close > 0 && close <= autoTrail * 1.02) {
      signals.push({ id: 'SIG_8_TRAIL_PROXIMITY', weight: 2.0 });
    }

    var weightedSum = signals.reduce(function(acc, s) { return acc + s.weight; }, 0);
    var radarLevel  = weightedSum >= 5.0 ? 'CRITICAL'
                    : weightedSum >= 3.0 ? 'WARNING'
                    : weightedSum >= 1.5 ? 'WATCH'
                    : 'CLEAR';

    return {
      ticker:         h.ticker,
      name:           h.name || df.name || '',
      radar_level:    radarLevel,
      weighted_sum:   Math.round(weightedSum * 10) / 10,
      signal_count:   signals.length,
      signals:        signals.map(function(s) { return s.id; }),
      rsi14:          round2_(rsi14),
      inst5d:         round2_(inst5d),
      retail_ratio:   retailRatio ? round2_(retailRatio) : null,
      auto_trail_ref: autoTrail || null,
      formula_id:     'PROACTIVE_SELL_RADAR_V2'
    };
  });
}


/**
 * L1: SECTOR_ROTATION_MOMENTUM_V1
 * 섹터 로테이션 모멘텀 추적 — rank_delta W1/W2 기반 RISING/STABLE/FADING/TOPPING_OUT 분류
 * 결과는 sector_rotation_momentum_json으로 buildHarnessRows_()에 전달된다.
 * calcAlphaLeadRow_()에서 FADING/TOPPING_OUT 페널티 적용.
 * @param {Object} sectorFlowData — readSectorFlowForRadar_() 반환값
 * @return {Array} sector_rotation_momentum_json rows
 */
function calcSectorRotationMomentum_(sectorFlowData) {
  var rows = [];
  var sectorNames = Object.keys(sectorFlowData || {});
  sectorNames.forEach(function(sName) {
    var sf = sectorFlowData[sName];
    if (!sf || !Number.isFinite(sf.rank)) return;
    var rankDeltaW1 = Number.isFinite(sf.prevRank)   ? sf.rank - sf.prevRank   : null;
    var rankDeltaW2 = Number.isFinite(sf.prevRankW2) ? sf.rank - sf.prevRankW2 : null;

    var momentumState = 'STABLE';
    if (rankDeltaW1 !== null && rankDeltaW2 !== null) {
      if (rankDeltaW1 >= 2 && rankDeltaW2 >= 2) {
        // 1주일 및 2주일 연속 순위 하락 → 추세 약화
        momentumState = 'FADING';
      } else if (sf.rank <= 3 && rankDeltaW1 >= 1) {
        // 상위권이지만 이미 하락 전환 → 고점 신호
        momentumState = 'TOPPING_OUT';
      } else if (rankDeltaW1 <= -2) {
        // 순위 상승 → 로테이션 유입
        momentumState = 'RISING';
      }
    } else if (rankDeltaW1 !== null) {
      if (rankDeltaW1 >= 3)        momentumState = 'FADING';
      else if (rankDeltaW1 <= -2)  momentumState = 'RISING';
    }

    rows.push({
      sector:          sName,
      rank:            sf.rank,
      prev_rank_w1:    Number.isFinite(sf.prevRank)   ? sf.prevRank   : null,
      prev_rank_w2:    Number.isFinite(sf.prevRankW2) ? sf.prevRankW2 : null,
      rank_delta_w1:   rankDeltaW1,
      rank_delta_w2:   rankDeltaW2,
      momentum_state:  momentumState,
      formula_id:      'SECTOR_ROTATION_MOMENTUM_V1'
    });
  });
  // 현재 순위 오름차순 정렬
  rows.sort(function(a, b) { return a.rank - b.rank; });
  return rows;
}

/**
 * calcAlphaShield_
 * 보유 종목별 Alpha-Shield 지표 계산:
 *   X1  deviation_ratio → MRG001 BUY_HARD_BLOCK  (이격 차단)
 *   X3  rs_ratio        → RS001 RS_LAGGARD       (상대강도 부진)
 *   W1  수급 다이버전스   → DIVERGENCE_ALERT
 *   W2  오버행 압력       → OVERHANG_WARNING
 *   W3  섹터 로테이션 이탈 → ROTATION_WARNING
 *   W4  수급 감속         → FLOW_DECEL_WARNING
 * critical_alert: 2개 이상 레이더 동시 발화 시 전면 재검토 강제
 */
function calcAlphaShield_(holdings, dfMap, kospiRet5d, sectorFlowData) {
  var perHolding     = [];
  var criticalAlerts = 0;

  holdings.forEach(function(h) {
    var df    = dfMap[h.ticker] || {};
    var close = df.close || 0;
    var ma20  = df.ma20  || 0;

    // ── X1: MEAN_REVERSION_GATE_V1 ─────────────────────────────────────────
    var deviationRatio = (close > 0 && ma20 > 0) ? round2_(close / ma20) : null;
    var mrgGate = deviationRatio === null ? 'INSUFFICIENT_DATA'
      : deviationRatio >= 1.15               ? 'BUY_HARD_BLOCK'
      : 'PASS';

    // ── X3: RS_RATIO_V1 ────────────────────────────────────────────────────
    var stockRet5d = df.ret5d;  // null = 컬럼 없음, 0 = 실제 0%
    var rsRatio = (stockRet5d !== null && typeof kospiRet5d === 'number' && kospiRet5d !== 0)
      ? round2_(stockRet5d / kospiRet5d) : null;
    var rsStatus = rsRatio === null ? 'INSUFFICIENT_DATA'
      : rsRatio < 0.80               ? 'RS_LAGGARD' : 'RS_OK';

    // ── 공통 수급 데이터 ──────────────────────────────────────────────────
    var frg5d       = df.frg5d;       // null = 컬럼 없음
    var inst5d      = df.inst5d;
    var frg20d      = df.frg20d;
    var volume      = df.volume;
    var avgVol5d    = df.avgVolume5d;
    var ma20Slope   = typeof df.ma20Slope === 'number' ? df.ma20Slope : null;

    var priceAboveMa20    = close > 0 && ma20 > 0 && close > ma20;
    var ma20SlopePositive = ma20Slope !== null && ma20Slope > 0;
    var frgNetNeg         = frg5d !== null && inst5d !== null && frg5d < 0 && inst5d < 0;
    var volRatio          = (volume !== null && avgVol5d !== null && avgVol5d > 0)
                              ? round2_(volume / avgVol5d) : null;

    // ── W1: DIVERGENCE_SCORE_V1 ────────────────────────────────────────────
    var w1Status = 'INSUFFICIENT_DATA';
    if (frg5d !== null && inst5d !== null && ma20Slope !== null
        && volume !== null && avgVol5d !== null) {
      w1Status = (priceAboveMa20 && !ma20SlopePositive && frgNetNeg
                  && volRatio !== null && volRatio <= 0.80)
        ? 'DIVERGENCE_ALERT' : 'CLEAR';
    }

    // ── W2: OVERHANG_PRESSURE_V1 ───────────────────────────────────────────
    var w2Status            = 'INSUFFICIENT_DATA';
    var overhangPressure    = null;
    if (frg20d !== null && avgVol5d !== null && avgVol5d > 0) {
      overhangPressure = round2_(Math.abs(frg20d) / (avgVol5d * 20));
      w2Status = (frg20d < 0 && overhangPressure > 0.30) ? 'OVERHANG_WARNING' : 'CLEAR';
    }

    // ── W3: SECTOR_ROTATION_RADAR_V1 ──────────────────────────────────────
    var w3Status      = 'INSUFFICIENT_DATA';
    var sectorName    = TICKER_SECTOR_MAP[h.ticker] || null;
    var sfRow         = sectorName ? (sectorFlowData[sectorName] || null) : null;
    var sectorRank    = null;
    var sectorPrvRank = null;
    if (sfRow) {
      sectorRank    = sfRow.rank;
      sectorPrvRank = sfRow.prevRank;
      if (Number.isFinite(sfRow.rank) && Number.isFinite(sfRow.prevRank)) {
        var dropW1 = sfRow.rank - sfRow.prevRank;
        var dropW2 = Number.isFinite(sfRow.prevRankW2)
          ? sfRow.rank - sfRow.prevRankW2 : dropW1;
        w3Status = (dropW1 >= 3 && dropW2 >= 3) ? 'ROTATION_WARNING' : 'CLEAR';
      }
    }

    // ── W4: FLOW_ACCELERATION_V1 ───────────────────────────────────────────
    var w4Status      = 'INSUFFICIENT_DATA';
    var flowAccelRatio = null;
    if (frg5d !== null && frg20d !== null) {
      var buyEnergy20dAvg = frg20d / 4;
      if (buyEnergy20dAvg > 0) {
        flowAccelRatio = round2_(frg5d / buyEnergy20dAvg);
        w4Status = (priceAboveMa20 && frg5d > 0 && flowAccelRatio < 0.50)
          ? 'FLOW_DECEL_WARNING' : 'CLEAR';
      } else {
        w4Status = 'CLEAR';
      }
    }

    // ── 발화 집계 ────────────────────────────────────────────────────────
    var ALERT_STATUSES = ['DIVERGENCE_ALERT','OVERHANG_WARNING','ROTATION_WARNING','FLOW_DECEL_WARNING'];
    var fires = [w1Status, w2Status, w3Status, w4Status].filter(function(s) {
      return ALERT_STATUSES.indexOf(s) >= 0;
    }).length;
    if (fires >= 2) criticalAlerts++;

    perHolding.push({
      ticker:          h.ticker,
      name:            h.name || '',
      weight_pct:      h.weightPct || 0,
      // X1 MRG001
      deviation_ratio: deviationRatio,
      mrg_gate:        mrgGate,
      // X3 RS001
      stock_ret5d:     stockRet5d,
      kospi_ret5d:     typeof kospiRet5d === 'number' ? kospiRet5d : null,
      rs_ratio:        rsRatio,
      rs_status:       rsStatus,
      // W1
      volume_ratio:    volRatio,
      w1_status:       w1Status,
      // W2
      overhang_pressure: overhangPressure,
      w2_status:       w2Status,
      // W3
      sector:          sectorName,
      sector_rank:     sectorRank,
      sector_prev_rank: sectorPrvRank,
      w3_status:       w3Status,
      // W4
      flow_accel_ratio: flowAccelRatio,
      w4_status:       w4Status,
      // 종합
      radar_fires:     fires,
      critical_alert:  fires >= 2 ? 'CRITICAL_ALERT' : 'OK'
    });
  });

  return {
    per_holding:          perHolding,
    critical_alert_count: criticalAlerts,
    lock:                 true
  };
}

// ── APEX V1: 판단 자료 생성 시점 하네스 ─────────────────────────────────────

/**
 * calcRegimeAdjustedSellPriority_  [K3: 국면·섹터 연계 H2 동적 우선순위]
 * 시장 국면(regime)에 따라 H2 매도후보의 매도 우선순위를 동적으로 조정한다.
 * H2 원래 순위(rank)는 변경하지 않고 regime_priority_adjustment(음수=우선↑)와
 * final_regime_rank을 추가로 부여한다.
 * LLM은 regime_adjusted_sell_priority_json을 H2보다 우선 참조하되,
 * sell_priority_lock=true 이므로 최종 순위를 임의 재해석할 수 없다.
 */
function calcRegimeAdjustedSellPriority_(h2Candidates, regime, dfMap, kospiRet5d) {
  var result = [];
  h2Candidates.forEach(function(cand) {
    var candScore = (typeof cand.sell_priority_score === 'number') ? cand.sell_priority_score : cand.score;
    if (typeof candScore !== 'number' || !Number.isFinite(candScore)) {
      throw new Error('SELL_PRIORITY_SCHEMA_INVALID: missing score field for ticker=' + cand.ticker);
    }
    var df = dfMap[cand.ticker] || {};
    var adj = 0;
    var reason = 'NO_REGIME_ADJ';

    if (regime === 'RISK_OFF' || regime === 'EVENT_SHOCK') {
      // 추세 붕괴/충격 국면: KOSPI 대비 고베타(많이 떨어지는) 종목 우선 매도
      var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null;
      var kRet5d = typeof kospiRet5d === 'number' ? kospiRet5d : null;
      if (ret5d !== null && kRet5d !== null && kRet5d < -1) {
        var betaProxy = ret5d / kRet5d;
        if (Number.isFinite(betaProxy) && betaProxy > 1.3) {
          adj = -3; reason = 'high_beta_breakdown_sell_first';
        } else if (Number.isFinite(betaProxy) && betaProxy > 1.0) {
          adj = -1; reason = 'above_beta_breakdown';
        }
      }
      // 수급 동반 이탈 종목 우선
      if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) {
        adj = Math.min(adj, -2); reason = reason === 'NO_REGIME_ADJ' ? 'dual_outflow_breakdown' : reason;
      }
    } else if (regime === 'RISK_OFF_CANDIDATE') {
      // 분배장 경고: 수급 약하고 flow_credit 낮은 종목 우선
      var fcOk = typeof df.flowCredit === 'number';
      if (fcOk && df.flowCredit < 0.30) { adj = -2; reason = 'low_flow_credit_distribution'; }
      else if (fcOk && df.flowCredit < 0.45) { adj = -1; reason = 'moderate_low_flow_distribution'; }
    } else if (regime === 'RISK_ON' || regime === 'SECULAR_LEADER_RISK_ON') {
      // 상승기: 섹터 대비 상대적 약자 우선 정리 (리더 보호)
      var sRet = typeof df.ret5d === 'number' ? df.ret5d : null;
      var kRet = typeof kospiRet5d === 'number' ? kospiRet5d : null;
      if (sRet !== null && kRet !== null && sRet < kRet - 3) {
        adj = -2; reason = 'sector_lag_in_risk_on_trim';
      }
      // 중복 ETF는 상승기에도 먼저 정리
      if (df.isDuplicateEtf) { adj = Math.min(adj, -2); reason = 'duplicate_etf_in_risk_on'; }
    } else if (regime === 'LEADER_CONCENTRATION' || regime === 'NEUTRAL') {
      // 조정기: AC(안티클라이막스) 발동 종목 우선
      if (df.acGate && String(df.acGate).toUpperCase().indexOf('CLIMAX') >= 0) {
        adj = -1; reason = 'anti_climax_in_pullback';
      }
    }

    result.push({
      rank: cand.rank,
      ticker: cand.ticker,
      name: cand.name,
      tier: cand.tier,
      original_score: candScore,
      trim_style: cand.trim_style || '',
      regime_priority_adjustment: adj,
      adjusted_sort_key: cand.tier * 100 + (cand.rank + adj),
      adjustment_reason: reason,
      regime_applied: regime
    });
  });

  result.sort(function(a, b) { return a.adjusted_sort_key - b.adjusted_sort_key; });
  result.forEach(function(r, i) { r.final_regime_rank = i + 1; });
  return result;
}

function findCandidateByTicker_(candidates, ticker) {
  for (var i = 0; i < (candidates || []).length; i++) {
    if (candidates[i].ticker === ticker) return candidates[i];
  }
  return null;
}

function findOrderBlueprintRow_(orders, ticker) {
  for (var i = 0; i < (orders || []).length; i++) {
    if (orders[i].ticker === ticker) return orders[i];
  }
  return null;
}

function calcDistributionRiskRow_(h, df, kospiRet5d, sectorFlowData) {
  // [2026-06-22 정정] 이전 주석("THIN_ADAPTER: delegated to Python —
  // inject_computed_harness.py:calc_distribution_detector_per_ticker")은 틀린 주석이었다.
  // 이 함수(formula_id=DISTRIBUTION_RISK_SCORE_V1, spec/13b_harness_formulas.yaml:365,
  // BUY/STAGED_BUY/ADD_ON 절대 차단 게이트)와 Python calc_distribution_detector_per_ticker
  // (formula_id=DISTRIBUTION_SELL_DETECTOR_V1, spec/13_formula_registry.yaml:2758,
  // PRE_DISTRIBUTION_EARLY_WARNING 2신호의 정밀도 보완용 6신호 감지기)는 서로 다른
  // 입력·출력·목적을 가진 독립 공식이다 — 하나가 다른 하나의 GAS 중복이 아니다.
  // 둘 다 유지하며 역할을 분리한다(governance/gas_logic_migration_ledger_v1.yaml F12/F13,
  // 사용자 결정 2026-06-22). 이 함수를 삭제하지 말 것.
  var close = df.close || h.close || 0;
  var ma20 = df.ma20 || 0;
  var high = df.high || close;
  var low = df.low || close;
  var volume = df.volume;
  var avgVol5d = df.avgVolume5d;
  var flowCredit = typeof df.flowCredit === 'number' ? df.flowCredit : null;
  var priceAboveMa20 = close > 0 && ma20 > 0 && close > ma20;
  var score = 0;
  var reasons = [];

  if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) {
    score += 30; reasons.push('smart_money_outflow');
  }
  if (volume !== null && avgVol5d !== null && avgVol5d > 0 && volume < avgVol5d * 0.80) {
    score += 20; reasons.push('volume_fade_after_surge');
  }
  if (high > low && close > 0) {
    var upperWickRatio = (high - close) / Math.max(high - low, 1);
    if (upperWickRatio >= 0.45 && priceAboveMa20) {
      score += 15; reasons.push('upper_wick_distribution');
    }
  }
  if (flowCredit !== null && flowCredit < 0.40) {
    score += 20; reasons.push('flow_credit_low');
  }
  if (typeof df.ret5d === 'number' && typeof kospiRet5d === 'number' && df.ret5d < kospiRet5d - 3) {
    score += 15; reasons.push('sector_relative_lag');
  }
  // J2: Anti-Climax Gate — 가격은 유지되나 수급 에너지 고갈 신호 (acGate / acTotal)
  if (df.acGate && String(df.acGate).toUpperCase().indexOf('CLIMAX') >= 0) {
    score += 15; reasons.push('anti_climax_gate');
  }
  if (typeof df.acTotal === 'number' && df.acTotal >= 2) {
    score += 10; reasons.push('ac_total_gte2');
  }
  // J2: 거래량 상승 국면에서 상승폭 축소 (가격 상승 + 거래량 급증 + 수익 미실현 구간)
  if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 40 && priceAboveMa20
      && (flowCredit === null || flowCredit < 0.50)) {
    score += 10; reasons.push('val_surge_no_flow_support');
  }

  // L4: PRE_DISTRIBUTION_EARLY_WARNING_V1
  // Signal 1: 신고점 근접 + 거래량 수축 — 분배 직전 전형적 패턴
  var high52w = typeof df.high52w === 'number' && df.high52w > 0 ? df.high52w : null;
  var nearNewHigh = (high52w !== null && close > 0 && close >= high52w * 0.97)
    || (ma20 > 0 && close > ma20 * 1.15);  // 52W high 미제공 시 MA20 +15% 이상 연장으로 대체
  if (nearNewHigh && volume !== null && avgVol5d !== null && avgVol5d > 0
      && volume < avgVol5d * 0.80) {
    score += 12; reasons.push('new_high_volume_contraction');
  }
  // Signal 2: 최근 급등 후 수급 약화 — 5일 +5% 이상 상승했으나 flow credit 저조
  if (typeof df.ret5d === 'number' && df.ret5d >= 5
      && flowCredit !== null && flowCredit < 0.45) {
    score += 10; reasons.push('surge_weak_flow');
  }

  var state = score >= 70 ? 'BLOCK_BUY' : score >= 55 ? 'TRIM_REVIEW' : 'PASS';
  var preDistWarning = (reasons.indexOf('new_high_volume_contraction') >= 0
    || reasons.indexOf('surge_weak_flow') >= 0) ? 'EARLY_WARNING' : 'NONE';
  return {
    ticker: h.ticker,
    name: h.name || df.name || '',
      ["distribution_risk_score"]: Math.min(100, Math.max(0, score)),
    anti_distribution_state: state,
    pre_distribution_warning: preDistWarning,
    reason_codes: reasons,
    formula_id: 'DISTRIBUTION_RISK_SCORE_V1'
  };
}

function calcAlphaLeadRow_(h, df, sectorFlowData, distributionRow) {
  var close = df.close || h.close || 0;
  var ma20 = df.ma20 || 0;
  var closeVsMa20Pct = (close > 0 && ma20 > 0) ? (close / ma20 - 1) * 100 : null;
  var sectorName = TICKER_SECTOR_MAP[h.ticker] || null;
  var sf = sectorName ? sectorFlowData[sectorName] : null;
  var score = 0;
  var lateChaseRisk = 0;
  var reasons = [];

  if (sf && Number.isFinite(sf.rank) && sf.rank <= 2) { score += 20; reasons.push('sector_rank_top2'); }

  // L1: SECTOR_ROTATION_MOMENTUM_V1 — 로테이션 모멘텀 패널티
  if (sf && Number.isFinite(sf.rank) && Number.isFinite(sf.prevRank)) {
    var rdW1 = sf.rank - sf.prevRank;
    var rdW2 = Number.isFinite(sf.prevRankW2) ? sf.rank - sf.prevRankW2 : rdW1;
    if (rdW1 >= 2 && rdW2 >= 2) {
      score -= 15; reasons.push('sector_fading');
    } else if (sf.rank <= 3 && rdW1 >= 1) {
      score -= 10; reasons.push('sector_topping_out');
    }
  }

  if (typeof df.ret5d === 'number' && df.ret5d > 0) { score += 10; reasons.push('ret5d_positive'); }
  if (df.frg5d !== null && df.inst5d !== null && (df.frg5d + df.inst5d) > 0) { score += 25; reasons.push('smart_money_inflow'); }
  if (typeof df.leaderTotal === 'number') { score += Math.min(20, df.leaderTotal * 5); reasons.push('leader_scan'); }
  if (typeof df.avgTradeVal5d === 'number' && df.avgTradeVal5d >= 50) { score += 10; reasons.push('liquidity_ok'); }
  if (closeVsMa20Pct !== null && closeVsMa20Pct >= 0 && closeVsMa20Pct <= 6) { score += 15; reasons.push('ma20_controlled_extension'); }

  var lateChase = closeVsMa20Pct !== null && closeVsMa20Pct > 10;
  if (closeVsMa20Pct !== null) {
    if (closeVsMa20Pct > 10) lateChaseRisk += 60;
    else if (closeVsMa20Pct > 6) lateChaseRisk += 25;
    else if (closeVsMa20Pct > 3) lateChaseRisk += 10;
  }
  if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 60) {
    lateChase = true;
    lateChaseRisk += 25;
    reasons.push('value_surge_extreme');
  } else if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 35) {
    lateChaseRisk += 10;
  }
  if (distributionRow && distributionRow.anti_distribution_state === 'BLOCK_BUY') {
    lateChase = true;
    lateChaseRisk += 40;
    reasons.push('distribution_block');
  }
  if (typeof df.dartRiskStatus === 'string' && df.dartRiskStatus !== 'OK') {
    lateChase = true;
    lateChaseRisk += 30;
    reasons.push('dart_risk');
  }

  // N2: VOLUME_BREAKOUT_CONFIRM_V1 — 신고가 부근 거래량 미확인 시 뒷박 차단
  var n2High52w = typeof df.high52w === 'number' && df.high52w > 0 ? df.high52w : 0;
  var n2Vol = typeof df.volume === 'number' ? df.volume : 0;
  var n2AvgVol5d = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : 0;
  if (n2High52w > 0 && close > 0 && close >= n2High52w * 0.97) {
    if (n2AvgVol5d > 0 && n2Vol < n2AvgVol5d * 1.2) {
      score -= 10;
      lateChaseRisk += 15;
      reasons.push('unconfirmed_breakout_volume');
    }
  }

  var state = lateChase ? 'BLOCKED_LATE_CHASE'
    : score >= 75 ? 'PILOT_ALLOWED'
    : score >= 55 ? 'WATCH_ONLY'
    : 'WATCH_ONLY';
  var buyState = state === 'PILOT_ALLOWED' ? 'ALLOW_PILOT' : (state === 'BLOCKED_LATE_CHASE' ? 'BLOCKED' : 'WATCH');
  return {
    ticker: h.ticker,
    name: h.name || df.name || '',
    alpha_lead_score: Math.min(100, Math.max(0, Math.round(score))),
    lead_entry_state: state,
    allowed_tranche_pct: state === 'PILOT_ALLOWED' ? 30 : 0,
    buy_permission_state: buyState,
    close_vs_ma20_pct: closeVsMa20Pct === null ? null : round2_(closeVsMa20Pct),
      ["late_chase_risk_score"]: Math.min(100, Math.max(0, Math.round(lateChaseRisk))),
    blocked_reason_codes: lateChase ? ['late_chase_or_distribution'] : [],
    reason_codes: reasons,
    formula_id: 'ALPHA_LEAD_SCORE_V1'
  };
}

function calcFollowThroughRow_(h, df) {
  var close = df.close || h.close || 0;
  var prevClose = df.prevClose || 0;
  var ma5Proxy = prevClose || close;
  var state = 'WAIT_PULLBACK';
  var score = 25;
  var reasons = [];
  if (close > 0 && df.ma20 > 0 && close < df.ma20) {
    state = 'FAILED_BREAKOUT'; reasons.push('close_below_ma20'); score = 0;
  } else if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) {
    state = 'FAILED_BREAKOUT'; reasons.push('dual_outflow'); score = 0;
  } else if (close > 0 && ma5Proxy > 0 && close >= ma5Proxy && df.frg5d !== null && df.frg5d > 0) {
    state = 'CONFIRMED_ADD_ON'; reasons.push('price_hold_and_foreign_inflow'); score = 100;
  } else if (close > 0 && ma5Proxy > 0 && close >= ma5Proxy) {
    score = 60;
  }
  return {
    ticker: h.ticker,
    name: h.name || df.name || '',
    follow_through_state: state,
    follow_through_score: score,
    reason_codes: reasons,
    formula_id: 'FOLLOW_THROUGH_CONFIRM_V1'
  };
}



// --- Source: src/gas_adapter_parts/gdf_04_execution_quality.gs ---
function calcProfitPreservationRow_(h, df, priceRow, distributionRow) {
  // THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/inject_computed_harness.py:trailing_stop_v2
  var close = df.close || h.close || 0;
  var avgCost = h.avgCost || 0;
  var profitPct = close > 0 && avgCost > 0 ? (close - avgCost) / avgCost * 100 : 0;
  var state = 'NORMAL';
  var preserveScore = 100;
  if (profitPct >= 30) state = 'PROFIT_LOCK_30';
  else if (profitPct >= 20) state = 'PROFIT_LOCK_20';
  else if (profitPct >= 10) state = 'PROFIT_LOCK_10';
  else if (profitPct >= 8 || (df.atr20 > 0 && close >= avgCost + df.atr20)) state = 'BREAKEVEN_RATCHET';
  if (state === 'PROFIT_LOCK_30') preserveScore = 20;
  else if (state === 'PROFIT_LOCK_20') preserveScore = 40;
  else if (state === 'PROFIT_LOCK_10') preserveScore = 60;
  else if (state === 'BREAKEVEN_RATCHET') preserveScore = 80;
  if (state === 'PROFIT_LOCK_30' && distributionRow && distributionRow.anti_distribution_state === 'PASS') {
    state = 'APEX_TRAILING';
  }
  if (distributionRow && distributionRow.anti_distribution_state === 'BLOCK_BUY') {
    preserveScore = Math.max(0, preserveScore - 15);
  }

  // L2: RATCHET_TRAILING_AUTO_V1 — ATR 기반 자동 트레일링 손절 계산
  var atr20 = typeof df.atr20 === 'number' && df.atr20 > 0 ? df.atr20 : 0;
  var ratchetStop = priceRow && typeof priceRow.stop_price === 'number' ? priceRow.stop_price : 0;
  var highestClose = priceRow && typeof priceRow.highest_price_since_entry === 'number'
    ? priceRow.highest_price_since_entry : close;
  var autoTrailingStop = null;
  var autoTrailingNote = null;
  if (atr20 > 0 && (state === 'PROFIT_LOCK_30' || state === 'APEX_TRAILING')) {
    var raw = Math.max(ratchetStop, highestClose - 2.0 * atr20);
    autoTrailingStop = tickNormalize_(raw);
    autoTrailingNote = 'max(ratchet,' + highestClose + '-2.0×ATR)';
  } else if (atr20 > 0 && state === 'PROFIT_LOCK_20') {
    var raw = Math.max(ratchetStop, highestClose - 1.5 * atr20);
    autoTrailingStop = tickNormalize_(raw);
    autoTrailingNote = 'max(ratchet,' + highestClose + '-1.5×ATR)';
  }

  return {
    ticker: h.ticker,
    name: h.name || df.name || '',
    profit_pct: round2_(profitPct),
    profit_preservation_state: state,
    rebound_preservation_score: Math.min(100, Math.max(0, Math.round(preserveScore))),
    protected_stop_price: priceRow ? priceRow.stop_price : null,
    ratchet_partial_qty: priceRow ? priceRow.ratchet_partial_qty : 0,
    auto_trailing_stop: autoTrailingStop,
    auto_trailing_note: autoTrailingNote,
    formula_id: 'PROFIT_PRESERVATION_STATE_V1'
  };
}

function calcExecutionQualityRow_(ticker, orderRow, df) {
  var amount = orderRow && orderRow.order_amount_krw ? orderRow.order_amount_krw : 0;
  var advKrw = 0;
  if (typeof df.avgTradeVal5d === 'number') {
    // AvgTradeValue_5D_M is usually million KRW in sheet label.
    advKrw = df.avgTradeVal5d * 1000000;
  }
  var status = 'PASS';
  var splitCount = 1;
  var reasons = [];
  if (amount > 0 && advKrw > 0 && amount > advKrw * 0.03) {
    status = 'BLOCKED_ADV_3PCT'; reasons.push('order_amount_gt_3pct_adv');
  } else if (amount > 0 && advKrw > 0 && amount > advKrw * 0.01) {
    status = 'SPLIT_REQUIRED'; splitCount = 2; reasons.push('order_amount_gt_1pct_adv');
  }
  if (df.spreadStatus && String(df.spreadStatus).indexOf('WIDE') >= 0) {
    status = 'BLOCKED_SPREAD'; reasons.push('wide_spread');
  }
  return {
    ticker: ticker,
    execution_quality_status: status,
    split_count: splitCount,
    child_order_amount_krw: splitCount > 1 ? Math.round(amount / splitCount) : amount,
    hts_allowed: status === 'PASS',
    reason_codes: reasons,
    formula_id: 'EXECUTION_QUALITY_GUARD_V1'
  };
}

// ── [2026-05-20_HARNESS_V5] H6: 뒷박 차단 — BREAKOUT_QUALITY_GATE_V2 ─────────
function calcBreakoutQualityGate_(h, df, alphaRow, distRow) {
  var close    = df.close || h.close || 0;
  var prevClose = df.prevClose || close;
  var ma20     = df.ma20 || 0;
  var rsi14    = typeof df.rsi14 === 'number' ? df.rsi14 : null;
  var volume   = typeof df.volume === 'number' ? df.volume : null;
  var avgVol5d = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : null;

  var ret1d    = (close > 0 && prevClose > 0) ? (close - prevClose) / prevClose * 100 : null;
  var ret3d    = typeof df.ret5d === 'number'  ? df.ret5d * 0.6 : null; // ret5d 프록시
  var disparity = (close > 0 && ma20 > 0) ? (close / ma20 - 1) * 100 : null;

  var timingScoreExit       = alphaRow && typeof alphaRow.timing_score_exit === 'number'      ? alphaRow.timing_score_exit      : 0;
  var distributionRiskScore = distRow  && typeof distRow["distribution_risk_score"] === 'number' ? distRow["distribution_risk_score"] : 0;
  var lateChaseRiskScore    = alphaRow && typeof alphaRow["late_chase_risk_score"] === 'number'   ? alphaRow["late_chase_risk_score"]  : 0;

  var score = 50;
  var reasons = [];

  if (ret3d !== null && ret3d >= 7)                               { score -= 30; reasons.push('ret3d_gte7'); }
  if (disparity !== null && disparity > 10)                       { score -= 25; reasons.push('disparity_gt10'); }
  if (ret1d !== null && ret1d >= 4 && volume !== null && avgVol5d !== null
      && avgVol5d > 0 && volume < avgVol5d * 0.9)                 { score -= 40; reasons.push('surge_day_low_vol'); }
  if (rsi14 !== null && rsi14 > 75)                               { score -= 20; reasons.push('rsi14_gt75'); }
  if (timingScoreExit >= 50)                                      { score -= 50; reasons.push('timing_exit_gte50'); }
  if (distributionRiskScore >= 70)                                { score -= 35; reasons.push('distribution_gte70'); }
  if (lateChaseRiskScore >= 70)                                   { score -= 30; reasons.push('late_chase_gte70'); }

  if (volume !== null && avgVol5d !== null && avgVol5d > 0
      && volume >= avgVol5d * 1.5 && ret1d !== null && ret1d >= 2
      && ret3d !== null && ret3d < 5)                             { score += 25; reasons.push('quality_breakout_vol'); }
  if (disparity !== null && disparity >= 0 && disparity < 6)     { score += 15; reasons.push('disparity_healthy'); }
  if (rsi14 !== null && rsi14 >= 45 && rsi14 <= 65)              { score += 10; reasons.push('rsi14_healthy'); }

  score = Math.max(0, Math.min(100, Math.round(score)));
  var gate = score < 10 ? 'BLOCKED_LATE_CHASE' : score < 40 ? 'WATCH_COOLING_OFF' : 'PILOT_ALLOWED';

  return {
    ticker:                 h.ticker,
    name:                   h.name || df.name || '',
    breakout_quality_score: score,
    breakout_quality_gate:  gate,
    reason_codes:           reasons,
    formula_id:             'BREAKOUT_QUALITY_GATE_V2',
    version:                '2026-05-20_HARNESS_V5'
  };
}

// ── [2026-05-20_HARNESS_V5] H7: 가짜 매도 차단 — ANTI_WHIPSAW_HOLD_GATE_V1 ───
function calcAntiWhipsawGate_(h, df, kospiRet5d) {
  var inst5d   = typeof df.inst5d === 'number' ? df.inst5d : null;
  var frg5d    = typeof df.frg5d  === 'number' ? df.frg5d  : null;
  var volSurge = typeof df.valSurgePct === 'number' ? df.valSurgePct : null;
  var consecutiveSell5d = typeof df.consecutiveSellSignals5d === 'number'
    ? df.consecutiveSellSignals5d : 0;

  var sectorRS5d = null;
  if (typeof df.ret5d === 'number' && typeof kospiRet5d === 'number') {
    var stockFactor = 1 + df.ret5d / 100;
    var kospiFactor = 1 + kospiRet5d / 100;
    sectorRS5d = kospiFactor > 0 ? stockFactor / kospiFactor * 100 : null;
  }

  var score = 0;
  var reasons = [];

  if (consecutiveSell5d >= 5)                        { score += 20; reasons.push('consecutive_sell_5d_gte5'); }
  if (inst5d !== null && inst5d > 0)                 { score += 30; reasons.push('inst_net_buy'); }
  if (frg5d  !== null && frg5d  > 0)                 { score += 20; reasons.push('frg_net_buy'); }
  if (sectorRS5d !== null && sectorRS5d > 100)       { score += 15; reasons.push('sector_outperforming'); }
  if (volSurge !== null && volSurge >= 50)           { score -= 25; reasons.push('vol_surge_50pct'); }
  if (volSurge !== null && volSurge >= 100)          { score -= 20; reasons.push('vol_surge_100pct'); }

  score = Math.max(-50, Math.min(100, Math.round(score)));

  // [V1.1] 자동 해제 조건 3개 — 충족 수에 따라 hold_days 결정
  var wClose = h.close || df.close || 0;
  var wMa20  = typeof df.ma20 === 'number' ? df.ma20 : 0;
  var clearCnt  = 0;
  var clearList = [];
  if (inst5d !== null && inst5d > 0)              { clearCnt++; clearList.push('inst_net_buy'); }
  if (frg5d  !== null && frg5d  > 0)              { clearCnt++; clearList.push('frg_net_buy'); }
  if (wMa20 > 0 && wClose > 0 && wClose > wMa20) { clearCnt++; clearList.push('price_above_ma20'); }

  var gate, holdDays;
  if (score >= 30) {
    if      (clearCnt >= 3) { gate = 'WHIPSAW_AUTO_RELEASED'; holdDays = 0; }
    else if (clearCnt >= 2) { gate = 'WHIPSAW_WEAKENING';     holdDays = 1; }
    else                     { gate = 'WHIPSAW_CONFIRMED';     holdDays = 3; }
  } else if (score >= 10) {
    gate = 'INCONCLUSIVE'; holdDays = 0;
  } else {
    gate = 'CONFIRMED_SELL'; holdDays = 0;
  }

  return {
    ticker:                   h.ticker,
    name:                     h.name || df.name || '',
    anti_whipsaw_score:       score,
    anti_whipsaw_gate:        gate,
    anti_whipsaw_hold_days:   holdDays,
    clear_conditions_count:   clearCnt,
    clear_conditions:         clearList,
    reason_codes:             reasons,
    formula_id:               'ANTI_WHIPSAW_HOLD_GATE_V1',
    version:                  '2026-05-24_V1.1'
  };
}

// ── [2026-05-20_HARNESS_V5] H8: 4경로 결정론적 현금확보 라우터 ─────────────────
function calcSmartCashRaiseV2_(h, df, profitRow, priceRow, cashShortfallInfo) {
  // THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/inject_computed_harness.py:cash_recovery
  var posClass       = String(h.positionClass || df.positionClass || '').toUpperCase();
  var rsi14          = typeof df.rsi14 === 'number' ? df.rsi14 : 50;
  var profitStage    = priceRow && priceRow.profit_lock_stage
    ? String(priceRow.profit_lock_stage)
    : (profitRow ? String(profitRow.profit_preservation_state || 'NORMAL') : 'NORMAL');
  var secularPass    = priceRow  && priceRow.secular_leader_gate_active === false; // PASS = not active restriction
  var emergencyFull  = !!(cashShortfallInfo && cashShortfallInfo.emergency_full_sell);
  var stopPrice      = priceRow  && typeof priceRow.stop_price === 'number' ? priceRow.stop_price : 0;
  var close          = df.close || h.close || 0;
  var breachImmediate = stopPrice > 0 && close > 0 && close < stopPrice;
  var stopBreachGate = breachImmediate ? 'BREACH' : 'PASS';

  var route, routeLabel, rationale;

  if (emergencyFull || breachImmediate) {
    route      = 'ROUTE_D';
    routeLabel = '긴급 전량매도';
    rationale  = emergencyFull ? 'emergency_full_sell=true' : 'close<stop_price(stop_breach_gate=BREACH)';
  } else if (posClass.indexOf('SATELLITE') >= 0 && rsi14 >= 35) {
    route      = 'ROUTE_A';
    routeLabel = '위성 비중 트림';
    rationale  = 'SATELLITE+RSI14(' + rsi14 + ')>=35';
  } else if (rsi14 < 35) {
    route      = 'ROUTE_B';
    routeLabel = '과매도 분할 매도';
    rationale  = 'RSI14(' + rsi14 + ')<35→K2_50/50';
  } else if (posClass.indexOf('CORE') >= 0
             && (profitStage === 'PROFIT_LOCK_STAGE_20'
                 || profitStage === 'PROFIT_LOCK_STAGE_30'
                 || profitStage === 'PROFIT_LOCK_20'
                 || profitStage === 'PROFIT_LOCK_30')
             && secularPass) {
    route      = 'ROUTE_C';
    routeLabel = '코어 익절 잠금';
    rationale  = 'CORE+' + profitStage + '+secular_PASS';
  } else {
    route      = 'NO_ACTION';
    routeLabel = '현금확보 비대상';
    rationale  = 'no_condition_met';
  }

  return {
    ticker:                 h.ticker,
    name:                   h.name || df.name || '',
    smart_cash_raise_route: route,
    route_label:            routeLabel,
    rationale:              rationale,
    profit_lock_stage:      profitStage,
    stop_breach_gate:       stopBreachGate,
    emergency_full_sell:    emergencyFull,
    rebound_wait_pct:       route === 'ROUTE_B' ? 50 : 0,
    formula_id:             'SMART_CASH_RAISE_V2',
    version:                '2026-05-20_HARNESS_V5'
  };
}

// ── [2026-05-20_HARNESS_V5] Gate 4b: O'Neil Follow-Through Day — FOLLOW_THROUGH_DAY_CONFIRM_V1
// 돌파 당일(Day 0)에 즉시 매수 금지. Day 2~7 사이에 수익률+거래량 조건 충족 시만 BUY_PILOT_ALLOWED.
// daysSinceBreakout / retSinceBreakout / volumeBreakoutDay 이 df에 없으면 프록시 계산으로 후퇴.
function calcFollowThroughDayConfirm_(h, df) {
  var ticker = h.ticker;
  var name   = h.name || df.name || '';

  // ── 입력 수집 (실제 필드 우선, 프록시 fallback) ──────────────────────────
  var daysSince   = typeof df.daysSinceBreakout    === 'number' ? df.daysSinceBreakout    : null;
  var retSince    = typeof df.retSinceBreakout      === 'number' ? df.retSinceBreakout      : null;
  var volToday    = typeof df.volume                === 'number' ? df.volume                : null;
  var volBreakout = typeof df.volumeBreakoutDay     === 'number' ? df.volumeBreakoutDay     : null;

  // 프록시: daysSinceBreakout — close vs MA20 돌파여부로 추정
  //   MA20 이하에서 위로 올라온 직후이면 daysSince=0, 그 이전이면 null
  if (daysSince === null) {
    var close = df.close || h.close || 0;
    var ma20  = df.ma20  || 0;
    var prevClose = df.prevClose || close;
    // 오늘 ma20 상향 돌파면 Day 0
    if (close > 0 && ma20 > 0 && close > ma20 && prevClose <= ma20) {
      daysSince = 0;
    }
    // 이미 ma20 위에 있고 ret5d 존재 → days를 ret5d로 추정(보수적 5일 상한)
    else if (close > 0 && ma20 > 0 && close > ma20 && typeof df.ret5d === 'number') {
      // 5일 기준 프록시: 상승률이 클수록 이미 많이 경과했다고 가정
      daysSince = df.ret5d >= 7 ? 8 : df.ret5d >= 3 ? 4 : 2;
    }
  }

  // 프록시: retSinceBreakout — ret5d 사용
  if (retSince === null && typeof df.ret5d === 'number') {
    retSince = df.ret5d;
  }

  // 프록시: volBreakoutDay — avgVolume5d 사용
  if (volBreakout === null && typeof df.avgVolume5d === 'number') {
    volBreakout = df.avgVolume5d;
  }

  // ── 상태 분류 ──────────────────────────────────────────────────────────────
  var state, result, reasons = [];

  if (daysSince === null) {
    state  = 'PENDING_DATA';
    result = 'WATCH_NO_BREAKOUT_TRACKED';
    reasons.push('days_since_breakout_null');

  } else if (daysSince === 0) {
    state  = 'BREAKOUT_DAY_1';
    result = 'WATCH_FOLLOW_THROUGH_PENDING';
    reasons.push('day0_no_immediate_buy');

  } else if (daysSince > 7) {
    state  = 'EXTENDED_FOLLOW';
    result = 'WATCH_TOO_LATE';
    reasons.push('days_since_gt7');

  } else {
    // daysSince 2~7 범위
    var volOk  = (volToday !== null && volBreakout !== null && volBreakout > 0)
                   ? (volToday >= volBreakout * 0.9) : true; // 데이터 없으면 통과
    var retOk  = (retSince !== null) ? (retSince >= 1.5) : false;

    if (retOk && volOk) {
      state  = 'FOLLOW_THROUGH_OK';
      result = 'BUY_PILOT_ALLOWED';
      reasons.push('days_' + daysSince + '_ret_' + (retSince !== null ? retSince.toFixed(1) : 'N/A'));
      if (volOk) reasons.push('vol_confirmed');
    } else {
      state  = 'FOLLOW_THROUGH_FAIL';
      result = 'WATCH_RESET_REQUIRED';
      if (!retOk)  reasons.push('ret_since_lt1.5pct');
      if (!volOk)  reasons.push('vol_lt90pct_breakout_day');
    }
  }

  return {
    ticker:                    ticker,
    name:                      name,
    days_since_breakout:       daysSince,
    ret_since_breakout:        retSince,
    vol_ratio_vs_breakout_day: (volToday !== null && volBreakout !== null && volBreakout > 0)
                                 ? Math.round(volToday / volBreakout * 100) / 100 : null,
    follow_through_state:      state,
    follow_through_result:     result,
    reason_codes:              reasons,
    formula_id:                'FOLLOW_THROUGH_DAY_CONFIRM_V1',
    version:                   '2026-05-20_HARNESS_V5'
  };
}


function calcApexExecutionHarness_(holdings, dfMap, sectorFlowData, kospiRet5d, h1, h2, h3, h4, orderBlueprint, cashShortfallInfo, marketRegime) {
  // THIN_ADAPTER: [sizing/decision] delegated to Python — src/quant_engine/inject_computed_harness.py:main
  var alphaLead = [];
  var followThrough = [];
  var distribution = [];
  var profitPreservation = [];
  var entryFreshness = [];
  var cashRaisePlan = [];
  var reboundTriggers = [];
  var smartSellQty = [];
  var sellValuePreservation = [];
  var executionQuality = [];
  var buyPermission = [];
  var limitPolicy = [];
  var benchmarkRelativeRows = [];
  var indexRelativeHealthRows = [];
  var saqgRows = [];
  var cashCreationLockRows = [];
  // ── [2026-05-20_HARNESS_V5] 신규 V5 게이트 결과 배열
  var breakoutQualityGate = [];
  var antiWhipsawGate = [];
  var smartCashRaiseV2 = [];
  var followThroughConfirm = [];
  var blockCount = 0;
  var regime = marketRegime || 'UNKNOWN';

  var priceMap = {};
  (h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; });
  var sellQtyMap = {};
  (h3.sellQty || []).forEach(function(s) { sellQtyMap[s.ticker] = s; });

  holdings.forEach(function(h) {
    var df = dfMap[h.ticker] || {};
    var distRow = calcDistributionRiskRow_(h, df, kospiRet5d, sectorFlowData);
    // [PROPOSAL50] P1-B: DSD V1.1 — SIG_7/SIG_8 추가, weighted_sum 5.0/3.0 상향
    applyDsdV1_1Signals_([distRow], dfMap);
    var alphaRow = calcAlphaLeadRow_(h, df, sectorFlowData, distRow);
    var ftRow = calcFollowThroughRow_(h, df);
    var priceRow = priceMap[h.ticker] || {};
    var profitRow = calcProfitPreservationRow_(h, df, priceRow, distRow);
    var orderRow = findOrderBlueprintRow_(orderBlueprint, h.ticker) || {};
    var eqRow = calcExecutionQualityRow_(h.ticker, orderRow, df);
    var saqgState = df.saqg_v1 || (h.position_type === 'core' ? 'EXEMPT' : 'WATCHLIST_ONLY');
    var cand = findCandidateByTicker_(h2.candidates, h.ticker) || {};
    var sq = sellQtyMap[h.ticker] || {};
    var tradePlan = calcApexTradePlan_(
      h, df, h1, alphaRow, ftRow, distRow, priceRow, orderRow, sq, profitRow, cashShortfallInfo, saqgState
    );
    var buyState = tradePlan.buyState;
    var buyReasons = tradePlan.buyReasons;
    if (buyState === 'BLOCKED') blockCount++;
    var style = tradePlan.style;
    var immediateQty = tradePlan.immediateQty;
    var reboundQty = tradePlan.reboundQty;
    var k2Emergency = tradePlan.k2Emergency;
    var tranchePhase = tradePlan.tranchePhase;
    var currentTrancheAllowedPct = tradePlan.currentTrancheAllowedPct;
    var nextTrancheCondition = tradePlan.nextTrancheCondition;
    var normalizedSellPrice = tradePlan.normalizedSellPrice;
    var normalizedBuyPrice = tradePlan.normalizedBuyPrice;
    var htsLimitPrice = tradePlan.htsLimitPrice;
    var close = h.close || df.close || 0;
    var atr20 = df.atr20 || 0;
    var holdingQty = h.holdingQty || 0;
    var prevClose = df.prevClose || close;

    // ── [2026-05-20_HARNESS_V5] V5 게이트 산출 ──────────────────────────────
    var bqRow  = calcBreakoutQualityGate_(h, df, alphaRow, distRow);
    var awRow  = calcAntiWhipsawGate_(h, df, kospiRet5d);
    var scrV2  = calcSmartCashRaiseV2_(h, df, profitRow, priceRow, cashShortfallInfo);
    var ftdRow = calcFollowThroughDayConfirm_(h, df);

    // H6: 뒷박 차단 — BUY 상태 override
    if (bqRow.breakout_quality_gate === 'BLOCKED_LATE_CHASE') {
      if (buyState !== 'BLOCKED') { buyState = 'BLOCKED'; }
      buyReasons.push('breakout_quality_BLOCKED_LATE_CHASE');
      blockCount++;
    }

    // Gate 4b: FTD 미확인 — BUY 차단 (돌파 당일 즉시 매수 금지, 데이터 부재 시 WATCH로 후퇴)
    if (ftdRow.follow_through_result === 'WATCH_FOLLOW_THROUGH_PENDING'
        || ftdRow.follow_through_result === 'WATCH_RESET_REQUIRED') {
      if (buyState === 'ALLOW_PILOT') {
        buyState = 'WATCH';  // PILOT → WATCH (BLOCKED 아님 — 관찰 유지)
        buyReasons.push('ftd_' + ftdRow.follow_through_result);
      }
    } else if (ftdRow.follow_through_result === 'WATCH_TOO_LATE') {
      if (buyState === 'ALLOW_PILOT') {
        buyState = 'WATCH';
        buyReasons.push('ftd_WATCH_TOO_LATE');
      }
    }

    // H7: 가짜 매도 차단 — V1.1: CONFIRMED/WEAKENING만 보류 표기 (AUTO_RELEASED 제외)
    if (awRow.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || awRow.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') {
      buyReasons.push('whipsaw_hold_' + (awRow.anti_whipsaw_hold_days || 1) + 'd');
    }

    distribution.push(distRow);
    alphaLead.push(alphaRow);
    followThrough.push(ftRow);
    profitPreservation.push(profitRow);
    benchmarkRelativeRows.push({
      ticker: h.ticker,
      name: h.name || df.name || '',
      stock_drawdown_from_high_pct: typeof df.stock_drawdown_from_high_pct === 'number' ? df.stock_drawdown_from_high_pct : null,
      excess_drawdown_pctp: typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null,
      recovery_ratio_5d: typeof df.recovery_ratio_5d === 'number' ? df.recovery_ratio_5d : null,
      recovery_ratio_20d: typeof df.recovery_ratio_20d === 'number' ? df.recovery_ratio_20d : null,
      downside_beta: typeof df.downside_beta === 'number' ? df.downside_beta : null,
      rs_line_20d_slope: typeof df.rs_line_20d_slope === 'number' ? df.rs_line_20d_slope : null,
      rs_line_60d_slope: typeof df.rs_line_60d_slope === 'number' ? df.rs_line_60d_slope : null,
      brt_verdict: df.brt_verdict || 'UNKNOWN',
      brt_method: df.brt_method || 'DATA_MISSING',
      formula_id: 'BENCHMARK_RELATIVE_TIMESERIES_V1'
    });
    var indexRelRow = calcIndexRelativeHealthGate_(h, df, kospiRet5d);
    indexRelativeHealthRows.push(indexRelRow);
    saqgRows.push({
      ticker: h.ticker,
      name: h.name || df.name || '',
      position_type: h.position_type || 'unknown',
      saqg_v1: saqgState,
      saqg_penalty: typeof df.saqg_penalty === 'number' ? df.saqg_penalty : null,
      saqg_failed_filters: df.saqg_failed_filters || '',
      hts_allowed: saqgState === 'ELIGIBLE' || saqgState === 'EXEMPT',
      formula_id: 'SATELLITE_ALPHA_QUALITY_GATE_V1'
    });
    breakoutQualityGate.push(bqRow);
    antiWhipsawGate.push(awRow);
    smartCashRaiseV2.push(scrV2);
    followThroughConfirm.push(ftdRow);
    executionQuality.push(eqRow);

    // ── 진입 신선도 게이트 (ENTRY_FRESHNESS_GATE_V1) ───────────────────────
    var freshnessState = 'FRESH_PILOT';
    var freshnessReasons = [];
    if (bqRow.breakout_quality_gate === 'BLOCKED_LATE_CHASE' || alphaRow["late_chase_risk_score"] >= 70) {
      freshnessState = 'BLOCK_LATE_CHASE';
      freshnessReasons.push('late_chase');
    } else if (ftRow.follow_through_state === 'WAIT_PULLBACK' || ftdRow.follow_through_result === 'WATCH_TOO_LATE' || ftdRow.follow_through_result === 'WATCH_RESET_REQUIRED') {
      freshnessState = 'PULLBACK_WAIT';
      freshnessReasons.push('follow_through_wait');
    } else if (distRow.pre_distribution_warning === 'EARLY_WARNING') {
      freshnessState = 'STALE_REVIEW';
      freshnessReasons.push('pre_distribution_warning');
    } else if (buyState === 'WATCH' || buyState === 'BLOCKED') {
      freshnessState = 'WATCH_FRESHNESS';
      freshnessReasons.push('buy_state_' + buyState.toLowerCase());
    }
    if (indexRelRow.relative_health_state === 'DECOUPLED' || indexRelRow.relative_health_state === 'OVER_EXTENDED') {
      freshnessState = freshnessState === 'FRESH_PILOT' ? 'WATCH_FRESHNESS' : freshnessState;
      freshnessReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase());
      if (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON') {
        buyState = 'WATCH';
        buyReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase());
      }
    } else if (indexRelRow.relative_health_state === 'UNDERPERFORMING') {
      if (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON') {
        buyState = 'WATCH';
      }
      freshnessReasons.push('index_relative_underperforming');
    }
    entryFreshness.push({
      ticker: h.ticker,
      name: h.name || df.name || '',
      alpha_lead_score: alphaRow.alpha_lead_score != null ? alphaRow.alpha_lead_score : null,
      ["late_chase_risk_score"]: alphaRow["late_chase_risk_score"] != null ? alphaRow["late_chase_risk_score"] : null,
      follow_through_state: ftRow.follow_through_state || null,
      breakout_quality_gate: bqRow.breakout_quality_gate || null,
      pre_distribution_warning: distRow.pre_distribution_warning || 'NONE',
      t20_alpha_gate: null,
      freshness_state: freshnessState,
      reason_codes: freshnessReasons,
      formula_id: 'ENTRY_FRESHNESS_GATE_V1'
    });

    // ── 회복 보존 매도 게이트 (SELL_VALUE_PRESERVATION_GATE_V1) ─────────────
    var sellPreserveState = 'HOLD';
    var sellPreserveReasons = [];
    if (scrV2.smart_cash_raise_route === 'ROUTE_D' || k2Emergency || scrV2.stop_breach_gate === 'BREACH') {
      sellPreserveState = 'EMERGENCY_EXIT';
      sellPreserveReasons.push('route_d_or_breach');
    } else if (awRow.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || awRow.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') {
      sellPreserveState = 'REBOUND_CONFIRM_HOLD';
      sellPreserveReasons.push('whipsaw_hold_' + (awRow.anti_whipsaw_hold_days || 1) + 'd');
    } else if (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0) {
      sellPreserveState = 'STAGED_REBOUND';
      sellPreserveReasons.push('rebound_wait_qty');
    } else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_10'
        || profitRow.profit_preservation_state === 'PROFIT_LOCK_20'
        || profitRow.profit_preservation_state === 'PROFIT_LOCK_30'
        || profitRow.profit_preservation_state === 'APEX_TRAILING') {
      sellPreserveState = 'PRESERVE_TIERED';
      sellPreserveReasons.push('profit_lock');
    } else if (distRow.anti_distribution_state === 'BLOCK_BUY') {
      sellPreserveState = 'TRIM_ONLY';
      sellPreserveReasons.push('distribution_exit');
    } else if (indexRelRow.relative_health_state === 'OVER_EXTENDED' || indexRelRow.relative_health_state === 'DECOUPLED') {
      if (style !== 'OVERSOLD_REBOUND_SELL') {
        sellPreserveState = 'TRIM_ONLY';
      }
      sellPreserveReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase());
    }
    sellValuePreservation.push({
      ticker: h.ticker,
      name: h.name || df.name || '',
      profit_preservation_state: profitRow.profit_preservation_state || 'NORMAL',
      cash_raise_group: style,
      anti_whipsaw_gate: awRow.anti_whipsaw_gate || null,
      immediate_qty: immediateQty > 0 ? immediateQty : null,
      rebound_wait_qty: reboundQty > 0 ? reboundQty : null,
      auto_trailing_stop: profitRow.auto_trailing_stop || null,
      sell_value_preservation_state: sellPreserveState,
      reason_codes: sellPreserveReasons,
      formula_id: 'SELL_VALUE_PRESERVATION_GATE_V1'
    });

    // K1: 트랜치 엔진 결과 포함 buy_permission_json
    buyPermission.push({
      ticker: h.ticker,
      name: h.name || df.name || '',
      buy_permission_state: buyState,
      max_tranche_pct: buyState === 'ALLOW_PILOT' ? 30 : buyState === 'ALLOW_ADD_ON' ? 60 : 0,
      tranche_phase: tranchePhase,
      current_tranche_allowed_pct: currentTrancheAllowedPct,
      next_tranche_condition: nextTrancheCondition,
      blocked_reason_codes: buyReasons,
      position_type:     h.position_type   || 'unknown',
      brt_verdict:       df.brt_verdict     || null,
      saqg_v1:           saqgState,
      rs_verdict:        df.rs_verdict      || null,
      composite_verdict: df.composite_verdict || null,
      rag_v1:            df.rag_v1          || null,
      formula_id: 'BUY_PERMISSION_MATRIX_V1+STAGED_ENTRY_TRANCHE_V1'
    });

    // K2: 반등 대기 분할 매도 결과 포함 cash_raise_plan_json
    cashRaisePlan.push({
      ticker: h.ticker,
      name: h.name || df.name || '',
      rank: cand.rank || null,
      execution_style: style,
      immediate_qty: immediateQty > 0 ? immediateQty : null,
      rebound_wait_qty: reboundQty > 0 ? reboundQty : null,
      emergency_full_sell: k2Emergency,
      max_daily_qty: Math.floor(holdingQty * 0.50),
      expected_immediate_krw: immediateQty > 0 ? Math.round(immediateQty * close) : 0,
      cash_shortfall_min_krw: (cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw) || 0,
      formula_id: 'SMART_CASH_RAISE_PLAN_V1+K2_STAGED_REBOUND_SELL'
    });

    // K2: 반등 트리거 조건부 잔여 수량
    var reboundTriggerPrice = null;
    if (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0) {
      // 반등 트리거: prevClose + 0.5×ATR 또는 단순 close + 0.3×ATR
      reboundTriggerPrice = atr20 > 0
        ? tickNormalize_((prevClose > 0 ? prevClose : close) + atr20 * 0.5)
        : null;
    }
    reboundTriggers.push({
      ticker: h.ticker,
      rebound_trigger_state: (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0)
        ? 'WAIT_REBOUND_TRIGGER' : 'NOT_APPLICABLE',
      trigger_price: reboundTriggerPrice,
      rebound_sell_qty: reboundQty > 0 ? reboundQty : null,
      emergency_override: k2Emergency,
      formula_id: 'REBOUND_SELL_TRIGGER_V1'
    });

    smartSellQty.push({
      ticker: h.ticker,
      immediate_sell_qty: immediateQty > 0 ? immediateQty : null,
      staged_total_qty: (typeof sq.sell_qty === 'number' && sq.sell_qty > 0) ? sq.sell_qty : null,
      rebound_wait_qty: reboundQty > 0 ? reboundQty : null,
      emergency_full_sell: k2Emergency,
      expected_cash_recovered_krw: immediateQty > 0 ? Math.round(immediateQty * close) : 0,
      formula_id: 'SELL_QUANTITY_ALLOCATOR_V1+K2_STAGED_REBOUND_SELL'
    });

    // J5: 스타일별 실제 지정가 산출 결과 포함 limit_price_policy_json
    limitPolicy.push({
      ticker: h.ticker,
      execution_style: style,
      sell_limit_price: normalizedSellPrice,
      buy_limit_price: normalizedBuyPrice,
      hts_limit_price: htsLimitPrice,
      tick_status: htsLimitPrice ? 'TICK_OK' : 'NO_EXECUTION_PRICE',
      sell_price_basis: style === 'URGENT_LIQUIDITY_TRIM'   ? 'min(close,prevClose×0.998)'
                      : style === 'OVERSOLD_REBOUND_SELL'   ? 'close_no_undercut'
                      : style === 'DISTRIBUTION_EXIT'       ? 'close-0.25×ATR20'
                      : style === 'PROFIT_PROTECT_TRIM'     ? 'ratchet_stop_or_close×0.999'
                      : 'close',
      formula_id: 'LIMIT_PRICE_POLICY_V1'
    });
  });

  // K3: 국면·섹터 연계 H2 동적 우선순위
  var regimeAdjPriority = calcRegimeAdjustedSellPriority_(
    h2.candidates, regime, dfMap, kospiRet5d
  );

  // ── [2026-05-21_CLA_HARNESS_V1] SATELLITE_FAILURE_GATE_V1 ────────────────────
  var satelliteRowsForSFG = [];
  holdings.forEach(function(h) {
    if (h.position_type !== 'core') {
      var df = dfMap[h.ticker] || {};
      satelliteRowsForSFG.push({
        composite_verdict: df.composite_verdict || null,
        rs_verdict:        df.rs_verdict        || null,
        ret20d:            typeof df.ret20d === 'number' ? df.ret20d : null,
        excess_ret_10d:    typeof df.excess_ret_10d === 'number' ? df.excess_ret_10d : null
      });
    }
  });
  var sfgResult = calcSatelliteFailureGate_(satelliteRowsForSFG);
  var sapgResult = calcSatelliteAggregatePnlGate_(holdings);
  holdings.forEach(function(h) {
    var df = dfMap[h.ticker] || {};
    cashCreationLockRows.push(calcCashCreationPurposeLockRow_(h, df, sfgResult));
  });


  // ── [2026-05-21_AEW_V1] ALPHA_EVALUATION_WINDOW_V1 ──────────────────────────
  var aewRows = calcAlphaEvaluationWindow_(holdings, dfMap);

  // SFG-1: TRIGGERED 시 위성 BUY 전면 차단 (post-processing)
  if (sfgResult.sfg_v1 === 'TRIGGERED' || sapgResult.sapg_status === 'SAPG_CRITICAL') {
    buyPermission.forEach(function(bp) {
      var h = holdings.find(function(x) { return x.ticker === bp.ticker; });
      if (h && h.position_type !== 'core') {
        if (bp.buy_permission_state !== 'BLOCKED') {
          bp.buy_permission_state = 'BLOCKED';
          bp.blocked_reason_codes = (bp.blocked_reason_codes || []).concat([
            sfgResult.sfg_v1 === 'TRIGGERED' ? 'sfg_v1_TRIGGERED' : 'sapg_CRITICAL'
          ]);
        }
      }
    });
  }

  // ── [QEH010] WHIPSAW V1.1 → order_blueprint validation_status 소급 차단 ──
  // V1.1: WHIPSAW_CONFIRMED(hold_3d) + WHIPSAW_WEAKENING(hold_1d) 차단
  //       WHIPSAW_AUTO_RELEASED(hold_0d)은 자동 해제 — 차단 안 함
  var whipsawTickers_ = {};
  antiWhipsawGate.forEach(function(aw) {
    if (aw.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || aw.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') {
      whipsawTickers_[aw.ticker] = aw.anti_whipsaw_hold_days || 1;
    }
  });
  var SELL_ORDER_TYPES_ = { SELL: 1, TRIM: 1, EXIT_100: 1, EXIT_FULL: 1 };
  orderBlueprint.forEach(function(bp) {
    var wHoldDays = whipsawTickers_[bp.ticker];
    if (wHoldDays
        && SELL_ORDER_TYPES_[bp.order_type]
        && bp.validation_status === 'PASS') {
      bp.validation_status = 'BLOCKED';
      bp.rationale_code    = 'WHIPSAW_V1_1:hold_' + wHoldDays + 'd';
    }
  });

  // ── [2026-05-20_HARNESS_V5] V5 포트폴리오 레벨 집계
  var smartCashRaiseRoute = 'NO_ACTION';
  for (var sci = 0; sci < smartCashRaiseV2.length; sci++) {
    if (smartCashRaiseV2[sci].smart_cash_raise_route !== 'NO_ACTION') {
      smartCashRaiseRoute = smartCashRaiseV2[sci].smart_cash_raise_route;
      break; // 첫 번째 실제 경로를 포트폴리오 레벨 대표 경로로 설정
    }
  }

  return {
    alpha_lead_json: alphaLead,
    follow_through_json: followThrough,
    distribution_risk_json: distribution,
    profit_preservation_json: profitPreservation,
    entry_freshness_json: entryFreshness,
    cash_raise_plan_json: cashRaisePlan,
    rebound_sell_trigger_json: reboundTriggers,
    smart_sell_quantities_json: smartSellQty,
    sell_value_preservation_json: sellValuePreservation,
    execution_quality_json: executionQuality,
    buy_permission_json: buyPermission,
    limit_price_policy_json: limitPolicy,
    regime_adjusted_sell_priority_json: regimeAdjPriority,
    benchmark_relative_timeseries_json: benchmarkRelativeRows,
    index_relative_health_json: indexRelativeHealthRows,
    saqg_json: saqgRows,
    cash_creation_purpose_lock_json: cashCreationLockRows,
    // ── [2026-05-20_HARNESS_V5] 신규 V5 출력 ──────────────────────────────
    breakout_quality_gate_json:    breakoutQualityGate,
    anti_whipsaw_gate_json:        antiWhipsawGate,
    smart_cash_raise_json:         smartCashRaiseV2,
    smart_cash_raise_route:        smartCashRaiseRoute,
    follow_through_confirm_json:   followThroughConfirm,
    breakout_quality_gate_lock:    true,
    anti_whipsaw_gate_lock:        true,
    follow_through_lock:           true,
    follow_through_confirm_lock:   true,
    apex_block_count: blockCount,
    // ── [2026-05-21_CLA_HARNESS_V1] 신규 하네스 출력 ──────────────────────────
    satellite_failure_gate_json:   sfgResult,
    sapg_json:                     sapgResult,
    // ── [2026-05-21_AEW_V1] ─────────────────────────────────────────────────────
    alpha_evaluation_window_json:  aewRows,
    sfg_v1_lock:                   true
  };
}


// ═══════════════════════════════════════════════════════════════════════════════
// [2026-05-23_PROPOSAL46] PA1~PA5 신규 하네스 calc 함수
// spec/13b_harness_formulas.yaml: PA1 PREDICTIVE_ALPHA_ENGINE_V1
//                                  PA2 ANTI_LATE_ENTRY_GATE_V2
//                                  PA3 CASH_PRESERVATION_SELL_ENGINE_V2
//                                  PA4 MACRO_EVENT_SYNCHRONIZER_V1
//                                  PA5 CONSISTENCY_VALIDATOR_V2
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * [PROPOSAL47_B6 / PROPOSAL48_B6_FALLBACK] prediction_accuracy_rate 읽기.
 * 우선순위: ① monthly_history.prediction_accuracy_rate
 *           ② settings.prediction_accuracy_rate
 *           ③ 상수 기본값 48.48 (운영 중 실측값으로 교체 예정)
 * 값이 0~1 범위면 *100 변환, 0~100 범위면 그대로 사용.
 */
var PREDICTION_ACCURACY_RATE_DEFAULT_ = 48.48;  // 2026-05-23 실측, 매월 갱신

function getPredictionAccuracyRate_() {
  function parseAccuracy_(val) {
    if (val === '' || val === null || val === undefined) return null;
    var num = typeof val === 'number' ? val : parseFloat(String(val));
    if (isNaN(num)) return null;
    return num <= 1 ? Math.round(num * 1000) / 10 : num;
  }

  try {
    var ss = getSpreadsheet_();

    // ① monthly_history 시트
    var sh = ss.getSheetByName('monthly_history');
    if (sh) {
      var mhData = sh.getDataRange().getValues();
      if (mhData && mhData.length >= 2) {
        var header = mhData[0] || [];
        var colIdx = -1;
        for (var i = 0; i < header.length; i++) {
          if (String(header[i]).trim().toLowerCase() === 'prediction_accuracy_rate') {
            colIdx = i; break;
          }
        }
        if (colIdx >= 0) {
          for (var r = mhData.length - 1; r >= 1; r--) {
            var parsed = parseAccuracy_(mhData[r][colIdx]);
            if (parsed !== null) return parsed;
          }
        }
      }
    }

    // ② settings 시트 (Key-Value 구조)
    var settingsSh = ss.getSheetByName('settings');
    if (settingsSh) {
      var sData = settingsSh.getDataRange().getValues();
      for (var si = 0; si < sData.length; si++) {
        var key = String(sData[si][0] || '').trim().toLowerCase();
        if (key === 'prediction_accuracy_rate') {
          var parsed2 = parseAccuracy_(sData[si][1]);
          if (parsed2 !== null) return parsed2;
        }
      }
    }
  } catch(e) { /* fallback to default */ }

  // ③ 상수 기본값
  return PREDICTION_ACCURACY_RATE_DEFAULT_;
}


/**
 * [PA1 V1.2] 팩터 가중치 오버라이드 읽��
 * settings 시트의 pa1_w_<factor> 키-값을 읽어 기본값과 병합.
 * 오버라이드가 존재하면 _source='DYNAMIC', 없으면 'STATIC'.
 */
function getPa1WeightOverrides_() {
  var defaults = {
    pullback_entry: 20, flow_strong:    20, rs_leader:      15,
    volume_confirm: 15, rsi_healthy:    15, brt_leader:     15,
    chase_risk:     25, distribution:   20, rsi_overbought: 20,
    foreign_sell:   15, usd_krw_weak:   10, stale_position: 10,
    _source: 'STATIC'
  };
  try {
    var ss = getSpreadsheet_();
    var sh = ss.getSheetByName('settings');
    if (!sh) return defaults;
    var data = sh.getDataRange().getValues();
    var overrides = {};
    for (var i = 0; i < data.length; i++) {
      var key = String(data[i][0] || '').trim();
      if (key.indexOf('pa1_w_') !== 0) continue;
      var factorName = key.slice(6);  // 'pa1_w_' = 6자
      var val = parseFloat(String(data[i][1] || ''));
      if (!isNaN(val) && val >= 0 && val <= 50) overrides[factorName] = val;
    }
    if (Object.keys(overrides).length === 0) return defaults;
    var merged = {};
    for (var k in defaults) merged[k] = defaults[k];
    for (var k in overrides) merged[k] = overrides[k];
    merged._source = 'DYNAMIC';
    return merged;
  } catch(e) {
    return defaults;
  }
}


/**
 * [PA1 V1.3] T+5 피드백 기록
 * STRONG_BUY_SIGNAL / EXIT_SIGNAL / TRIM_SIGNAL 예측 → pa1_feedback 시트 기록.
 * V1.3: TRIM_SIGNAL 추가, signal_type 컬럼 추가 (BUY/SELL 분리 정확도 추적)
 * evaluatePa1FeedbackBatch_() 주간 배치에서 결과를 평가.
 */
function recordPa1FeedbackEntry_(paeRows, dfMap) {
  if (!paeRows || !paeRows.length) return;
  // [V1.3] TRIM_SIGNAL 추가
  var RECORD_VERDICTS = { STRONG_BUY_SIGNAL: 1, EXIT_SIGNAL: 1, TRIM_SIGNAL: 1 };
  var toRecord = paeRows.filter(function(pa) { return !!RECORD_VERDICTS[pa.synthesis_verdict]; });
  if (!toRecord.length) return;
  try {
    var ss  = getSpreadsheet_();
    var sh  = ss.getSheetByName('pa1_feedback');
    if (!sh) {
      sh = ss.insertSheet('pa1_feedback');
      sh.appendRow(['date','ticker','synthesis_verdict','direction_confidence',
                    'close_at_record','signal_type','t5_evaluated','t5_return_pct','t5_correct']);
    } else {
      // [V1.3] signal_type 컬럼 없으면 헤더 확인 — 없어도 appendRow는 동작함
    }
    var today = Utilities.formatDate(new Date(), 'Asia/Seoul', 'yyyy-MM-dd');
    toRecord.forEach(function(pa) {
      var df = dfMap[pa.ticker] || {};
      var closeNow = df.close || 0;
      var signalType = (pa.synthesis_verdict === 'STRONG_BUY_SIGNAL') ? 'BUY' : 'SELL';
      sh.appendRow([today, pa.ticker, pa.synthesis_verdict,
                    pa.direction_confidence, closeNow, signalType, false, '', '']);
    });
  } catch(e) {
    Logger.log('[PA1_FEEDBACK] recordPa1FeedbackEntry_ error: ' + e.message);
  }
}


/**
 * [PA1 V1.3] 매도 PASS 정확도 조회
 * pa1_feedback 시트에서 signal_type=SELL + t5_evaluated=true 행의 정확도 산출.
 * @return {number|null} sell_pass_accuracy_rate (0~100) or null if insufficient data
 */
function getSellPassAccuracyRate_() {
  try {
    var ss   = getSpreadsheet_();
    var fbSh = ss.getSheetByName('pa1_feedback');
    if (!fbSh) return null;
    var data = fbSh.getDataRange().getValues();
    if (data.length < 2) return null;
    var header = data[0];
    var COL = {};
    header.forEach(function(h, i) { COL[String(h)] = i; });
    if (COL['signal_type'] == null || COL['t5_evaluated'] == null || COL['t5_correct'] == null) return null;
    var sellRows = data.slice(1).filter(function(row) {
      return String(row[COL['signal_type']] || '').toUpperCase() === 'SELL'
          && (row[COL['t5_evaluated']] === true || String(row[COL['t5_evaluated']]).toUpperCase() === 'TRUE');
    });
    if (sellRows.length < 5) return null;
    var correct = sellRows.filter(function(row) {
      return row[COL['t5_correct']] === true || String(row[COL['t5_correct']]).toUpperCase() === 'TRUE';
    }).length;
    return Math.round(correct / sellRows.length * 1000) / 10;
  } catch(e) {
    Logger.log('[PA1_V1.3] getSellPassAccuracyRate_ error: ' + e.message);
    return null;
  }
}


/**
 * [PA1 V1.2] 주간 배치 — T+5(7캘린더일) 결과 평가 + prediction_accuracy_rate 갱신
 * GAS 트리거에 주 1회 등록해 사용 (매주 월요일 권장).
 */
function evaluatePa1FeedbackBatch_() {
  try {
    var ss   = getSpreadsheet_();
    var fbSh = ss.getSheetByName('pa1_feedback');
    if (!fbSh) { Logger.log('[PA1_V1.2] pa1_feedback 시트 없음'); return; }

    var data = fbSh.getDataRange().getValues();
    if (data.length < 2) return;
    var header = data[0];
    var COL = {};
    header.forEach(function(h, i) { COL[String(h)] = i; });
    var reqCols = ['date','ticker','synthesis_verdict','close_at_record','t5_evaluated','t5_return_pct','t5_correct'];
    for (var ci = 0; ci < reqCols.length; ci++) {
      if (COL[reqCols[ci]] == null) { Logger.log('[PA1_V1.2] 컬럼 누락: ' + reqCols[ci]); return; }
    }

    // 현재 종가 맵 (data_feed 시트)
    var priceMap = {};
    var dfSheet = ss.getSheetByName('data_feed');
    if (dfSheet) {
      var dfData = dfSheet.getDataRange().getValues();
      if (dfData.length > 1) {
        var dfHeader = dfData[0];
        var tCol = dfHeader.indexOf('Ticker');
        var cCol = dfHeader.indexOf('Close');
        if (tCol >= 0 && cCol >= 0) {
          for (var ri = 1; ri < dfData.length; ri++) {
            var t = String(dfData[ri][tCol] || '').trim();
            var c = parseFloat(String(dfData[ri][cCol] || ''));
            if (t && !isNaN(c) && c > 0) priceMap[t] = c;
          }
        }
      }
    }

    var todayMs  = new Date().getTime();
    var evalThisRun = 0;
    for (var i = 1; i < data.length; i++) {
      var row = data[i];
      var evaled = row[COL['t5_evaluated']];
      if (evaled === true || String(evaled).toUpperCase() === 'TRUE') continue;
      var daysDiff = (todayMs - new Date(row[COL['date']]).getTime()) / 86400000;
      if (daysDiff < 7) continue;
      var ticker  = String(row[COL['ticker']] || '');
      var verdict = String(row[COL['synthesis_verdict']] || '');
      var closeAt = parseFloat(String(row[COL['close_at_record']] || ''));
      var closeNow = priceMap[ticker] || 0;
      if (closeAt <= 0 || closeNow <= 0) continue;
      var t5Ret = Math.round((closeNow - closeAt) / closeAt * 10000) / 100;
      var isCorrect = (verdict === 'STRONG_BUY_SIGNAL') ? (t5Ret > 0) : (t5Ret < 0);
      fbSh.getRange(i + 1, COL['t5_evaluated']  + 1).setValue(true);
      fbSh.getRange(i + 1, COL['t5_return_pct'] + 1).setValue(t5Ret);
      fbSh.getRange(i + 1, COL['t5_correct']    + 1).setValue(isCorrect ? 'CORRECT' : 'WRONG');
      evalThisRun++;
    }

    // prediction_accuracy_rate 갱신 (최소 10건 평가 완료 후)
    var freshData = fbSh.getDataRange().getValues();
    var allEval = 0, allCorrect = 0;
    for (var j = 1; j < freshData.length; j++) {
      var ev = freshData[j][COL['t5_evaluated']];
      if (ev !== true && String(ev).toUpperCase() !== 'TRUE') continue;
      allEval++;
      if (String(freshData[j][COL['t5_correct']] || '') === 'CORRECT') allCorrect++;
    }
    if (allEval >= 10) {
      var newRate = Math.round(allCorrect / allEval * 1000) / 10;
      var settingSh = ss.getSheetByName('settings');
      if (settingSh) {
        var sData = settingSh.getDataRange().getValues();
        var updated = false;
        for (var si = 0; si < sData.length; si++) {
          if (String(sData[si][0] || '').trim().toLowerCase() === 'prediction_accuracy_rate') {
            settingSh.getRange(si + 1, 2).setValue(newRate);
            updated = true;
            break;
          }
        }
        if (!updated) settingSh.appendRow(['prediction_accuracy_rate', newRate]);
        Logger.log('[PA1_V1.2] prediction_accuracy_rate=' + newRate + '% (' + allCorrect + '/' + allEval + ')');
      }
    }
    Logger.log('[PA1_V1.2] evaluatePa1FeedbackBatch_ 완료: 이번 평가=' + evalThisRun + '건');

    // [PA1 V1.2] 정확도 기반 가중치 자동 조정 (평가 완료 후)
    if (allEval >= 10) {
      var accuracy7d = allCorrect / allEval;
      adjustPaeWeights_();
    }
  } catch(e) {
    Logger.log('[PA1_V1.2] evaluatePa1FeedbackBatch_ 오류: ' + e.message);
  }
}


/**
 * [PA1 V1.2] adjustPaeWeights_
 * T+5 예측 정확도(7일) 기반으로 thesis/antithesis 가중치 자동 조정.
 * 조정값을 settings 시트에 pa1_w_<factor> 형태로 기록 → 다음 실행 시 반영.
 */
function adjustPaeWeights_() {
  try {
    // 현재 precision 읽기
    var accRate = getPredictionAccuracyRate_();
    if (accRate === null) return;  // 데이터 부족 시 조정 안 함
    var accuracy = accRate / 100;  // 0~1 범위로 변환

    var ss = getSpreadsheet_();
    var settingSh = ss.getSheetByName('settings');
    if (!settingSh) return;

    var sData = settingSh.getDataRange().getValues();
    var currentWeights = {};
    var rowIndex = {};
    sData.forEach(function(row, i) {
      var key = String(row[0] || '').trim().toLowerCase();
      if (key.indexOf('pa1_w_') === 0) {
        currentWeights[key] = parseFloat(String(row[1] || '')) || null;
        rowIndex[key] = i + 1;  // 1-based
      }
    });

    // 기본 thesis/antithesis 총합 (12개 팩터 기본 가중치 합)
    var DEFAULT_THESIS_TOTAL   = 100;  // 20+20+15+15+15+15
    var DEFAULT_ANTI_TOTAL     = 100;  // 25+20+20+15+10+10

    // 조정 방향 결정
    var adjustThesis = 0;
    var adjustAnti   = 0;
    if (accuracy < 0.55) {
      // 정확도 낮음 → antithesis 강화 (+5% of base)
      adjustThesis = -5;
      adjustAnti   = +5;
    } else if (accuracy > 0.75) {
      // 정확도 높음 → thesis 강화 (+3% of base)
      adjustThesis = +3;
      adjustAnti   = 0;
    } else {
      Logger.log('[PA1_V1.2] adjustPaeWeights_: 정확도 정상범위(' + Math.round(accuracy*100) + '%) — 조정 불필요');
      return;
    }

    // thesis 팩터 가중치 조정 (각 비례 분배)
    var thesisFactors = ['pullback_entry','flow_strong','rs_leader','volume_confirm','rsi_healthy','brt_leader'];
    var thesisDefaults = { pullback_entry: 20, flow_strong: 20, rs_leader: 15, volume_confirm: 15, rsi_healthy: 15, brt_leader: 15 };
    thesisFactors.forEach(function(f) {
      var key = 'pa1_w_' + f;
      var baseW = thesisDefaults[f] || 0;
      var currentW = currentWeights[key] != null ? currentWeights[key] : baseW;
      var delta = Math.round(baseW / DEFAULT_THESIS_TOTAL * adjustThesis);
      var newW  = Math.max(5, Math.min(35, currentW + delta));
      if (rowIndex[key]) {
        settingSh.getRange(rowIndex[key], 2).setValue(newW);
      } else {
        settingSh.appendRow([key, newW]);
      }
    });

    // antithesis 팩터 가중치 조정
    var antiFactors = ['chase_risk','distribution','rsi_overbought','foreign_sell','usd_krw_weak','stale_position'];
    var antiDefaults = { chase_risk: 25, distribution: 20, rsi_overbought: 20, foreign_sell: 15, usd_krw_weak: 10, stale_position: 10 };
    antiFactors.forEach(function(f) {
      var key = 'pa1_w_' + f;
      var baseW = antiDefaults[f] || 0;
      var currentW = currentWeights[key] != null ? currentWeights[key] : baseW;
      var delta = Math.round(baseW / DEFAULT_ANTI_TOTAL * adjustAnti);
      var newW  = Math.max(5, Math.min(40, currentW + delta));
      if (rowIndex[key]) {
        settingSh.getRange(rowIndex[key], 2).setValue(newW);
      } else {
        settingSh.appendRow([key, newW]);
      }
    });

    Logger.log('[PA1_V1.2] adjustPaeWeights_ 완료: accuracy=' + Math.round(accuracy*100) + '% adjustThesis=' + adjustThesis + ' adjustAnti=' + adjustAnti);
  } catch(e) {
    Logger.log('[PA1_V1.2] adjustPaeWeights_ 오류: ' + e.message);
  }
}

/**
 * updatePa1WeightsManual_
 * PA1 팩터 가중치를 Work-1 승인값으로 settings 시트에 직접 기록.
 * 근거: 기존 8.0x 획일 비율(thesis=30, anti=240) → 2.6x 차별화(thesis=70, anti=185)
 * 효과: 모든 종목이 EXIT(-83~-95)로 획일화됐던 synthesis가 종목별 차별화됨
 *       (예: 000270 기아 +20 BULLISH / 005930 삼성전자 -18 BEARISH 등)
 * 사용법: GAS 에디터 → updatePa1WeightsManual_ 선택 → 실행
 */
function updatePa1WeightsManual_() {
  try {
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    var settingSh = ss.getSheetByName(SETTINGS_SHEET_NAME);
    if (!settingSh) {
      Logger.log('[updatePa1WeightsManual_] settings 시트를 찾을 수 없음');
      return;
    }

    // Work-1 승인 PA1 가중치 (thesis 70pt, antithesis 185pt, ratio=2.6x)
    var APPROVED_WEIGHTS = {
      // Thesis 팩터 (개별종목 차별화 강화): 5→10~15
      pa1_w_pullback_entry: 15,  // 눌림목 진입 — 핵심 타이밍
      pa1_w_flow_strong:    15,  // 수급 강세
      pa1_w_rs_leader:      10,  // 상대강도 선도
      pa1_w_volume_confirm: 10,  // 거래량 확인
      pa1_w_rsi_healthy:    10,  // RSI 여력
      pa1_w_brt_leader:     10,  // BRT 선도
      // Antithesis 팩터 (핵심만 유지, 획일화 해소): 일부 완화
      pa1_w_chase_risk:     40,  // 뒷박 위험 — 유지
      pa1_w_distribution:   40,  // 분배 신호 — 유지
      pa1_w_rsi_overbought: 40,  // RSI 과열 — 유지
      pa1_w_foreign_sell:   30,  // 외인 매도 — 완화 (단기 노이즈)
      pa1_w_usd_krw_weak:   15,  // 환율 약세 — 대폭 완화 (전 종목 동일 페널티 방지)
      pa1_w_stale_position: 20   // 장기보유 페널티 — 완화
    };

    // settings 시트에서 기존 pa1_w_* 행 인덱스 수집
    var data = settingSh.getDataRange().getValues();
    var rowIndex = {};
    data.forEach(function(row, i) {
      var key = String(row[0] || '').trim().toLowerCase();
      if (key.indexOf('pa1_w_') === 0) {
        rowIndex[key] = i + 1;  // 1-based
      }
    });

    // 값 쓰기 (존재하면 업데이트, 없으면 추가)
    var updated = []; var added = [];
    Object.keys(APPROVED_WEIGHTS).forEach(function(key) {
      var val = APPROVED_WEIGHTS[key];
      if (rowIndex[key]) {
        settingSh.getRange(rowIndex[key], 2).setValue(val);
        updated.push(key + '=' + val);
      } else {
        settingSh.appendRow([key, val]);
        added.push(key + '=' + val);
      }
    });

    var thesisTotal = 15+15+10+10+10+10;
    var antiTotal   = 40+40+40+30+15+20;
    Logger.log('[updatePa1WeightsManual_] 완료');
    Logger.log('  업데이트: ' + updated.join(', '));
    Logger.log('  신규 추가: ' + (added.length ? added.join(', ') : '없음'));
    Logger.log('  thesis합=' + thesisTotal + 'pt  antithesis합=' + antiTotal + 'pt  ratio=' + (antiTotal/thesisTotal).toFixed(1) + 'x');
    SpreadsheetApp.getUi().alert(
      'PA1 가중치 업데이트 완료\n' +
      'thesis합=' + thesisTotal + 'pt / antithesis합=' + antiTotal + 'pt (ratio=' + (antiTotal/thesisTotal).toFixed(1) + 'x)\n' +
      '업데이트: ' + updated.length + '개 / 추가: ' + added.length + '개\n\n' +
      '다음 runDataFeed 실행 시 새 가중치가 PA1 계산에 반영됩니다.'
    );
  } catch(e) {
    Logger.log('[updatePa1WeightsManual_] 오류: ' + e.message);
    SpreadsheetApp.getUi().alert('오류: ' + e.message);
  }
}


/**
 * PA4 — MACRO_EVENT_SYNCHRONIZER_V1
 * 외국인 순매도 연속일·USD/KRW·FOMC·VIX 등 거시 변수를 macro_risk_score로 환산.
 * heat_gate_adj(-3/-1/0/+1) 및 mega_sell_alert 산출.
 * @param {Object} macroJson   getMacroJson() 반환값
 * @param {Array}  eventRows   getEventRiskJson().events (DaysLeft, Type 컬럼)
 */
function calcMacroEventSynchronizerV1_(macroJson, eventRows) {
  return calcMacroEventSynchronizerV1Impl_(macroJson, eventRows);
}

/**
 * PA1 — PREDICTIVE_ALPHA_ENGINE_V1
 * 正(thesis) + 反(antithesis) = 合(direction_confidence) 3계층 점수.
 * synthesis_verdict=BEARISH(EXIT/TRIM) → BUY 차단 근거.
 * @param {Array}  holdings
 * @param {Object} dfMap
 * @param {Object} macroJson   getMacroJson() 반환값
 * @param {Object} mesResult   calcMacroEventSynchronizerV1_ 반환값
 */
function calcPredictiveAlphaEngineV1_(holdings, dfMap, macroJson, mesResult, weightOverrides) {
  return calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult, weightOverrides);
}


/**
 * PA2 — ANTI_LATE_ENTRY_GATE_V2
 * 3중 AND 게이트: velocity_1d / velocity_5d / distribution_weighted_sum.
 * ANTI_CHASING_VELOCITY_V1을 완전 대체.
 * @param {Array}  holdings
 * @param {Object} dfMap
 */
function calcAntiLateEntryGateV2_(holdings, dfMap) {
  return calcAntiLateEntryGateV2Impl_(holdings, dfMap);
}


/**
 * PA3 — CASH_PRESERVATION_SELL_ENGINE_V2
 * K2(분할) + C1(폭포수) + C2(타이밍)를 통합. 매도 스타일 결정 + value_preservation_score.
 * h3.sellQty에 수량이 있는 종목만 처리.
 * @param {Array}  holdings
 * @param {Object} dfMap
 * @param {Object} cashShortfallInfo   calcCashShortfallHarness_ 반환값
 * @param {Object} h3                  calcQuantities_ 반환값 (.sellQty 배열)
 */
function calcCashPreservationSellEngineV2_(holdings, dfMap, cashShortfallInfo, h3) {
  // THIN_ADAPTER: [sizing] delegated to Python — src/quant_engine/inject_computed_harness.py:cash_recovery
  var shortfallKrw = (cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw) || 0;

  var sellQtyMap = {};
  ((h3 && h3.sellQty) || []).forEach(function(sq) {
    if (typeof sq.sell_qty === 'number' && sq.sell_qty > 0) {
      sellQtyMap[sq.ticker] = Math.floor(sq.sell_qty);
    }
  });

  var rows = [];

  holdings.forEach(function(h) {
    var df      = dfMap[h.ticker] || {};
    var baseQty = sellQtyMap[h.ticker] || 0;

    if (baseQty <= 0 && shortfallKrw <= 0) return;

    var close     = h.close      || df.close      || 0;
    var prevClose = df.prevClose || close;
    var rsi14     = typeof df.rsi14  === 'number' ? df.rsi14  : 50;
    var atr20     = typeof df.atr20  === 'number' ? df.atr20  : (close * 0.02);
    var stopPrice = h.stopPrice  || 0;
    var frg5d     = typeof df.frg5d  === 'number' ? df.frg5d  : 0;
    var inst5d    = typeof df.inst5d === 'number' ? df.inst5d : 0;
    var volume    = typeof df.volume      === 'number' ? df.volume      : 0;
    var avgVol5d  = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : 0;

    // 현금 부족 시 baseQty 추정 (h3 미포함 종목)
    if (baseQty <= 0 && shortfallKrw > 0 && close > 0) {
      baseQty = Math.min(Math.floor(shortfallKrw / close), h.holdingQty || 0);
    }
    if (baseQty <= 0) return;

    // distribution weighted_sum (inline)
    var distWS = 0;
    if (frg5d < 0)                                distWS += 2.0;
    if (inst5d < 0)                               distWS += 2.0;
    if (avgVol5d > 0 && volume > avgVol5d * 1.3) distWS += 1.5;
    if (prevClose > 0 && close < prevClose)        distWS += 1.5;
    if (rsi14 > 70)                               distWS += 1.0;
    if (df.acGate === 'BLOCK')                    distWS += 1.0;

    var emergencyFullSell = h.stopBreach === true;

    // ── execution_style 결정 ─────────────────────────────────────────────────
    var execStyle;
    if (emergencyFullSell)  execStyle = 'EMERGENCY_FULL_EXIT';
    else if (rsi14 < 30)    execStyle = 'OVERSOLD_REBOUND_SELL';
    else                     execStyle = 'STAGED_WATERFALL';

    // ── 수량 산출 ────────────────────────────────────────────────────────────
    var immediateQty = 0, reboundWaitQty = 0, reboundTriggerPrice = 0, reboundDeadlineDays = 0;

    if (execStyle === 'OVERSOLD_REBOUND_SELL') {
      immediateQty        = Math.floor(baseQty * 0.50);
      reboundWaitQty      = baseQty - immediateQty;
      // TICK_NORMALIZER_V1 간소화: 10원 단위 반올림
      reboundTriggerPrice = Math.round((prevClose + 0.5 * atr20) / 10) * 10;
      reboundDeadlineDays = 3;
    } else if (execStyle === 'EMERGENCY_FULL_EXIT') {
      immediateQty        = baseQty;
      reboundWaitQty      = 0;
      reboundTriggerPrice = 0;
      reboundDeadlineDays = 0;
    } else {
      immediateQty        = Math.floor(baseQty * 0.50);
      reboundWaitQty      = baseQty - immediateQty;
      reboundTriggerPrice = prevClose > 0 ? prevClose : close;
      reboundDeadlineDays = 5;
    }

    // ── rebound_scenario ─────────────────────────────────────────────────────
    var limitPrice       = prevClose > 0 ? prevClose : close;
    var immediateKrw     = immediateQty * limitPrice;
    var reboundUpsideKrw = reboundWaitQty * (reboundTriggerPrice > 0 ? reboundTriggerPrice : limitPrice);
    var downsideRiskKrw  = reboundWaitQty * (stopPrice > 0 ? stopPrice : close * 0.92);
    var rrNum = reboundUpsideKrw - immediateKrw;
    var rrDen = Math.max(1, immediateKrw - downsideRiskKrw);
    var riskRewardRatio  = round2_(rrNum / rrDen);

    // ── value_preservation_score ─────────────────────────────────────────────
    var vpScore = 100;
    if (immediateQty >= baseQty && rsi14 < 30) vpScore -= 30; // full_sell_oversold
    if (distWS >= 3.0)                          vpScore -= 15; // distribution_high
    if (reboundWaitQty > 0)                     vpScore += 15; // rebound_wait_exists
    if (reboundTriggerPrice > 0 && limitPrice > 0
        && reboundTriggerPrice <= limitPrice * 1.03) vpScore += 10; // tight_trigger
    vpScore = Math.max(0, Math.min(100, Math.round(vpScore)));

    rows.push({
      ticker:                    h.ticker,
      name:                      h.name || df.name || '',
      execution_style:           execStyle,
      base_qty:                  baseQty,
      immediate_qty:             immediateQty,
      rebound_wait_qty:          reboundWaitQty,
      rebound_trigger_price:     reboundTriggerPrice,
      rebound_deadline_days:     reboundDeadlineDays,
      risk_reward_ratio:         riskRewardRatio,
      value_preservation_score:  vpScore,
      immediate_sell_krw:        Math.round(immediateKrw),
      rebound_upside_krw:        Math.round(reboundUpsideKrw),
      emergency_full_sell_flag:  emergencyFullSell,
      sell_value_damage_warning: vpScore < 50,
      dist_weighted_sum:         Math.round(distWS * 10) / 10,
      formula_id:                'CASH_PRESERVATION_SELL_ENGINE_V2'
    });
  });

  return rows;
}

/**
 * PA5 — CONSISTENCY_VALIDATOR_V2
 * 12개 논리 검증 항목으로 hApex 일관성 점검. score < 90 → cv_verdict=BLOCK.
 * Sprint C 마지막에 실행 — 이전 PA1~PA4 결과까지 모두 포함한 hApex 검증.
 * @param {Object} hApex
 * @param {Object} asResult
 * @param {Object} cashFloorInfo
 * @param {string} capturedAtIso
 * @param {Date}   now
 */
function calcConsistencyValidatorV2_(hApex, asResult, cashFloorInfo, capturedAtIso, now) {
  return calcConsistencyValidatorV2Impl_(hApex, asResult, cashFloorInfo, capturedAtIso, now);
}


/**
 * [PROPOSAL47_A1] WATCH_BREAKOUT_REALTIME_GATE_V1
 * REVIEW / EXIT 라이프사이클 단계의 보유 종목 중 velocity_1d >= 2.0% 급등 탐지.
 * 감시 중 급등 누락(49건 근본 원인) 해결 — 당일 급등 감지 시 후보 승격 검토 신호 생성.
 * anti_late_entry_grade가 F(BLOCK)인 경우 승격 제외 (추격 매수 방지).
 *
 * @param {Array}  holdings      asResult.holdings
 * @param {Object} dfMap         종목별 데이터 피드
 * @param {Array}  slgRows       satellite_lifecycle_gate_json (lifecycle_stage 포함)
 * @param {Array}  aleRows       anti_late_entry_json (entry_grade 포함, F면 제외)
 * @returns {Array} watch_breakout_candidates_json
 */
function calcWatchBreakoutRealtimeGateV1_(holdings, dfMap, slgRows, aleRows) {
  var VELOCITY_THRESHOLD = 2.0;
  var REVIEW_STAGES = ['REVIEW', 'EXIT'];

  var slgMap = {};
  (slgRows || []).forEach(function(r) {
    slgMap[String(r.ticker || '')] = String(r.lifecycle_stage || '');
  });
  var aleMap = {};
  (aleRows || []).forEach(function(r) {
    aleMap[String(r.ticker || '')] = r;
  });

  var results = [];
  (holdings || []).forEach(function(h) {
    var ticker = String(h.ticker || '');
    var stage = slgMap[ticker] || '';
    if (REVIEW_STAGES.indexOf(stage) < 0) return;

    var df = dfMap[ticker] || {};
    var close     = Number(df.close     || h.close     || 0);
    var prevClose = Number(df.prevClose || 0);
    if (close <= 0 || prevClose <= 0) return;

    var velocity1d = Math.round((close - prevClose) / prevClose * 10000) / 100;
    if (velocity1d < VELOCITY_THRESHOLD) return;

    var aleEntry = aleMap[ticker] || {};
    var aleGrade = aleEntry.entry_grade || 'B';
    if (aleGrade === 'F') return;  // 추격매수 방지: anti_late_entry_grade F 제외

    results.push({
      ticker:                ticker,
      name:                  h.name || df.name || '',
      lifecycle_stage:       stage,
      velocity_1d:           velocity1d,
      promotion_signal:      'WATCH_BREAKOUT',
      anti_late_entry_grade: aleGrade,
      formula_id:            'WATCH_BREAKOUT_REALTIME_GATE_V1'
    });
  });

  return results;
}

/**
 * [PROPOSAL48_A3] ANTI_WHIPSAW_REENTRY_GATE_V1
 * 매도 압박(tier=1/2) 종목이 당일 +3% 이상 급반등 시 REENTRY_CANDIDATE 마킹.
 * 9건 "매도 신호 후 반등" 패턴 처리. 매도 실행 전 재검토 신호 제공.
 *
 * @param {Array}  sellCandidates  hApex.sell_candidates_json (tier, ticker, action 포함)
 * @param {Object} dfMap           종목별 데이터 피드
 * @param {Array}  holdings        asResult.holdings
 * @returns {Array} anti_whipsaw_reentry_json
 */
function calcAntiWhipsawReentryGateV1_(sellCandidates, dfMap, holdings) {
  var REENTRY_VELOCITY_THRESHOLD = 3.0;   // 재진입 급반등 기준: +3%
  var WHIPSAW_TIERS = [1, 2];             // 즉시·단계 매도 압박 대상

  var results = [];
  (sellCandidates || []).forEach(function(cand) {
    var tier = typeof cand.tier === 'number' ? cand.tier : parseInt(cand.tier) || 99;
    if (WHIPSAW_TIERS.indexOf(tier) < 0) return;

    var ticker  = cand.ticker;
    var df      = dfMap[ticker] || {};
    var h       = (holdings || []).find(function(x) { return x.ticker === ticker; }) || {};
    var close   = h.close    || df.close    || 0;
    var prevClose = df.prevClose || 0;

    if (close <= 0 || prevClose <= 0) return;
    var velocity1d = Math.round((close - prevClose) / prevClose * 10000) / 100;
    if (velocity1d < REENTRY_VELOCITY_THRESHOLD) return;

    var profitPct = h.avgCost > 0
      ? Math.round((close - h.avgCost) / h.avgCost * 1000) / 10
      : null;
    var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : null;

    // 재진입 등급: A(rsi<50 + rs_leader), B(rsi<60), C(기본)
    var reentryGrade = 'C';
    if (rsi14 !== null && rsi14 < 50 && df.rs_verdict === 'LEADER') reentryGrade = 'A';
    else if (rsi14 !== null && rsi14 < 60) reentryGrade = 'B';

    results.push({
      ticker:              ticker,
      name:                h.name || df.name || '',
      sell_tier:           tier,
      sell_action:         cand.action || '',
      velocity_1d:         velocity1d,
      close:               close,
      prev_close:          prevClose,
      rsi14:               rsi14,
      rs_verdict:          df.rs_verdict  || '',
      profit_pct:          profitPct,
      reentry_grade:       reentryGrade,
      reentry_signal:      'REENTRY_CANDIDATE',
      whipsaw_warning:     '매도 압박 중 반등 — 실행 전 재검토 권고',
      formula_id:          'ANTI_WHIPSAW_REENTRY_GATE_V1'
    });
  });

  return results;
}


/**
 * [PROPOSAL48_C7] getAlphaHistorySummary_
 * alpha_history 시트의 T20/T60 alpha gate 결과를 집계.
 * 위성 종목의 장기 알파 생성 능력 추적 — T+5 피드백 루프 대용 지표.
 * DATA_INSUFFICIENT 상태에서도 구조를 갖춰 LLM 참조 가능하게 유지.
 */
function getAlphaHistorySummary_() {
  try {
    var ss = getSpreadsheet_();
    var sh = ss.getSheetByName('alpha_history');
    if (!sh) return { status: 'NO_SHEET', formula_id: 'ALPHA_HISTORY_SUMMARY_V1' };

    var rows = sh.getDataRange().getValues();
    if (!rows || rows.length < 2) return { status: 'EMPTY', formula_id: 'ALPHA_HISTORY_SUMMARY_V1' };

    var header = rows[0].map(function(h) { return String(h).trim(); });
    var idx = {};
    ['Ticker','T20_Alpha_Gate','T60_Alpha_Gate','T20_Vs_Core_Pctp','T60_Vs_Core_Pctp','SAQG_Grade_At_Entry'].forEach(function(k) {
      idx[k] = header.indexOf(k);
    });

    var t20 = { total: 0, pass: 0, fail: 0, missing: 0 };
    var t60 = { total: 0, pass: 0, fail: 0, missing: 0 };
    var gradeCount = {};

    for (var r = 1; r < rows.length; r++) {
      var row = rows[r];
      var g20 = idx['T20_Alpha_Gate'] >= 0 ? String(row[idx['T20_Alpha_Gate']] || '') : '';
      var g60 = idx['T60_Alpha_Gate'] >= 0 ? String(row[idx['T60_Alpha_Gate']] || '') : '';
      var grade = idx['SAQG_Grade_At_Entry'] >= 0 ? String(row[idx['SAQG_Grade_At_Entry']] || '') : '';

      if (g20 && g20 !== 'PENDING') {
        t20.total++;
        if (g20 === 'PASS') t20.pass++;
        else if (g20 === 'FAIL') t20.fail++;
        else t20.missing++;
      }
      if (g60 && g60 !== 'PENDING') {
        t60.total++;
        if (g60 === 'PASS') t60.pass++;
        else if (g60 === 'FAIL') t60.fail++;
        else t60.missing++;
      }
      if (grade) gradeCount[grade] = (gradeCount[grade] || 0) + 1;
    }

    var t20Rate = t20.total > 0 ? Math.round(t20.pass / t20.total * 1000) / 10 : null;
    var t60Rate = t60.total > 0 ? Math.round(t60.pass / t60.total * 1000) / 10 : null;

    return {
      status:           (t20.total > 0 || t60.total > 0) ? 'OK' : 'DATA_INSUFFICIENT',
      t20_total:        t20.total,
      t20_pass_rate:    t20Rate,
      t20_pass:         t20.pass,
      t20_fail:         t20.fail,
      t60_total:        t60.total,
      t60_pass_rate:    t60Rate,
      t60_pass:         t60.pass,
      t60_fail:         t60.fail,
      grade_count:      gradeCount,
      total_rows:       rows.length - 1,
      formula_id:       'ALPHA_HISTORY_SUMMARY_V1'
    };
  } catch(e) {
    return { status: 'ERROR', error: e.message, formula_id: 'ALPHA_HISTORY_SUMMARY_V1' };
  }
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P0-1: EXPORT_GATE_V1 — PENDING_EXPORT 원인 자동 진단
// Direction G5: PENDING_EXPORT 원인 진단 의무
// ═══════════════════════════════════════════════════════════════════════

/**
 * calcExportGate_
 * 5개 체크리스트 자동 평가 → EXPORT_READY / PENDING_EXPORT
 * PASS 전 HTS 입력 금지 조건을 결정론적으로 산출.
 */
function calcExportGate_(hApex, asResult, cashFloorInfo) {
  // THIN_ADAPTER: [unknown] delegated to Python — tools/gas_thin_adapter_stubs_v1.py:stub_calc_export_gate
  var checks = [];

  // CHECK_1: account_snapshot 캡처 완료 여부
  var captureRequired = !(asResult && asResult.holdings && asResult.holdings.length > 0
    && asResult.settlementCashD2Krw > 0);
  checks.push({
    check_id: 'CHECK_1_SNAPSHOT_CAPTURED',
    status: captureRequired ? 'FAIL' : 'PASS',
    message: captureRequired
      ? 'account_snapshot 미캡처 — HTS 화면 캡처 후 재실행 필요'
      : 'account_snapshot OK'
  });

  // CHECK_2: 데이터 완성도 (buy_permission_json 기준 전 종목 존재)
  var bpJson = (hApex && hApex.buy_permission_json) || [];
  var holdingCount = (asResult && asResult.holdings) ? asResult.holdings.length : 0;
  var dataOk = holdingCount > 0 && bpJson.length >= holdingCount;
  checks.push({
    check_id: 'CHECK_2_DATA_COMPLETENESS',
    status: dataOk ? 'PASS' : 'FAIL',
    message: dataOk
      ? 'data_feed 완성도 OK (' + bpJson.length + '/' + holdingCount + ')'
      : 'data_feed 누락 — npm run convert-data-json 후 재실행'
  });

  // CHECK_3: 하네스 무결성 체크섬 (consistency_score 기준)
  var cvScore = (hApex && typeof hApex.consistency_score === 'number') ? hApex.consistency_score : null;
  var cvOk = cvScore !== null && cvScore >= 70;
  checks.push({
    check_id: 'CHECK_3_HARNESS_INTEGRITY',
    status: cvOk ? 'PASS' : 'FAIL',
    message: cvOk
      ? 'consistency_score=' + cvScore + ' 무결성 OK'
      : 'consistency_score=' + (cvScore !== null ? cvScore : 'null') + ' — 70 미만 또는 미산출'
  });

  // CHECK_4: SELL_PRICE_SANITY — INVALID 주문 없음
  var blueprint = (hApex && hApex.order_blueprint_json) || [];
  var invalidPrices = blueprint.filter(function(b) {
    return String(b.validation_status || '').indexOf('INVALID') >= 0;
  });
  checks.push({
    check_id: 'CHECK_4_NO_INVALID_PRICES',
    status: invalidPrices.length === 0 ? 'PASS' : 'FAIL',
    message: invalidPrices.length === 0
      ? 'SELL_PRICE_SANITY 이상 없음'
      : 'INVALID 가격 ' + invalidPrices.length + '건: ' +
        invalidPrices.map(function(b) { return b.ticker; }).join(',')
  });

  // CHECK_5: cashFloor 블록 상태 확인 (HARD_BLOCK 시 현금 부족 경보)
  var cashStatus = (cashFloorInfo && cashFloorInfo.status) || 'UNKNOWN';
  var cashOk = cashStatus !== 'UNKNOWN';
  checks.push({
    check_id: 'CHECK_5_CASH_LEDGER',
    status: cashOk ? 'PASS' : 'WARN',
    message: cashOk
      ? 'cash_floor_status=' + cashStatus + ' (기록됨)'
      : 'cash_floor_status=UNKNOWN — settlement_cash_d2_krw 확인 필요'
  });

  // [PROPOSAL51] P1-A: CHECK_6 — SCRS_RENDER 검증 (immediate_sell_qty 유효값 필수)
  var scrsV2 = (hApex && hApex.scrs_v2_json) || {};
  // [PROPOSAL51-FIX] GAS는 immediate_qty 반환 (calcSmartCashRecoverySell_ 확인)
  var scrsRows = scrsV2.selected_combo || scrsV2.candidates || scrsV2.rows || [];
  var scrsRenderOk = scrsRows.length === 0 || scrsRows.every(function(r) {
    var qty = r.immediate_qty !== undefined ? r.immediate_qty : r.immediate_sell_qty;
    return qty !== null && qty !== undefined && qty !== '-' && qty !== '';
  });
  checks.push({
    check_id: 'CHECK_6_SCRS_RENDER',
    status: scrsRenderOk ? 'PASS' : 'WARN',
    message: scrsRenderOk
      ? 'SCRS-V2 immediate_sell_qty 렌더링 OK'
      : 'SCRS-V2 immediate_sell_qty 누락 — render_operational_report 키 불일치 확인 필요'
  });

  // [PROPOSAL51] P1-A: CHECK_7 — PORTFOLIO_HEALTH_SCORE 타입 (Boolean 금지)
  var healthScore = hApex && hApex.portfolio_health_score;
  var healthTypeOk = (typeof healthScore === 'number' && !isNaN(healthScore));
  checks.push({
    check_id: 'CHECK_7_HEALTH_SCORE_TYPE',
    status: healthTypeOk ? 'PASS' : 'WARN',
    message: healthTypeOk
      ? 'portfolio_health_score=' + healthScore + ' (숫자 OK)'
      : 'portfolio_health_score=' + JSON.stringify(healthScore) + ' — 숫자여야 함 (Boolean/null 금지)'
  });

  // [PROPOSAL51] P1-A: CHECK_8 — CLUSTER_SYNC 교정 없음 확인
  var clusterSync = (hApex && hApex.cluster_sync_result_json) || {};
  var clusterSyncOk = clusterSync.status === 'SYNCED' || !clusterSync.status;
  checks.push({
    check_id: 'CHECK_8_CLUSTER_SYNC',
    status: clusterSyncOk ? 'PASS' : 'WARN',
    message: clusterSyncOk
      ? 'SEMICONDUCTOR_CLUSTER_SYNC: 정합성 OK'
      : 'CLUSTER_SYNC 교정 발생 (cluster_pct=' + (clusterSync.cluster_pct || '?')
        + '%, threshold=' + (clusterSync.threshold_pct || '?') + '%)'
  });

  var failChecks = checks.filter(function(c) { return c.status === 'FAIL'; });
  var warnChecks = checks.filter(function(c) { return c.status === 'WARN'; });

  var exportStatus;
  if      (failChecks.length > 0) exportStatus = 'PENDING_EXPORT';
  else if (warnChecks.length > 0) exportStatus = 'REVIEW_ONLY';
  else                            exportStatus = 'EXPORT_READY';

  var htsAllowed = exportStatus === 'EXPORT_READY';
  var nonPassChecks = checks.filter(function(c) { return c.status !== 'PASS'; });
  var resolutionGuide = nonPassChecks.map(function(c) {
    return '[' + c.check_id + '] ' + c.message;
  });

  return {
    json_validation_status: exportStatus,
    export_gate_status:     exportStatus,
    all_checks_passed:      failChecks.length === 0 && warnChecks.length === 0,
    checks:                 checks,
    failed_checks:          failChecks.map(function(c) { return c.check_id; }),
    warn_checks:            warnChecks.map(function(c) { return c.check_id; }),
    resolution_guide:       resolutionGuide,
    hts_entry_allowed:      htsAllowed,
    formula_id:             'EXPORT_GATE_V2'
  };
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P0-2: ROUTING_TRACE_V1 — 라우팅 Trace 필수 출력 (Direction G4)
// ═══════════════════════════════════════════════════════════════════════

/**
 * buildRoutingTrace_
 * 모든 보고서 선행 출력 의무 — request_route, bundle, prompt, 검증 상태 etc.
 * 누락 시 보고서 전체 INCOMPLETE_REPORT.
 */
function buildRoutingTrace_(intradayLock, cashFloorInfo, hApex, capturedAtIso) {
  var scope = intradayLock ? 'TRIM_ONLY' : 'FULL_ANALYSIS';
  var bundleSelected = (function() {
    var cv = (hApex && hApex.consistency_score);
    if (cv === null || cv === undefined) return 'retirement_portfolio_ultra_compact';
    if (cv < 70) return 'retirement_portfolio_ultra_compact';
    return 'retirement_portfolio_compact';
  })();

  var exportGate = (hApex && hApex.export_gate_json) || {};
  var jsonValStatus = exportGate.json_validation_status || 'PENDING_EXPORT';
  var captureRequired = exportGate.checks
    ? !exportGate.checks.some(function(c) {
        return c.check_id === 'CHECK_1_SNAPSHOT_CAPTURED' && c.status === 'PASS';
      })
    : true;

  var cashLedgerBasis = 'D2_ONLY';
  var snapshotExecGate = (cashFloorInfo && cashFloorInfo.status === 'PASS')
    ? 'FULL_EXECUTION' : 'REVIEW_ONLY';

  return {
    request_route:          'PIPELINE_EOD_BATCH',
    bundle_selected:        bundleSelected,
    prompt_entrypoint:      'prompts/analysis_prompt.md',
    json_validation_status: jsonValStatus,
    capture_required:       captureRequired,
    intraday_scope:         scope,
    snapshot_execution_gate: snapshotExecGate,
    price_basis:            capturedAtIso || 'UNKNOWN',
    cash_ledger_basis:      cashLedgerBasis,
    routing_trace_complete: true,
    formula_id:             'ROUTING_TRACE_V1'
  };
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P0-3: WATCH_LEDGER_V1 — WATCH 감시 원장 (Direction I4)
// HTS 입력 금지 컬럼명만 허용 — 주문표와 물리적 분리
// ═══════════════════════════════════════════════════════════════════════

/**
 * buildWatchLedger_
 * order_blueprint_json에서 validation_status != PASS 행을 분리.
 * 허용 컬럼: ticker/name, reference_stop_price, reference_tp_state, hts_allowed, reason_code
 * 금지 컬럼: 지정가, 손절가, 익절가, 주문가, 주문수량 등 (INVALID_COLUMN)
 */
function buildWatchLedger_(orderBlueprint, h4) {
  // THIN_ADAPTER: [stop_loss/take_profit] delegated to Python — tools/gas_thin_adapter_stubs_v1.py:stub_build_watch_ledger
  var priceMap = {};
  ((h4 && h4.prices) || []).forEach(function(p) { priceMap[p.ticker] = p; });
  var blueprintRows = Array.isArray(orderBlueprint) ? orderBlueprint : [];

  var watchRows = blueprintRows.filter(function(b) {
    return b.validation_status !== 'PASS';
  });

  return watchRows.map(function(b) {
    var p = priceMap[b.ticker] || {};
    var tpState = (function() {
      if (!p.tp1_price) return 'INVALID_TP_STALE';
      if (p.tp_state === 'TP1_ALREADY_TRIGGERED') return 'TP1_ALREADY_TRIGGERED';
      return 'PENDING';
    })();
    return {
      ticker:               b.ticker,
      name:                 b.name || '',
      reference_stop_price: p.stop_price || null,
      reference_tp_state:   tpState,
      hts_allowed:          false,
      reason_code:          b.validation_status || 'NO_EXECUTION:WATCH',
      note:                 '주문 아님. HTS 입력 금지.'
    };
  });
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P1-1: EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1 (EJCE-V1)
// 30년 전문가 수준 3관점(애널리스트·트레이더·퀀트) 합의 게이트
// Direction EJ1: consensus_result=NO_BUY 시 BUY 절대 금지
// ═══════════════════════════════════════════════════════════════════════

/**
 * calcExpertJudgmentConsensus_
 * 3관점 독립 채점 → majority_rule → final_allowed_action 고착화
 * LLM "분위기 좋으니까" 판단을 결정론적 합의로 대체.
 */
function calcExpertJudgmentConsensus_(ticker, df, paeRow, h1, hApex, dfMap) {
  df = df || {};
  paeRow = paeRow || {};

  // ── ANALYST_VIEW: 펀더멘털·밸류에이션 ─────────────────────────────────────
  var compositeScore   = toNumber_(df['SS001_Score']  || df['composite_score']) || 0;
  var pegScore         = toNumber_(df['PEG_Score']    || df['peg_score'])       || 0;
  var upsidePct        = toNumber_(df['Upside_Pct']   || df['upside_pct'])      || 0;
  var epsMiss          = toNumber_(df['EPS_Revision_Status'] === 'MISS' ? 1 : 0);
  var dartRisk         = String(df['DART_Risk'] || '').toUpperCase() === 'Y';

  var analystScore = 0;
  if (compositeScore >= 70)          analystScore += 30;
  else if (compositeScore >= 50)     analystScore += 15;
  if (pegScore >= 8 || upsidePct > 15) analystScore += 20;
  if (upsidePct > 15)                analystScore += 5;
  if (epsMiss >= 2)                  analystScore -= 30;
  if (dartRisk)                      analystScore -= 20;

  var analystVerdict = analystScore >= 30 ? 'BULLISH'
    : analystScore >= -10              ? 'NEUTRAL'
    : 'BEARISH';

  // ── TRADER_VIEW: 타이밍·수급·추세 ─────────────────────────────────────────
  var flowCredit       = toNumber_(df['Flow_Credit']  || df['flow_credit'])     || 0;
  var rsVerdict        = String(df['RS_Verdict']      || df['rs_verdict']       || '').toUpperCase();
  var velocity1d       = toNumber_(df['Ret5D'] != null ? df['Close'] / (df['Close'] / (1 + toNumber_(df['Ret5D']) / 100)) - 1 : 0) * 100;
  // 더 단순하게: Ret5D/5 근사
  var ret5d            = toNumber_(df['Ret5D']        || df['ret5d'])           || 0;
  var vel1d_approx     = ret5d / 5;
  var paeAnti          = toNumber_(paeRow.antithesis_score)                     || 0;
  var distCount        = toNumber_(df['Dist_Signals'] || df['distribution_signals_count']) || 0;
  var ma20             = toNumber_(df['MA20'])                                  || 0;
  var close            = toNumber_(df['Close']        || df['close'])           || 0;
  var atr20            = toNumber_(df['ATR20'])                                 || 0;
  var inPullback       = (ma20 > 0 && close > 0) ? close <= ma20 * 1.03 : false;

  var traderScore = 0;
  if (flowCredit >= 0.55 && rsVerdict === 'LEADER') traderScore += 25;
  if (inPullback)                                    traderScore += 20;
  if (vel1d_approx < 1.5 && ret5d > 0)              traderScore += 20;
  if (vel1d_approx >= 3.0)                           traderScore -= 30;  // 뒷박 강한 패널티
  if (paeAnti >= 50)                                 traderScore -= 25;  // 설거지 경보
  if (distCount >= 2)                                traderScore -= 25;

  var traderVerdict = traderScore >= 20 ? 'ENTRY_OK'
    : traderScore >= -10             ? 'WAIT'
    : 'BLOCK_ENTRY';

  // ── QUANT_VIEW: 통계·팩터·리스크예산 ─────────────────────────────────────
  var pacVal     = toNumber_((hApex && hApex.portfolio_alpha_confidence)) || 0;
  var heatGate   = String((hApex && hApex.heat_gate_status) || '').toUpperCase();
  var ddGuard    = String((hApex && hApex.drawdown_guard_state) || '').toUpperCase();
  var expectedEdge = toNumber_(df['Expected_Edge'] || df['expected_edge']) || 0;
  var atrAvail   = atr20 > 0;

  var quantScore = 0;
  if (expectedEdge > 0 && atrAvail)  quantScore += 25;
  if (atrAvail)                       quantScore += 10;
  if (pacVal > 20)                    quantScore += 20;
  if (pacVal < -20)                   quantScore -= 30;  // 전체 알파 신뢰도 BLOCK
  if (heatGate === 'BLOCK_NEW_BUY')   quantScore -= 20;
  if (ddGuard === 'NO_BUY')           quantScore -= 15;

  var quantVerdict = quantScore >= 20 ? 'APPROVED'
    : quantScore >= -10             ? 'REDUCED'
    : 'REJECTED';

  // ── CONSENSUS_MATRIX: 2/3 이상 BLOCK → NO_BUY ───────────────────────────
  var blockCount = 0;
  if (analystVerdict === 'BEARISH')   blockCount++;
  if (traderVerdict === 'BLOCK_ENTRY') blockCount++;
  if (quantVerdict === 'REJECTED')    blockCount++;

  var consensusResult, finalAllowedAction;
  if (blockCount >= 2) {
    consensusResult     = 'NO_BUY';
    finalAllowedAction  = 'HOLD';
  } else if (analystVerdict === 'BULLISH' && traderVerdict === 'ENTRY_OK' && quantVerdict === 'APPROVED') {
    consensusResult     = 'STRONG_BUY';
    finalAllowedAction  = 'BUY';
  } else if (analystVerdict === 'BULLISH' && traderVerdict === 'ENTRY_OK') {
    consensusResult     = 'BUY_HALF';
    finalAllowedAction  = 'BUY_HALF';
  } else if (analystVerdict === 'BULLISH' && traderVerdict === 'WAIT') {
    consensusResult     = 'BUY_PULLBACK';
    finalAllowedAction  = 'WAIT_PULLBACK';
  } else if (analystVerdict === 'NEUTRAL' && traderVerdict === 'ENTRY_OK') {
    consensusResult     = 'BUY_PILOT';
    finalAllowedAction  = 'PILOT';
  } else {
    consensusResult     = 'HOLD_WATCH';
    finalAllowedAction  = 'WATCH';
  }

  var blockReasons = [];
  if (analystVerdict === 'BEARISH')    blockReasons.push('ANALYST_BEARISH');
  if (traderVerdict === 'BLOCK_ENTRY') blockReasons.push('TRADER_BLOCK_ENTRY_vel=' + vel1d_approx.toFixed(1) + '%');
  if (quantVerdict === 'REJECTED')     blockReasons.push('QUANT_REJECTED_pac=' + pacVal.toFixed(1));

  return {
    ticker:               ticker,
    analyst_score:        analystScore,
    analyst_verdict:      analystVerdict,
    trader_score:         traderScore,
    trader_verdict:       traderVerdict,
    quant_score:          quantScore,
    quant_verdict:        quantVerdict,
    block_count:          blockCount,
    consensus_result:     consensusResult,
    final_allowed_action: finalAllowedAction,
    block_reasons:        blockReasons,
    override_required:    blockCount >= 2,
    formula_id:           'EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1'
  };
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P1-2: SMART_CASH_RECOVERY_SELL_ENGINE_V2 (SCRS-V2)
// 세련된 현금확보 매도 — 주식가치 보호 + 반등 포착 통합 엔진
// Direction C3: SCRS-V2 selected_combo만 HTS 주문표 기재 허용
// ═══════════════════════════════════════════════════════════════════════

/**
 * calcSmartCashRecoverySell_
 * 현금 부족액을 최소 주식가치 훼손으로 회수.
 * 반등 기대 수익(expected_rebound_gain_krw) 사전 산출.
 * "현금 급함" 이유로 Stage_2 우회 원천 차단.
 */
function calcSmartCashRecoverySell_(holdings, dfMap, cashShortfallInfo, h2, hApex) {
  var shortfall    = toNumber_((cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw))  || 0;
  var totalAsset   = toNumber_((hApex && hApex.total_asset_krw) || (cashShortfallInfo && cashShortfallInfo.total_asset_krw)) || 1;
  var emergencyScore = shortfall / totalAsset * 100;

  var level = emergencyScore >= 15 ? 'EMERGENCY'
    : emergencyScore >= 8          ? 'URGENT'
    : emergencyScore >= 3          ? 'NORMAL'
    : 'TRIM_ONLY';

  var holdMap = {};
  (holdings || []).forEach(function(h) { holdMap[h.ticker] = h; });

  var sellQtyMap = {};
  ((hApex && hApex.sell_quantities_json) || []).forEach(function(sq) {
    sellQtyMap[sq.ticker] = sq;
  });

  var candidates = ((h2 && h2.candidates) || []).slice();
  
  // [Phase 3] SMART_CASH_RECOVERY_V6: value_damage_score(가치 훼손 점수) 기준 오름차순 정렬
  candidates.forEach(function(c) {
    var h = holdMap[c.ticker] || {};
    var df = dfMap[c.ticker] || {};
    var close = toNumber_(h.close || df['Close'] || df.close) || 0;
    var atr20 = toNumber_(df['ATR20'] || df.atr20) || (close * 0.02);
    // 가치 훼손 점수: 슬리피지 및 낙폭 리스크를 수치화 (낮을수록 매도 유리)
    c.value_damage_score = close > 0 ? ((atr20 * 0.3) / close) * 100 : 100;
  });
  candidates.sort(function(a, b) {
    return (a.value_damage_score || 0) - (b.value_damage_score || 0);
  });

  var cumulative = 0;
  var combo      = [];

  for (var i = 0; i < candidates.length; i++) {
    if (shortfall > 0 && cumulative >= shortfall) break;
    var c       = candidates[i];
    var h       = holdMap[c.ticker] || {};
    var df      = dfMap[c.ticker]   || {};
    var close   = toNumber_(h.close || df['Close'] || df.close) || 0;
    var atr20   = toNumber_(df['ATR20'] || df.atr20)            || (close * 0.02);
    var holding = toNumber_(h.holdingQty || h.holding_qty)      || 0;
    var sqRow   = sellQtyMap[c.ticker]                          || {};
    var baseQty = toNumber_(sqRow.sell_qty)                     || Math.floor(holding * 0.33);

    if (close <= 0 || baseQty <= 0) continue;

    var currentValue    = holding * close;
    var immediateQty    = Math.floor(baseQty * 0.50);
    var reboundWaitQty  = baseQty - immediateQty;
    var slippage        = atr20 * 0.3;
    var immediateKrw    = immediateQty  * Math.max(0, close - slippage);
    var damagePct       = currentValue > 0 ? immediateKrw / currentValue * 100 : 100;

    if (damagePct > 30 && level !== 'EMERGENCY') continue;

    var reboundTrigger  = tickNormalize_(close + atr20 * 0.5, close);
    var expectedReboundKrw = reboundWaitQty * Math.max(0, reboundTrigger - close);
    
    // [Phase 3] 유동성 기준 exec_mode 강제 지정
    var avgTradeValue = toNumber_(df['AvgTradeValue_20D_M'] || df.avgTradeVal20d) || 10000000000;
    var execMode = 'LIMIT_NEAR_BID';
    if (avgTradeValue < 5000000000) {
      execMode = 'TWAP_5_SPLIT';
    } else if (avgTradeValue > 50000000000) {
      execMode = 'MARKET';
    }

    cumulative += immediateKrw;
    combo.push({
      rank:                   c.rank,
      ticker:                 c.ticker,
      name:                   c.name || (h.name || ''),
      exec_mode:              execMode,
      value_damage_score:     Math.round(c.value_damage_score * 10) / 10,
      immediate_qty:          immediateQty,
      rebound_wait_qty:       reboundWaitQty,
      immediate_krw:          Math.round(immediateKrw),
      rebound_trigger_price:  reboundTrigger,
      expected_rebound_krw:   Math.round(expectedReboundKrw),
      value_damage_pct:       Math.round(damagePct * 10) / 10,
      rebound_deadline_date:  addBusinessDays_(new Date(), 3)
    });
  }

  var totalReboundGain = combo.reduce(function(s, c) { return s + c.expected_rebound_krw; }, 0);
  var avgDamage        = combo.length > 0
    ? combo.reduce(function(s, c) { return s + c.value_damage_pct; }, 0) / combo.length : 0;

  var emergencyFullSell = combo.length > 0
    && combo[0].immediate_krw * 2 < shortfall
    && level === 'EMERGENCY';

  return {
    emergency_level:           level,
    shortfall_krw:             Math.round(shortfall),
    selected_combo:            combo,
    total_immediate_sell_krw:  Math.round(cumulative),
    expected_rebound_gain_krw: Math.round(totalReboundGain),
    value_damage_pct_avg:      Math.round(avgDamage * 10) / 10,
    emergency_full_sell:       emergencyFullSell,
    shortfall_covered:         shortfall <= 0 || cumulative >= shortfall,
    formula_id:                'SMART_CASH_RECOVERY_SELL_ENGINE_V6'
  };
}

// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P1-C: CASH_RECOVERY_DISPLAY_LOCK_V1 (CRDL-V1)
// 현금회복 금액 3분리 표시 잠금 — 207억 과대표시 차단
// min_required / optimal_combo / reference_total (주문 아님)
// ═══════════════════════════════════════════════════════════════════════

/**
 * calcCashRecoveryDisplayLock_
 * 현금회복 금액을 3분리(최소필요/최적조합/전체후보) 표시 잠금.
 * reference_total_krw는 "주문 아님" 레이블 필수.
 */
function calcCashRecoveryDisplayLock_(scrsJson, trimPlanJson, cashInfo) {
  function normalizeRows_(v) {
    if (Array.isArray(v)) return v;
    if (!v) return [];
    if (typeof v === 'string') {
      try { return normalizeRows_(JSON.parse(v)); } catch (e) { return []; }
    }
    if (typeof v === 'object') {
      var vals = [];
      for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) vals.push(v[k]);
      return vals;
    }
    return [];
  }

  var scrs = scrsJson || {};
  if (typeof scrs === 'string') {
    try { scrs = JSON.parse(scrs); } catch (e0) { scrs = {}; }
  }
  var trim = normalizeRows_(trimPlanJson);
  var cash = cashInfo || {};

  var minRequired  = toNumber_(cash.cash_shortfall_min_krw) || 0;
  var combo = normalizeRows_(scrs.selected_combo);
  var optimalCombo = combo.reduce(function(s, r) { return s + (toNumber_(r.immediate_krw) || 0); }, 0);
  var refTotal     = trim.reduce(function(s, r) {
    return s + (toNumber_(r.sell_amount_krw || r.trim_amount_krw || r.trimming_krw) || 0);
  }, 0);

  var coverageStatus;
  if (minRequired <= 0)                   coverageStatus = 'NO_SHORTFALL';
  else if (optimalCombo < minRequired)    coverageStatus = 'UNCOVERED';
  else if (optimalCombo > minRequired * 2) coverageStatus = 'OVER_SELL';
  else                                    coverageStatus = 'COVERED';

  return {
    formula_id:          'CASH_RECOVERY_DISPLAY_LOCK_V1',
    min_required_krw:    Math.round(minRequired),
    optimal_combo_krw:   Math.round(optimalCombo),
    reference_total_krw: Math.round(refTotal),
    coverage_status:     coverageStatus,
    display_mode:        'SHOW_MIN_OPTIMAL',
    reference_label:     '참고용 전체 후보 누적 — 주문 아님',
    over_sell_warning:   coverageStatus === 'OVER_SELL'
      ? 'OVER_SELL_WARNING: 최적조합(' + Math.round(optimalCombo/10000) + '만원)이 최소필요(' + Math.round(minRequired/10000) + '만원)의 2배 초과' : null,
    shortfall_uncovered: coverageStatus === 'UNCOVERED'
      ? 'CASH_SHORTFALL_UNCOVERED: SCRS-V2 재실행 필요' : null
  };
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P1-B: DATA_QUALITY_GATE_V2 (DQG-V2)
// 데이터 완성도 필드충족률 기반 게이트 — 행수 카운트 폐기
// COMPLETE(≥90%) / PARTIAL(≥60%) / INSUFFICIENT(<60%)
// ═══════════════════════════════════════════════════════════════════════

/**
 * calcDataQualityGateV2_
 * 핵심 필드 충족률로 데이터 완성도 등급 산출.
 * T+20=0건, trade_quality=0건 시 특수 경고 발동.
 */
function calcDataQualityGateV2_(hApex) {
  var h = hApex || {};

  var pa1 = ((h.alpha_lead_json || [])[0]) || {};
  var tradeQualRecords = ((h.trade_quality_report_json || {}).records || []);
  var tqFirst  = tradeQualRecords[0] || {};
  var alphaHist = (h.alpha_history_summary_json) || {};
  var scrsV2   = (h.scrs_v2_json) || {};
  var combo    = scrsV2.selected_combo || [];
  var cluster  = (h.semiconductor_cluster_json) || {};
  var alphaEval = (h.alpha_evaluation_window_json || []);
  var firstAlpha = alphaEval[0] || {};
  var pp0      = ((h.profit_preservation_json) || [])[0] || {};

  var isValid = function(v) {
    return v !== null && v !== undefined && v !== '-' && v !== 'PENDING' && v !== '';
  };

  // [R2-1c] 필드경로 버그 수정: 실재 데이터를 0으로 깔던 false-negative 제거.
  // prediction: alpha_lead_json[0] → pa1_report_json(PA1 진짜 필드).
  // cash: cash_shortfall_json.cash_shortfall_min_krw(None) → 직접키 h.cash_shortfall_min_krw.
  // cluster: h.semiconductor_cluster_json → h.semiconductor_cluster_gate_json 또는 직접 필드.
  // stop_loss: final_stop_price/stop_price(없는 키) → protected_stop_price/auto_trailing_stop.
  // trade_quality/alpha_eval/pattern: 표본 필요 → PENDING 값으로 명시(분모 제외).
  var pa1Report = h.pa1_report_json || {};
  if (typeof pa1Report === 'string') { try { pa1Report = JSON.parse(pa1Report); } catch(e) { pa1Report = {}; } }
  var pa1Rows = Array.isArray(pa1Report) ? pa1Report : (pa1Report.rows || []);
  var pa1Row0 = pa1Rows[0] || {};

  var clusterDirect = h.semiconductor_cluster_json || {};
  if (typeof clusterDirect === 'string') { try { clusterDirect = JSON.parse(clusterDirect); } catch(e) { clusterDirect = {}; } }

  var CATEGORIES = {
    prediction:    [pa1Row0.direction_confidence, pa1Row0.synthesis_verdict, pa1Row0.thesis_score, pa1Row0.antithesis_score],
    trade_quality: [tqFirst.grade || 'PENDING', tqFirst.feedback_tag || 'PENDING', tqFirst.t5_return_pct, tqFirst.t20_vs_core_pct],
    pattern:       [(h.pattern_blacklist_auto_json || {}).status || 'PENDING', (h.pattern_blacklist_auto_json || {}).accumulated_poor_count],
    ["stop_loss"]:     [pp0.auto_trailing_stop, pp0.protected_stop_price, pp0.profit_preservation_state],
    cash:          [h.settlement_cash_d2_krw, h.cash_floor_status, h.cash_shortfall_min_krw],
    sell_engine:   [scrsV2.emergency_level, (combo[0] || {}).immediate_qty, (combo[0] || {}).rebound_wait_qty],
    cluster:       [clusterDirect.cluster_state, clusterDirect.combined_pct],
    alpha_eval:    [firstAlpha.alpha_gate_verdict || 'PENDING', alphaHist.prediction_accuracy_rate]
  };

  var categoryScores = {};
  Object.keys(CATEGORIES).forEach(function(cat) {
    var fields = CATEGORIES[cat];
    var filled = fields.filter(isValid).length;
    categoryScores[cat] = Math.round(filled / fields.length * 100);
  });

  var catVals = Object.keys(categoryScores).map(function(k) { return categoryScores[k]; });
  var overallPct = catVals.length > 0
    ? Math.round(catVals.reduce(function(s, v) { return s + v; }, 0) / catVals.length) : 0;

  var grade = overallPct >= 90 ? 'COMPLETE' : overallPct >= 60 ? 'PARTIAL' : 'INSUFFICIENT';

  var warnings = [];
  var t20Count  = toNumber_((alphaHist).t20_evaluation_count) || 0;
  var tqCount   = tradeQualRecords.length;
  var accRate   = alphaHist.prediction_accuracy_rate;
  var t5Count   = toNumber_(alphaHist.t5_match_count) || 0;

  if (t20Count === 0)    warnings.push('warn_t20_zero: T+20 평가 0건 — 장기 예측 신뢰도 미검증');
  if (tqCount  === 0)    warnings.push('warn_quality_unverified: 거래 품질 기록 0건');
  if (!isValid(accRate)) warnings.push('warn_accuracy_unknown: 예측 정확도 미산출(PENDING)');
  if (t5Count  < 5)      warnings.push('warn_insufficient_samples: T+5 표본 ' + t5Count + '건(최소 5건 미달)');

  return {
    formula_id:               'DATA_QUALITY_GATE_V2',
    overall_completeness_pct: overallPct,
    completeness_grade:       grade,
    category_scores:          categoryScores,
    special_warnings:         warnings,
    t20_evaluation_count:     t20Count,
    trade_quality_record_count: tqCount,
    prediction_accuracy_rate: accRate || null,
    confidence_ceiling: grade === 'INSUFFICIENT'
      ? 'BUY_SELL_CONFIDENCE_LIMITED: 핵심 데이터 부족 — 신호 신뢰도 상한 경고' : null
  };
}


/**
 * addBusinessDays_: 영업일 기준 날짜 계산 (토·일 제외)
 */
function addBusinessDays_(startDate, days) {
  var d = new Date(startDate.getTime());
  var added = 0;
  while (added < days) {
    d.setDate(d.getDate() + 1);
    var dow = d.getDay();
    if (dow !== 0 && dow !== 6) added++;
  }
  return Utilities.formatDate(d, 'Asia/Seoul', 'yyyy-MM-dd');
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P2-1: DETERMINISTIC_SERVING_LOCK_ENGINE_V1 (DSLE-V1)
// 11단계 stage_token 잠금 + LLM 수치 생성 = 0 강제
// Direction D3: LLM 서빙 수치 생성 절대 금지
// ═══════════════════════════════════════════════════════════════════════

/**
 * calcDeterministicServingLock_
 * 11단계 파이프라인 각 단계의 status·checksum을 토큰으로 기록.
 * integrity_checksum 불일치 시 INVALID_SERVING_OVERRIDE 자동 표시.
 */
function calcDeterministicServingLock_(hApex, capturedAtIso, now) {
  var stages = [
    { id: 'Stage_01_freshness',     key: 'data_freshness_status' },
    { id: 'Stage_02_intraday',      key: 'intraday_scope' },
    { id: 'Stage_03_portfolio',     key: 'cash_floor_status' },
    { id: 'Stage_04_macro',         key: 'macro_risk_score' },
    { id: 'Stage_05_sell_radar',    key: 'distribution_sell_detector_json' },
    { id: 'Stage_06_buy_gate',      key: 'anti_late_entry_json' },
    { id: 'Stage_07_sell_priority', key: 'sell_candidates_json' },
    { id: 'Stage_08_cash_recovery', key: 'scrs_v2_json' },
    { id: 'Stage_09_rs_quality',    key: 'rs_verdict' },
    { id: 'Stage_10_tick_norm',     key: 'tick_normalized_prices_json' },
    { id: 'Stage_11_serving',       key: 'order_blueprint_json' },
  ];

  var tokens = [];
  var blockDetected = false;
  var blockReason   = null;

  for (var i = 0; i < stages.length; i++) {
    var s     = stages[i];
    var value = hApex ? hApex[s.key] : null;
    var status = (value !== null && value !== undefined) ? 'OK' : 'MISSING';
    if (status === 'MISSING' && i < 4) {
      blockDetected = true;
      blockReason   = blockReason || (s.id + '_MISSING');
    }
    tokens.push({
      stage_id:  s.id,
      key:       s.key,
      status:    status,
      checksum:  computeStringChecksum_(safeStringifyForChecksum_(value))
    });
  }

  var tokenChecksum = computeStringChecksum_(safeStringifyForChecksum_(tokens));

  return {
    route_lock_status:       blockDetected ? 'PARTIALLY_LOCKED' : 'FULLY_LOCKED',
    stage_tokens:            tokens,
    integrity_checksum:      tokenChecksum,
    llm_serving_budget: {
      max_tokens:                   1000,
      numeric_generation_allowed:   0,
      constraint:                   'LLM_SERVING_CONSTRAINT_V1'
    },
    block_reason:            blockReason,
    captured_at:             capturedAtIso || null,
    generated_at:            now ? Utilities.formatDate(now, 'Asia/Seoul', 'yyyy-MM-dd HH:mm') : null,
    formula_id:              'DETERMINISTIC_SERVING_LOCK_ENGINE_V1'
  };
}

// ============================================================
// WBS-4.4  일별 성과 대시보드 (포트폴리오 수익률 vs KOSPI 알파)
// ============================================================

/**
 * 매일 runDataFeed 이후 호출. evaluation_dashboard 시트에
 * 포트폴리오 수익률·KOSPI 수익률·알파·누적알파·MDD 를 기록.
 *
 * 설계 원칙:
 *  - daily_history  → total_asset, mdd_pct
 *  - macro 시트     → KOSPI Close (어제/오늘 Close 차이로 1D 수익률 계산)
 *  - evaluation_dashboard 시트의 직전 행을 기준 자산·KOSPI Close 로 사용
 *  - 시트 없으면 자동 생성, 오늘 행이 이미 있으면 덮어쓰기
 */
function updateEvaluationDashboard_() {
  var ss = getSpreadsheet_();
  var today = Utilities.formatDate(new Date(), 'Asia/Seoul', 'yyyy-MM-dd');

  // ── 1. daily_history에서 오늘 total_asset, mdd_pct 읽기 ──────────────────
  var histSheet = ss.getSheetByName('daily_history');
  if (!histSheet) {
    Logger.log('[EVAL_DASH] daily_history 시트 없음, 건너뜀');
    return;
  }
  var histData = histSheet.getDataRange().getValues();
  if (histData.length < 2) {
    Logger.log('[EVAL_DASH] daily_history 데이터 부족');
    return;
  }
  var hHdr = histData[0].map(function(c) { return String(c).trim(); });
  var hDateIdx  = hHdr.indexOf('date');
  var hAssetIdx = hHdr.indexOf('total_asset');
  var hMddIdx   = hHdr.indexOf('mdd_pct');
  if (hDateIdx < 0 || hAssetIdx < 0) {
    Logger.log('[EVAL_DASH] daily_history 헤더 불일치: ' + hHdr.join(','));
    return;
  }
  var todayHistRow = null;
  for (var r = 1; r < histData.length; r++) {
    if (String(histData[r][hDateIdx]).trim() === today) {
      todayHistRow = histData[r];
      break;
    }
  }
  if (!todayHistRow) {
    Logger.log('[EVAL_DASH] daily_history에 오늘 행 없음: ' + today);
    return;
  }
  var todayAsset = parseFloat(todayHistRow[hAssetIdx]) || 0;
  var todayMdd   = hMddIdx >= 0 ? (parseFloat(todayHistRow[hMddIdx]) || 0) : 0;

  // ── 2. macro 시트에서 KOSPI Close 읽기 ────────────────────────────────────
  var todayKospiClose = null;
  var macroSheet = ss.getSheetByName('macro');
  if (macroSheet) {
    var mData = macroSheet.getDataRange().getValues();
    var mHdrRowIdx = 0;
    for (var i = 0; i < Math.min(5, mData.length); i++) {
      if (mData[i].join(',').indexOf('Name') >= 0) { mHdrRowIdx = i; break; }
    }
    var mHdr     = mData[mHdrRowIdx].map(function(c) { return String(c).trim(); });
    var mNameIdx  = mHdr.indexOf('Name');
    var mCloseIdx = mHdr.indexOf('Close');
    for (var j = mHdrRowIdx + 1; j < mData.length; j++) {
      if (mNameIdx >= 0 && String(mData[j][mNameIdx]).trim() === 'KOSPI') {
        if (mCloseIdx >= 0) todayKospiClose = parseFloat(mData[j][mCloseIdx]) || null;
        break;
      }
    }
  }

  // ── 3. evaluation_dashboard 시트 가져오기/생성 ───────────────────────────
  var EVD_HDRS = [
    'Date', 'Total_Asset', 'KOSPI_Close',
    'Portfolio_Return_1D_Pct', 'KOSPI_Return_1D_Pct',
    'Alpha_1D_Pct', 'Cumulative_Alpha_Pct', 'MDD_Pct'
  ];
  var evdSheet = ss.getSheetByName('evaluation_dashboard');
  if (!evdSheet) {
    evdSheet = ss.insertSheet('evaluation_dashboard');
    evdSheet.getRange(1, 1, 1, EVD_HDRS.length).setValues([EVD_HDRS]);
    evdSheet.setFrozenRows(1);
  }

  // ── 4. 직전 행(prev) 및 오늘 행 위치 파악 ──────────────────────────────
  var evdData = evdSheet.getDataRange().getValues();
  var eHdr = evdData.length > 0
    ? evdData[0].map(function(c) { return String(c).trim(); })
    : EVD_HDRS;
  var eDateIdx     = eHdr.indexOf('Date');
  var eAssetIdx    = eHdr.indexOf('Total_Asset');
  var eKospiIdx    = eHdr.indexOf('KOSPI_Close');
  var eCumAlphaIdx = eHdr.indexOf('Cumulative_Alpha_Pct');

  var prevAsset      = null;
  var prevKospi      = null;
  var prevCumAlpha   = 0;
  var todayRowIdx    = -1;   // 1-based sheet row index (0 = not found)

  for (var k = 1; k < evdData.length; k++) {
    var rowDate = eDateIdx >= 0 ? String(evdData[k][eDateIdx]).trim() : '';
    if (rowDate === today) {
      todayRowIdx = k + 1;   // getRange은 1-based
    } else if (rowDate !== '' && rowDate < today) {
      prevAsset    = eAssetIdx    >= 0 ? (parseFloat(evdData[k][eAssetIdx])    || null) : null;
      prevKospi    = eKospiIdx    >= 0 ? (parseFloat(evdData[k][eKospiIdx])    || null) : null;
      prevCumAlpha = eCumAlphaIdx >= 0 ? (parseFloat(evdData[k][eCumAlphaIdx]) || 0)   : 0;
    }
  }

  // ── 5. 수익률·알파 계산 ────────────────────────────────────────────────
  var portfolioRet1D = null;
  if (prevAsset !== null && prevAsset > 0 && todayAsset > 0) {
    portfolioRet1D = Math.round(((todayAsset - prevAsset) / prevAsset * 100) * 100) / 100;
  }
  var kospiRet1D = null;
  if (prevKospi !== null && prevKospi > 0 && todayKospiClose !== null && todayKospiClose > 0) {
    kospiRet1D = Math.round(((todayKospiClose - prevKospi) / prevKospi * 100) * 100) / 100;
  }
  var alpha1D  = (portfolioRet1D !== null && kospiRet1D !== null)
    ? Math.round((portfolioRet1D - kospiRet1D) * 100) / 100
    : null;
  var cumAlpha = alpha1D !== null
    ? Math.round((prevCumAlpha + alpha1D) * 100) / 100
    : prevCumAlpha;

  var newRow = [
    today, todayAsset, todayKospiClose,
    portfolioRet1D, kospiRet1D,
    alpha1D, cumAlpha, todayMdd
  ];

  // ── 6. 오늘 행 덮어쓰기 또는 추가 ────────────────────────────────────
  if (todayRowIdx > 0) {
    evdSheet.getRange(todayRowIdx, 1, 1, newRow.length).setValues([newRow]);
    Logger.log('[EVAL_DASH] 오늘 행 업데이트 date=' + today
      + ' portfolio_ret=' + portfolioRet1D
      + ' alpha=' + alpha1D + ' cum_alpha=' + cumAlpha);
  } else {
    evdSheet.appendRow(newRow);
    Logger.log('[EVAL_DASH] 오늘 행 추가 date=' + today
      + ' portfolio_ret=' + portfolioRet1D
      + ' alpha=' + alpha1D + ' cum_alpha=' + cumAlpha);
  }
}


// --- Source: src/gas_adapter_parts/gdf_05_alpha_engines.gs ---
function safeStringifyForChecksum_(value) {
  var s = JSON.stringify(value);
  return (s === undefined || s === null) ? '' : s;
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P2-2: YAML_GAS_COVERAGE_AUDIT_ENGINE_V1 (YGCA-V1)
// YAML 지침 ↔ GAS 함수 커버리지 감사 — settings 탭에 결과 기록
// ═══════════════════════════════════════════════════════════════════════

/**
 * auditYamlGasCoverage_
 * 필수 함수 목록과 실제 정의를 비교해 커버리지 % 산출.
 * GAS에서는 typeof 로 함수 존재 여부를 확인한다.
 */
function auditYamlGasCoverage_() {
  var REQUIRED = [
    // Stage 0
    { yaml: 'HARNESS_DATA_FRESHNESS_GATE_V1',         gs: 'calcHarnessDataFreshnessGate_' },
    { yaml: 'INTRADAY_ACTION_MATRIX_V1',              gs: 'calcIntradayLock_' },
    // Stage 1
    { yaml: 'FLOW_CREDIT_V1',                         gs: 'buildAllowedAction' },
    { yaml: 'TARGET_CASH_PCT_V1',                     gs: 'calcCashFloor_' },
    { yaml: 'TOTAL_HEAT_V1',                          gs: 'calcHarnessPortfolioGuardState_' },
    { yaml: 'CASH_SHORTFALL_V1',                      gs: 'calcCashShortfallHarness_' },
    { yaml: 'CASH_RECOVERY_OPTIMIZER_V1',             gs: 'calcCashPreservationPlan_' },
    // Stage 2
    { yaml: 'POSITION_SIZE_V1',                       gs: 'calcQuantities_' },
    { yaml: 'STOP_PRICE_CORE_V1',                     gs: 'calcPrices_' },
    { yaml: 'PROFIT_RATCHET_TIERED_V2',               gs: 'calcProfitPreservationRow_' },
    { yaml: 'TAKE_PROFIT_LADDER_V1',                  gs: 'calcTpQuantityLadder_' },
    // Stage 3
    { yaml: 'DISTRIBUTION_SELL_DETECTOR_V1',          gs: 'calcDistributionRiskRow_' },
    { yaml: 'DIVERGENCE_SCORE_V1',                    gs: 'calcSellConflictScore_' },
    { yaml: 'OVERHANG_PRESSURE_V1',                   gs: 'calcReboundHoldbackScore_' },
    { yaml: 'FLOW_ACCELERATION_V1',                   gs: 'calcAlphaShield_' },
    { yaml: 'PRE_DISTRIBUTION_EARLY_WARNING_V1',      gs: 'calcDistributionRiskRow_' },
    // Stage 4
    { yaml: 'ANTI_LATE_ENTRY_GATE_V2',                gs: 'calcAntiLateEntryGateV2_' },
    { yaml: 'PULLBACK_ENTRY_TRIGGER_V1',              gs: 'calcEntryTimingSignal_' },
    { yaml: 'BREAKOUT_QUALITY_GATE_V2',               gs: 'calcBreakoutQualityGate_' },
    { yaml: 'STAGED_ENTRY_TRANCHE_V1',                gs: 'calcCoreSatelliteExecutionState_' },
    // Stage 5
    { yaml: 'SELL_WATERFALL_ENGINE_V1',               gs: 'calcSmartCashRaiseV2_' },
    { yaml: 'SELL_EXECUTION_TIMING_V1',               gs: 'calcExitSellAction_' },
    { yaml: 'SELL_VALUE_PRESERVATION_TIERED_V2',      gs: 'calcCashPreservationSellEngineV2_' },
    { yaml: 'SELL_PRICE_SANITY_V1',                   gs: 'calcSellSignalSanityScore_' },
    { yaml: 'K2_STAGED_REBOUND_SELL_V1',              gs: 'calcAntiWhipsawGate_' },
    // Stage 6
    { yaml: 'TICK_NORMALIZER_V1',                     gs: 'tickNormalize_' },
    // Stage 7-8
    { yaml: 'RS_VERDICT_V2',                          gs: 'calcIndexRelativeHealthGate_' },
    { yaml: 'BENCHMARK_RELATIVE_TIMESERIES_V1',       gs: 'calcIndexRelativeHealthGate_' },
    { yaml: 'SATELLITE_ALPHA_QUALITY_GATE_V1',        gs: 'calcCoreCandidateQualityGrade_' },
    { yaml: 'SATELLITE_LIFECYCLE_GATE_V1',            gs: 'calcSatelliteLifecycleGate_' },
    { yaml: 'PORTFOLIO_CORRELATION_GATE_V1',          gs: 'calcPortfolioCorrelationGate_' },
    // Stage 9
    { yaml: 'LLM_SERVING_CONSTRAINT_V1',              gs: 'calcDeterministicServingLock_' },
    { yaml: 'DETERMINISTIC_ROUTING_ENGINE_V1',        gs: 'buildHarnessContext_' },
    // Portfolio risk
    { yaml: 'DRAWDOWN_GUARD_V1',                      gs: 'calcDrawdownGuard_' },
    { yaml: 'PORTFOLIO_BETA_GATE_V1',                 gs: 'calcPortfolioBetaGate_' },
    { yaml: 'SECTOR_CONCENTRATION_LIMIT_V1',          gs: 'calcSectorConcentrationGate_' },
    { yaml: 'POSITION_COUNT_LIMIT_V1',                gs: 'calcPositionCountLimit_' },
    { yaml: 'SINGLE_POSITION_WEIGHT_CAP_V1',          gs: 'calcSinglePositionWeightCap_' },
    { yaml: 'SEMICONDUCTOR_CLUSTER_GATE_V1',          gs: 'calcSemiconductorClusterGate_' },
    { yaml: 'PORTFOLIO_DRAWDOWN_GATE_V1',             gs: 'calcPortfolioDrawdownGate_' },
    { yaml: 'WIN_LOSS_STREAK_GUARD_V1',               gs: 'calcWinLossStreakGuard_' },
    // Alerts
    { yaml: 'STOP_BREACH_ALERT_V1',                   gs: 'calcStopBreachAlert_' },
    { yaml: 'RELATIVE_STOP_SIGNAL_V1',                gs: 'calcRelativeStopSignal_' },
    { yaml: 'TP_TRIGGER_ALERT_V1',                    gs: 'calcTpTriggerAlert_' },
    { yaml: 'HEAT_CONCENTRATION_ALERT_V1',            gs: 'calcHeatConcentrationAlert_' },
    { yaml: 'REGIME_TRANSITION_ALERT_V1',             gs: 'calcRegimeTransitionAlert_' },
    { yaml: 'PORTFOLIO_HEALTH_SCORE_V1',              gs: 'calcPortfolioHealthScore_' },
    // Proposal50 신규
    { yaml: 'EXPORT_GATE_V1',                         gs: 'calcExportGate_' },
    { yaml: 'ROUTING_TRACE_V1',                       gs: 'buildRoutingTrace_' },
    { yaml: 'WATCH_LEDGER_V1',                        gs: 'buildWatchLedger_' },
    { yaml: 'EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1',    gs: 'calcExpertJudgmentConsensus_' },
    { yaml: 'SMART_CASH_RECOVERY_SELL_ENGINE_V2',     gs: 'calcSmartCashRecoverySell_' },
    { yaml: 'DETERMINISTIC_SERVING_LOCK_ENGINE_V1',   gs: 'calcDeterministicServingLock_' },
    { yaml: 'MACRO_REGIME_ADAPTIVE_GATE_V2',          gs: 'calcMacroRegimeAdaptiveGate_' },
    { yaml: 'MANDATORY_REDUCTION_PLAN_V1',            gs: 'calcMandatoryReductionPlan_' },
    // Proposal50 P0 Gap 해소 함수
    { yaml: 'VALIDATE_ORDER_CONDITION_V1',            gs: 'validateOrderCondition_' },
    { yaml: 'SHADOW_LEDGER_V1',                       gs: 'buildShadowLedger_' },
    { yaml: 'LLM_SERVING_CONSTRAINT_V1',              gs: 'calcLlmServingConstraint_' },
    { yaml: 'AVG_TRADE_VALUE_SIGNAL_V1',              gs: 'calcAvgTradeValueSignal_' },
    { yaml: 'TRIM_PLAN_MIN_CASH_V1',                  gs: 'calcTrimPlanMinCash_' },
    { yaml: 'PREDICTIVE_ALPHA_ENGINE_V1',             gs: 'calcPredictiveAlphaEngineV1_' },
    { yaml: 'MACRO_EVENT_SYNCHRONIZER_V1',            gs: 'calcMacroEventSynchronizerV1_' },
    { yaml: 'ANTI_LATE_ENTRY_GATE_V2',                gs: 'calcAntiLateEntryGateV2_' },
    { yaml: 'CONSISTENCY_VALIDATOR_V2',               gs: 'calcConsistencyValidatorV2_' },
    { yaml: 'SATELLITE_FAILURE_GATE_V1',              gs: 'calcSatelliteFailureGate_' },
    { yaml: 'SATELLITE_AGGREGATE_PNL_GATE_V1',        gs: 'calcSatelliteAggregatePnlGate_' },
    { yaml: 'CLA_REGIME_EXIT_CONDITION_V1',           gs: 'calcClaRegimeExitCondition_' },
    { yaml: 'EVENT_RISK_HOLD_GATE_V1',                gs: 'calcEventRiskHoldGate_' },
    { yaml: 'SECTOR_ROTATION_MOMENTUM_V1',            gs: 'calcSectorRotationMomentum_' },
    // Monthly Batch 피드백 루프
    { yaml: 'TRADE_QUALITY_SCORER_V1',                gs: 'calcTradeQualityScorer_' },
    { yaml: 'PATTERN_BLACKLIST_AUTO_V1',              gs: 'calcPatternBlacklistAuto_' },
    { yaml: 'ALPHA_FEEDBACK_LOOP_V1',                 gs: 'calcAlphaFeedbackLoop_' },
    // Proposal51 신규
    { yaml: 'SELL_PRICE_SANITY_V2',                   gs: 'calcSellPriceSanityV2_' },
    { yaml: 'EXPORT_GATE_V2',                         gs: 'calcExportGate_' },
    { yaml: 'SEMICONDUCTOR_CLUSTER_SYNC_V1',          gs: 'syncSemiconductorCluster_' },
    { yaml: 'PROACTIVE_SELL_RADAR_V2',                gs: 'calcProactiveSellRadarV2_' },
    { yaml: 'ANTI_LATE_ENTRY_GATE_V3',                gs: 'applyAlegGate4And5_' },
    { yaml: 'PRICE_HIERARCHY_LOCK_V1',                gs: 'applyPriceHierarchyLockAll_' },
    { yaml: 'DATA_QUALITY_GATE_V2',                   gs: 'calcDataQualityGateV2_' },
    { yaml: 'CASH_RECOVERY_DISPLAY_LOCK_V1',          gs: 'calcCashRecoveryDisplayLock_' },
    // Proposal53 신규
    { yaml: 'FUNDAMENTAL_QUALITY_GATE_V1',            gs: 'calcFundamentalQualityGateV1_' },
    { yaml: 'HORIZON_ALLOCATION_LOCK_V1',             gs: 'calcHorizonAllocationLockV1_' },
    { yaml: 'SMART_MONEY_LIQUIDITY_GATE_V1',          gs: 'calcSmartMoneyLiquidityGateV1_' },
    { yaml: 'ROUTING_SERVING_DECISION_TRACE_V2',      gs: 'buildRoutingServingTraceV2_' },
    { yaml: 'FUNDAMENTAL_MULTI_FACTOR_SCORE_V2',      gs: 'calcFundamentalMultiFactorScoreV2_' },
    { yaml: 'EARNINGS_GROWTH_QUALITY_GATE_V1',        gs: 'calcEarningsGrowthQualityGateV1_' },
    { yaml: 'MARKET_SHARE_MOMENTUM_PROXY_V1',         gs: 'calcMarketShareMomentumProxyV1_' },
    { yaml: 'CASHFLOW_STABILITY_GATE_V1',             gs: 'calcCashflowStabilityGateV1_' },
    { yaml: 'ROUTING_DECISION_EXPLAIN_LOCK_V1',       gs: 'calcRoutingExplainLockV1_' },
  ];

  var implemented = REQUIRED.filter(function(req) {
    try { return typeof eval(req.gs) === 'function'; } catch(e) { return false; }
  });
  // eval 대신 안전한 방법으로 확인 (GAS에서는 this 대신 globalThis 또는 eval 허용)
  // GAS 환경: 전역 함수 → typeof functionName 으로 확인 불가 → 이름 기반 hardlist 사용
  var IMPLEMENTED_HARDLIST = [
    'calcHarnessDataFreshnessGate_','calcIntradayLock_','buildAllowedAction',
    'calcCashFloor_','calcHarnessPortfolioGuardState_','calcCashShortfallHarness_',
    'calcCashPreservationPlan_','calcQuantities_','calcPrices_',
    'calcProfitPreservationRow_','calcTpQuantityLadder_','calcDistributionRiskRow_',
    'calcSellConflictScore_','calcReboundHoldbackScore_','calcAlphaShield_',
    'calcAntiLateEntryGateV2_','calcEntryTimingSignal_','calcBreakoutQualityGate_',
    'calcCoreSatelliteExecutionState_','calcSmartCashRaiseV2_','calcExitSellAction_',
    'calcCashPreservationSellEngineV2_','calcSellSignalSanityScore_','calcAntiWhipsawGate_',
    'tickNormalize_','calcIndexRelativeHealthGate_','calcCoreCandidateQualityGrade_',
    'calcSatelliteLifecycleGate_','calcPortfolioCorrelationGate_',
    'calcDeterministicServingLock_','buildHarnessContext_',
    'calcDrawdownGuard_','calcPortfolioBetaGate_','calcSectorConcentrationGate_',
    'calcPositionCountLimit_','calcSinglePositionWeightCap_','calcSemiconductorClusterGate_',
    'calcPortfolioDrawdownGate_','calcWinLossStreakGuard_',
    'calcStopBreachAlert_','calcTpTriggerAlert_','calcHeatConcentrationAlert_',
    'calcRegimeTransitionAlert_','calcPortfolioHealthScore_',
    'calcExportGate_','buildRoutingTrace_','buildWatchLedger_',
    'calcExpertJudgmentConsensus_','calcSmartCashRecoverySell_',
    'calcMacroRegimeAdaptiveGate_','calcMandatoryReductionPlan_',
    'validateOrderCondition_','buildShadowLedger_','calcLlmServingConstraint_',
    'calcAvgTradeValueSignal_','calcTrimPlanMinCash_',
    'applyAlegGate4And5_','applyDsdV1_1Signals_',
    'calcPredictiveAlphaEngineV1_','calcMacroEventSynchronizerV1_',
    'calcAntiLateEntryGateV2_','calcConsistencyValidatorV2_',
    'calcSatelliteFailureGate_','calcSatelliteAggregatePnlGate_',
    'calcClaRegimeExitCondition_','calcEventRiskHoldGate_',
    'calcSectorRotationMomentum_','calcAlphaShield_',
    'calcTradeQualityScorer_','calcPatternBlacklistAuto_','calcAlphaFeedbackLoop_',
    'calcRelativeStopSignal_',
    // Proposal51 신규
    'calcSellPriceSanityV2_','syncSemiconductorCluster_',
    'calcProactiveSellRadarV2_',
    'applyPriceHierarchyLockAll_','calcDataQualityGateV2_','calcCashRecoveryDisplayLock_',
    'calcFundamentalQualityGateV1_','calcHorizonAllocationLockV1_',
    'calcSmartMoneyLiquidityGateV1_','buildRoutingServingTraceV2_',
    'calcFundamentalMultiFactorScoreV2_','calcEarningsGrowthQualityGateV1_',
    'calcMarketShareMomentumProxyV1_','calcCashflowStabilityGateV1_',
    'calcRoutingExplainLockV1_',
  ];

  var implSet = {};
  IMPLEMENTED_HARDLIST.forEach(function(f) { implSet[f] = true; });

  var gaps = REQUIRED.filter(function(req) { return !implSet[req.gs]; });
  var implCount = REQUIRED.length - gaps.length;
  var coveragePct = Math.round(implCount / REQUIRED.length * 1000) / 10;

  var result = {
    total_required:  REQUIRED.length,
    implemented:     implCount,
    coverage_pct:    coveragePct,
    gaps:            gaps.map(function(g) { return { yaml: g.yaml, gs: g.gs }; }),
    coverage_label:  coveragePct >= 95 ? 'FULL'
      : coveragePct >= 80              ? 'HIGH'
      : coveragePct >= 60              ? 'MEDIUM'
      : 'LOW',
    formula_id:      'YAML_GAS_COVERAGE_AUDIT_ENGINE_V1'
  };

  Logger.log('[COVERAGE_AUDIT] ' + coveragePct + '% (' + implCount + '/' + REQUIRED.length + ')'
    + (gaps.length > 0 ? ' GAPS: ' + gaps.map(function(g){ return g.yaml; }).join(',') : ''));

  // settings 탭에 기록
  try {
    var ss = getSpreadsheet_();
    var sh = ss.getSheetByName('settings');
    if (sh) {
      var data = sh.getDataRange().getValues();
      var found = false;
      for (var i = 0; i < data.length; i++) {
        if (String(data[i][0]) === 'coverage_pct') {
          sh.getRange(i + 1, 2).setValue(coveragePct);
          found = true;
          break;
        }
      }
      if (!found) {
        sh.appendRow(['coverage_pct', coveragePct, 'YAML↔GAS 커버리지 %', new Date().toISOString()]);
      }
    }
  } catch(e) {
    Logger.log('[COVERAGE_AUDIT] settings 탭 기록 실패: ' + e.message);
  }

  return result;
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P0-B: MACRO_REGIME_ADAPTIVE_GATE_V2 (MRAG-V2)
// 거시·이벤트 위험도 4레이어 → heat_gate_threshold / position_size_scale 동적 조정
// Direction ME2: effective_heat_gate_threshold = ME1 + MRAG-V2 중 더 엄격한 값
// ═══════════════════════════════════════════════════════════════════════

/**
 * calcMacroRegimeAdaptiveGate_
 * LAYER_1 미시(Market Internals) + LAYER_2 거시(Macro) + LAYER_3 글로벌 + LAYER_4 이벤트
 * total_mrag_score 0~100 → heat_gate_threshold / position_size_scale 결정론적 조정
 */
function calcMacroRegimeAdaptiveGate_(macroJson, mesResult, hApex) {
  return calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex);
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P1-A: ANTI_LATE_ENTRY_GATE V2.1 — GATE_4/GATE_5 추가
// 뒷박 원천 차단 5게이트 완성 (기존 V2의 3게이트 → 5게이트)
// ═══════════════════════════════════════════════════════════════════════

/**
 * applyAlegGate4And5_
 * alegRows에 GATE_4(PAE연동) + GATE_5(블랙리스트) 추가.
 * Direction A2: BLOCK if ANY gate(1~5)=BLOCK
 */
function applyAlegGate4And5_(alegRows, paeRows, hApex) {
  return applyAlegGate4And5Impl_(alegRows, paeRows, hApex);
}



// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P1-B: DISTRIBUTION_SELL_DETECTOR V1.1 — SIG_7/SIG_8
// 설거지 신호 6개 → 8개, weighted_sum 임계값 5.0/3.0 상향
// ═══════════════════════════════════════════════════════════════════════

/**
 * applyDsdV1_1Signals_
 * dsdRows에 SIG_7/SIG_8 추가 적용.
 * Direction B3: weighted_sum >= 5.0 → DISTRIBUTION_CONFIRMED
 */
function applyDsdV1_1Signals_(dsdRows, dfMap) {
  (dsdRows || []).forEach(function(dsdRow) {
    var df     = dfMap[dsdRow.ticker] || {};
    var close_ = toNumber_(df['Close'] || df.close) || 0;
    var open_  = toNumber_(df['Open']  || df.open)  || 0;

    // SIG_7: 연속 양봉 후 음봉 반전 (w=1.5)
    var prev3Bull = df['Prev3D_AllBullish'] === true
      || String(df['Prev3D_AllBullish'] || '').toUpperCase() === 'TRUE';
    var todayBear = close_ < open_ && close_ > 0 && open_ > 0;
    var sig7 = prev3Bull && todayBear;
    dsdRow.sig_7_reversal = sig7;
    if (sig7) dsdRow.weighted_sum = (toNumber_(dsdRow.weighted_sum) || 0) + 1.5;

    // SIG_8: 개인집중유입 + 기관매도 (w=1.5) — 데이터 없으면 w=0
    var retailR = toNumber_(df['Retail_Buy_Ratio_5D'] || df.retail_buy_ratio_5d) || 0;
    var instS   = toNumber_(df['Inst_5D'] || df.inst_5d) || 0;
    var sig8    = retailR > 0.70 && instS < 0;
    dsdRow.sig_8_retail_inflow = sig8;
    if (sig8) dsdRow.weighted_sum = (toNumber_(dsdRow.weighted_sum) || 0) + 1.5;

    // V1.1 임계값 재적용
    var ws = toNumber_(dsdRow.weighted_sum) || 0;
    dsdRow.distribution_verdict = ws >= 5.0 ? 'DISTRIBUTION_CONFIRMED'
      : ws >= 3.0                            ? 'DISTRIBUTION_WARNING'
      : 'NO_SIGNAL';

    // 조기 경보 V2: (SIG_1 OR SIG_2) + RSI14 >= 70
    var rsi14 = toNumber_(df['RSI14'] || df.rsi14) || 0;
    dsdRow.early_warning_v2 = (dsdRow.sig_1 || dsdRow.sig_2) && rsi14 >= 70;
    dsdRow.dsd_version = 'V1.1';
  });
  return dsdRows;
}


// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P1-C: MANDATORY_REDUCTION_PLAN_V1
// 반도체 클러스터 한도 2배 초과 → 4주 의무 감축 계획 결정론적 산출
// ═══════════════════════════════════════════════════════════════════════

/**
 * calcMandatoryReductionPlan_
 * Direction O2: mandatory_reduction_json을 하네스 확정값으로 잠금.
 */
function calcMandatoryReductionPlan_(semiconductorClusterGate, holdings, dfMap, h3, totalAsset) {
  function toDateYmd_(v) {
    if (!v) return null;
    if (typeof v === 'string') return v.slice(0, 10);
    if (Object.prototype.toString.call(v) === '[object Date]' && !isNaN(v.getTime())) {
      return Utilities.formatDate(v, 'Asia/Seoul', 'yyyy-MM-dd');
    }
    return null;
  }

  // [PROPOSAL51-FIX] calcSemiconductorClusterGate_ 반환키는 combined_pct (cluster_pct 아님)
  var clusterPct   = toNumber_((semiconductorClusterGate || {}).combined_pct
                             || (semiconductorClusterGate || {}).cluster_pct) || 0;
  var clusterLimit = toNumber_((semiconductorClusterGate || {}).cap_pct
                             || (semiconductorClusterGate || {}).cluster_limit_pct) || 25;

  if (clusterPct <= clusterLimit * 2.0) {
    return { is_mandatory: false, cluster_pct: clusterPct, cluster_limit_pct: clusterLimit,
             formula_id: 'MANDATORY_REDUCTION_PLAN_V1' };
  }

  var excessPct          = clusterPct - clusterLimit;
  var weeklyReducPct     = Math.ceil(excessPct / 4 * 10) / 10;
  var weeklyReducKrw     = Math.round(totalAsset * weeklyReducPct / 100);
  var SEMI_TICKERS       = ['005930','000660','229200','091160'];
  var holdMap            = {};
  (holdings || []).forEach(function(h) { holdMap[h.ticker] = h; });
  var sellQtyMap         = {};
  ((h3 && h3.sellQty) || []).forEach(function(sq) { sellQtyMap[sq.ticker] = sq; });

  var reduction = [];
  // 1순위: RS_BROKEN
  (holdings || []).filter(function(h) {
    var df = dfMap[h.ticker] || {};
    return SEMI_TICKERS.indexOf(h.ticker) >= 0
      && String(df['RS_Verdict'] || df.rs_verdict || '').toUpperCase() === 'BROKEN';
  }).forEach(function(h) {
    reduction.push({ priority: 1, reason: 'RS_BROKEN', ticker: h.ticker, name: h.name || '',
      suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null });
  });
  // 2순위: ETF
  (holdings || []).filter(function(h) {
    return SEMI_TICKERS.indexOf(h.ticker) >= 0
      && (h.name && (h.name.indexOf('KODEX') >= 0 || h.name.indexOf('TIGER') >= 0
                  || h.name.indexOf('ETF') >= 0 || h.ticker === '229200'));
  }).filter(function(h) { return !reduction.some(function(r) { return r.ticker === h.ticker; }); })
  .forEach(function(h) {
    reduction.push({ priority: 2, reason: 'ETF_PREFERRED', ticker: h.ticker, name: h.name || '',
      suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null });
  });
  // 3순위: APEX_SUPER
  (holdings || []).filter(function(h) {
    var df = dfMap[h.ticker] || {};
    return SEMI_TICKERS.indexOf(h.ticker) >= 0
      && String(df['Profit_Lock_Stage'] || df.profit_lock_stage || '').toUpperCase() === 'APEX_SUPER';
  }).filter(function(h) { return !reduction.some(function(r) { return r.ticker === h.ticker; }); })
  .forEach(function(h) {
    reduction.push({ priority: 3, reason: 'APEX_SUPER_TRAILING', ticker: h.ticker, name: h.name || '',
      suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null });
  });

  var completeDate = addBusinessDays_(new Date(), 20);  // 4주 × 5영업일

  return {
    is_mandatory:                true,
    cluster_pct:                 clusterPct,
    cluster_limit_pct:           clusterLimit,
    current_excess_pct:          Math.round(excessPct * 10) / 10,
    weekly_reduction_target_pct: weeklyReducPct,
    weekly_reduction_target_krw: weeklyReducKrw,
    weeks_to_normalize:          4,
    estimated_completion_date:   toDateYmd_(completeDate),
    reduction_priority:          reduction,
    formula_id:                  'MANDATORY_REDUCTION_PLAN_V1'
  };
}

// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P0-C: SEMICONDUCTOR_CLUSTER_SYNC_V1
// cluster gate ↔ mandatory_reduction_plan 단일 소스 동기화
// ═══════════════════════════════════════════════════════════════════════

/**
 * syncSemiconductorCluster_
 * SEMICONDUCTOR_CLUSTER_SYNC_V1: cluster_gate ↔ mandatory_reduction_json 정합성 검증 및 자동 교정
 * - combined_pct > cap_pct * 2이면 is_mandatory=true 강제
 * - combined_pct <= cap_pct * 2이면 is_mandatory=false 강제
 * @param {Object} hApex — mandatory_reduction_json 포함
 * @return {{ status, corrected, before_is_mandatory, after_is_mandatory, cluster_pct, threshold_pct }}
 */
function syncSemiconductorCluster_(hApex) {
  var mrj = (hApex && hApex.mandatory_reduction_json) || {};
  var clusterPct  = toNumber_(mrj.cluster_pct)       || 0;
  var clusterLimit = toNumber_(mrj.cluster_limit_pct) || 25;
  var threshold    = clusterLimit * 2.0;
  var shouldBeMandatory = clusterPct > threshold;
  var wasMandatory      = mrj.is_mandatory === true;

  var syncStatus, corrected;
  if (shouldBeMandatory === wasMandatory) {
    syncStatus = 'SYNCED';
    corrected  = false;
  } else {
    syncStatus = 'CORRECTED';
    corrected  = true;
    // 인라인 교정
    mrj.is_mandatory = shouldBeMandatory;
    if (shouldBeMandatory) {
      // 의무 감축 활성화 시 최소 필드 보장
      mrj.current_excess_pct = Math.round((clusterPct - clusterLimit) * 10) / 10;
    } else {
      // 의무 감축 비활성화 — 세부 필드 제거
      delete mrj.current_excess_pct;
      delete mrj.weekly_reduction_target_pct;
      delete mrj.weekly_reduction_target_krw;
      delete mrj.reduction_priority;
    }
    hApex.mandatory_reduction_json = mrj;
    Logger.log('[SCRSV1] CLUSTER_SYNC 교정: is_mandatory ' + wasMandatory
      + ' → ' + shouldBeMandatory + ' (cluster=' + clusterPct + '%, threshold=' + threshold + '%)');
  }

  return {
    formula_id:           'SEMICONDUCTOR_CLUSTER_SYNC_V1',
    status:               syncStatus,
    corrected:            corrected,
    cluster_pct:          clusterPct,
    threshold_pct:        threshold,
    cap_pct:              clusterLimit,
    before_is_mandatory:  wasMandatory,
    after_is_mandatory:   shouldBeMandatory
  };
}


/**
 * HS007: validateOrderCondition_
 * 주문 조건 텍스트에 다중 조건 접속사가 포함되면 INVALID_MULTI_CONDITION 반환.
 * HTS 자동주문은 단일 지정가만 허용 — 접속사 복합 조건은 HTS 오입력 원인.
 */
function validateOrderCondition_(text) {
  if (!text || typeof text !== 'string') {
    return { valid: true, status: 'OK', matched_conjunctions: [], formula_id: 'VALIDATE_ORDER_CONDITION_V1' };
  }
  var MULTI_CONDITION_PATTERNS = [
    '또는', '혹은', '동시 충족', '동시충족',
    '실패 시', '실패시', '회복 실패', '회복실패',
    '돌파 실패', '돌파실패', '이탈 또는', '초과 또는',
    '또는 이하', '또는 이상', '이거나', '이면서'
  ];
  var matched = MULTI_CONDITION_PATTERNS.filter(function(p) {
    return text.indexOf(p) >= 0;
  });
  if (matched.length > 0) {
    return {
      valid:                false,
      status:               'INVALID_MULTI_CONDITION',
      matched_conjunctions: matched,
      resolution:           '단일 가격 조건만 기재 (예: "종가 196,500원 이탈 시")',
      formula_id:           'VALIDATE_ORDER_CONDITION_V1'
    };
  }
  return { valid: true, status: 'OK', matched_conjunctions: [], formula_id: 'VALIDATE_ORDER_CONDITION_V1' };
}

/**
 * H10 (HS010_REVISED): buildShadowLedger_
 * BLOCKED/INVALID 블루프린트를 그림자 원장으로 분리.
 * 차단 여부와 무관하게 산출 지표를 투명하게 보존 — 사용자의 사후 평가·오버라이드 지원.
 */
function buildShadowLedger_(blueprints, dfMap) {
  // THIN_ADAPTER: [stop_loss/sizing/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:check_sell_price_sanity
  dfMap = dfMap || {};
  var ledger = [];
  var bpRows = Array.isArray(blueprints) ? blueprints : [];
  bpRows.forEach(function(bp) {
    var isBlocked = bp.validation_status === 'BLOCKED'
                 || bp.validation_status === 'INVALID'
                 || String(bp.validation_status || '').indexOf('INVALID') === 0;
    if (!isBlocked) return;
    var df = dfMap[bp.ticker] || {};
    ledger.push({
      ticker:              bp.ticker,
      name:                bp.name || df.name || '',
      block_reason:        bp.rationale_code || bp.validation_status || 'BLOCKED',
      order_type:          bp.order_type || '',
      limit_price_calc:    bp.limit_price   || null,
      ["stop_loss_calc"]:      bp["stop_loss"]     || df["stop_loss_price"] || null,
      ["take_profit_calc"]:    bp["take_profit"]   || df["tp1_price"] || null,
      base_qty_calc:       bp.qty           || df.base_qty || null,
      value_at_risk_krw:   bp.value_at_risk_krw || null,
      override_possible:   true,
      formula_id:          'SHADOW_LEDGER_V1'
    });
  });
  return {
    shadow_ledger:   ledger,
    blocked_count:   ledger.length,
    formula_id:      'SHADOW_LEDGER_V1'
  };
}

/**
 * D2: calcLlmServingConstraint_
 * LLM 12가지 금지행동 체크리스트 — 보고서 조립 직전 실행.
 * 하나라도 위반 가능성이 있으면 INVALID_LLM_OVERRIDE 태그를 반환하여 보고서에 표기.
 */
function calcLlmServingConstraint_(hApex) {
  var h = hApex || {};
  var violations = [];

  // Check 1: 미등록 공식 사용 가능성 — serving_lock_json numeric_generation_allowed
  var sLock = h.serving_lock_json || {};
  var budget = sLock.llm_serving_budget || {};
  if (budget.numeric_generation_allowed !== 0) {
    violations.push({ check: 1, rule: '미등록 공식으로 지정가/수량 산출', status: 'WARN_NOT_LOCKED' });
  }

  // Check 2: BLOCK 판정 우회 — hts_entry_allowed=false인데 blueprint PASS 존재 불가
  var exportGate = h.export_gate_json || {};
  if (exportGate.hts_entry_allowed === false) {
    var blueprints = h.order_blueprint_json || [];
    var passCount = (Array.isArray(blueprints) ? blueprints : []).filter(function(b) {
      return b.validation_status === 'PASS';
    }).length;
    if (passCount > 0) {
      violations.push({ check: 2, rule: 'hts_entry_allowed=false 상태에서 PASS blueprint 존재', status: 'VIOLATION' });
    }
  }

  // Check 3: SELL_PRICE_SANITY INVALID 가격 복원 위험 — INVALID 종목이 shadow_ledger에 없으면 경고
  var shadowLedger = h.shadow_ledger_json || {};
  var invalidBlueprints = (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : [])
    .filter(function(b) { return String(b.validation_status || '').indexOf('INVALID') === 0; });
  if (invalidBlueprints.length > 0 && (!shadowLedger.blocked_count || shadowLedger.blocked_count === 0)) {
    violations.push({ check: 3, rule: 'INVALID blueprint가 Shadow Ledger에 미포함', status: 'VIOLATION' });
  }

  // Check 5: K2 반등 대기 수량 — scrs_v2_json에 rebound_wait_qty가 있으면 분리 표기 의무
  var scrs = h.scrs_v2_json || {};
  var selectedCombo = Array.isArray(scrs.selected_combo) ? scrs.selected_combo : [];
  if (selectedCombo.length > 0) {
    var hasRebound = selectedCombo.some(function(c) { return c.rebound_wait_qty > 0; });
    if (hasRebound && !scrs._display_split_confirmed) {
      violations.push({ check: 5, rule: 'K2 rebound_wait_qty 분리 미표기 위험', status: 'WARN' });
    }
  }

  // Check 9: consistency_score < 90이면 보고서 계속 생성 금지
  var asResult = h.account_snapshot_result || {};
  var cScore = asResult.consistency_score;
  if (typeof cScore === 'number' && cScore < 90) {
    violations.push({ check: 9, rule: 'consistency_score=' + cScore + ' < 90 (ABORT 필요)', status: 'VIOLATION' });
  }

  // Check 10: mega_sell_alert=TRUE이면 BUY/ADD_ON 금지
  var macroJson = h.macro_event_json || {};
  if (macroJson.mega_sell_alert === true || macroJson.mega_sell_alert === 'TRUE') {
    var buyBlueprints = (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : [])
      .filter(function(b) { return b.order_type === 'BUY' || b.order_type === 'ADD_ON'; });
    if (buyBlueprints.length > 0) {
      violations.push({ check: 10, rule: 'mega_sell_alert=TRUE 상태에서 BUY/ADD_ON blueprint 존재', status: 'VIOLATION' });
    }
  }

  // Check 11: synthesis_verdict=BEARISH 종목에 BUY 금지
  var paeRows = h.predictive_alpha_json || [];
  var bearishTickers = (Array.isArray(paeRows) ? paeRows : [])
    .filter(function(r) { return r.synthesis_verdict === 'BEARISH'; })
    .map(function(r) { return r.ticker; });
  if (bearishTickers.length > 0) {
    (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : []).forEach(function(b) {
      if ((b.order_type === 'BUY' || b.order_type === 'ADD_ON') && bearishTickers.indexOf(b.ticker) >= 0) {
        violations.push({ check: 11, rule: 'synthesis_verdict=BEARISH 종목 BUY blueprint: ' + b.ticker, status: 'VIOLATION' });
      }
    });
  }

  var constraintStatus = violations.some(function(v) { return v.status === 'VIOLATION'; })
    ? 'INVALID_LLM_OVERRIDE' : violations.length > 0 ? 'WARN' : 'PASS';

  return {
    constraint_status:  constraintStatus,
    violations:         violations,
    violation_count:    violations.filter(function(v) { return v.status === 'VIOLATION'; }).length,
    warn_count:         violations.filter(function(v) { return v.status === 'WARN' || v.status === 'WARN_NOT_LOCKED'; }).length,
    total_checks:       12,
    formula_id:         'LLM_SERVING_CONSTRAINT_V1'
  };
}

/**
 * H6: calcAvgTradeValueSignal_
 * secular_leader(005930·000660) PROFIT_LOCK_STAGE_20 구간에서
 * 5일 평균 거래대금 > 20일 평균 × 3.0이면 과열신호 +1 판정.
 */
function calcAvgTradeValueSignal_(ticker, df) {
  df = df || {};
  var SECULAR_TICKERS = ['005930', '000660'];
  var isSecular = SECULAR_TICKERS.indexOf(String(ticker || '')) >= 0;
  var stage = String(df.profit_lock_stage || df.Profit_Lock_Stage || '').toUpperCase();
  var avgVal5d  = toNumber_(df.avg_trade_val_5d  || df.avgTradeVal5d)  || 0;
  var avgVal20d = toNumber_(df.avg_trade_val_20d || df.avgTradeVal20d) || 0;

  if (!isSecular || stage !== 'PROFIT_LOCK_20' || avgVal20d <= 0) {
    return {
      ticker:              ticker,
      applicable:          false,
      signal:              'NOT_APPLICABLE',
      avg_trade_val_5d:    avgVal5d,
      avg_trade_val_20d:   avgVal20d,
      overheat_triggered:  false,
      formula_id:          'AVG_TRADE_VALUE_SIGNAL_V1'
    };
  }

  var ratio = avgVal5d / avgVal20d;
  var overheat = ratio >= 3.0;
  return {
    ticker:              ticker,
    applicable:          true,
    signal:              overheat ? 'OVERHEAT_TRADE_VALUE' : 'NORMAL',
    avg_trade_val_5d:    avgVal5d,
    avg_trade_val_20d:   avgVal20d,
    ratio_5d_vs_20d:     Math.round(ratio * 100) / 100,
    overheat_triggered:  overheat,
    overheat_score_add:  overheat ? 1 : 0,
    threshold:           3.0,
    formula_id:          'AVG_TRADE_VALUE_SIGNAL_V1'
  };
}

/**
 * G2: calcTrimPlanMinCash_
 * 최소 현금(cash_floor) 달성을 위한 결정론적 TRIM 계획 산출.
 * H2 매도후보 순위(sell_priority) 그대로 종목 순서를 결정 — LLM 임의 선택 금지.
 */
function calcTrimPlanMinCash_(holdings, dfMap, cashShortfallInfo, sellPriorityList) {
  dfMap = dfMap || {};
  var shortfall = toNumber_((cashShortfallInfo || {}).cash_shortfall_min_krw) || 0;
  var plan = [];
  var accumulatedKrw = 0;
  var holdingRows = Array.isArray(holdings) ? holdings : [];
  var priorityRows = Array.isArray(sellPriorityList) ? sellPriorityList : [];

  priorityRows.forEach(function(sp) {
    if (accumulatedKrw >= shortfall) return;
    var h = holdingRows.find(function(x) { return x.ticker === sp.ticker; }) || {};
    var df = dfMap[sp.ticker] || {};
    var avgCost = toNumber_(h.avg_cost || h.average_cost) || 0;
    var qty = toNumber_(h.qty || h.quantity) || 0;

    if (qty === 0 || avgCost === 0) {
      plan.push({
        priority:          sp.priority || plan.length + 1,
        ticker:            sp.ticker,
        name:              sp.name || df.name || '',
        sell_qty:          'CAPTURE_REQUIRED',
        estimated_sell_krw: 0,
        sell_price_ref:    null,
        accumulated_krw:   accumulatedKrw,
        shortfall_covered: false,
        note:              'CAPTURE_REQUIRED: qty/cost 미확정'
      });
      return;
    }

    var closePrice = toNumber_(df.close || df.close_price) || avgCost;
    var remaining = shortfall - accumulatedKrw;
    var neededQty = Math.ceil(remaining / closePrice);
    var sellQty = Math.min(neededQty, qty);
    var estimatedKrw = sellQty * closePrice;
    accumulatedKrw += estimatedKrw;

    plan.push({
      priority:          sp.priority || plan.length + 1,
      ticker:            sp.ticker,
      name:              sp.name || df.name || '',
      sell_qty:          sellQty,
      estimated_sell_krw: Math.round(estimatedKrw),
      sell_price_ref:    closePrice,
      accumulated_krw:   Math.round(accumulatedKrw),
      shortfall_covered: accumulatedKrw >= shortfall,
      note:              accumulatedKrw >= shortfall ? 'SHORTFALL_MET' : 'PARTIAL'
    });
  });

  return {
    cash_shortfall_min_krw:  Math.round(shortfall),
    plan:                    plan,
    total_plan_krw:          Math.round(accumulatedKrw),
    shortfall_fully_covered: accumulatedKrw >= shortfall,
    is_plan_only:            true,
    hts_order_required:      'order_blueprint_json.validation_status 기준으로만 판단',
    formula_id:              'TRIM_PLAN_MIN_CASH_V1'
  };
}


// ═══════════════════════════════════════════════════════════════════════════════
// [PROPOSAL50] F1 — TRADE_QUALITY_SCORER_V1
// 실행된 매수·매도를 T+5/T+20 기준으로 자동 채점.
// trade_quality_history 시트를 읽어 미채점 레코드를 업데이트하고 결과 배열 반환.
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * calcTradeQualityScorer_
 * trade_quality_history 시트에서 미채점 레코드를 배치 처리.
 * BUY: velocity/ma20/volume/t5/t20 각 20점 합산 (100점 만점)
 * SELL: above_ma20/above_cost/not_too_early/cash_goal_met 각 25점 합산 (100점 만점)
 */
function calcTradeQualityScorer_(ss) {
  try {
    ss = ss || getSpreadsheet_();
    var sh = ss.getSheetByName('trade_quality_history');
    if (!sh) {
      Logger.log('[F1] trade_quality_history 시트 없음');
      return { status: 'SHEET_NOT_FOUND', scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
    }

    var data = sh.getDataRange().getValues();
    if (data.length < 2) {
      return { status: 'NO_DATA', scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
    }

    var header = data[0];
    var COL = {};
    header.forEach(function(h, i) { COL[String(h).trim()] = i; });

    // 필수 컬럼 확인
    var REQ = ['ticker', 'action', 'scored'];
    for (var ri = 0; ri < REQ.length; ri++) {
      if (COL[REQ[ri]] == null) {
        Logger.log('[F1] 필수 컬럼 누락: ' + REQ[ri]);
        return { status: 'COLUMN_MISSING', missing: REQ[ri], scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
      }
    }

    // 현재 종가 맵 (T+5/T+20 평가용)
    var priceMap = {};
    var dfSheet = ss.getSheetByName('data_feed');
    if (dfSheet) {
      var dfData = dfSheet.getDataRange().getValues();
      if (dfData.length > 1) {
        var dfHeader = dfData[0];
        var tCol = dfHeader.indexOf('Ticker');
        var cCol = dfHeader.indexOf('Close');
        if (tCol >= 0 && cCol >= 0) {
          for (var dri = 1; dri < dfData.length; dri++) {
            var tk = String(dfData[dri][tCol] || '').trim();
            var cl = parseFloat(String(dfData[dri][cCol] || ''));
            if (tk && !isNaN(cl) && cl > 0) priceMap[tk] = cl;
          }
        }
      }
    }

    var todayMs = new Date().getTime();
    var scoredResults = [];
    var scoredThisRun = 0;

    for (var i = 1; i < data.length; i++) {
      var row = data[i];
      var alreadyScored = String(row[COL['scored']] || '').toUpperCase();
      if (alreadyScored === 'TRUE' || alreadyScored === 'SCORED') continue;

      var ticker = String(row[COL['ticker']] || '').trim();
      var action = String(row[COL['action']] || '').toUpperCase();
      if (!ticker) continue;

      var entryDate = row[COL['entry_date'] != null ? COL['entry_date'] : -1];
      var daysSinceEntry = entryDate ? (todayMs - new Date(entryDate).getTime()) / 86400000 : 0;

      // T+5 이상 경과해야 채점 (T+20 필드는 optional)
      if (COL['entry_date'] != null && daysSinceEntry < 7) continue;

      var score = 0;
      var subscores = {};
      var feedbackTag = 'GOOD_EXECUTION';

      if (action === 'BUY') {
        // 매수 품질 채점
        var velocity1d   = parseFloat(String(row[COL['velocity_1d_at_entry'] != null ? COL['velocity_1d_at_entry'] : -1] || ''));
        var entryPrice   = parseFloat(String(row[COL['entry_price']  != null ? COL['entry_price']  : -1] || ''));
        var ma20Entry    = parseFloat(String(row[COL['ma20_at_entry'] != null ? COL['ma20_at_entry'] : -1] || ''));
        var volRatio     = parseFloat(String(row[COL['volume_ratio_at_entry'] != null ? COL['volume_ratio_at_entry'] : -1] || ''));
        var t5RetPct     = parseFloat(String(row[COL['t5_return_pct']  != null ? COL['t5_return_pct']  : -1] || ''));
        var t20VsCore    = parseFloat(String(row[COL['t20_vs_core_pctp'] != null ? COL['t20_vs_core_pctp'] : -1] || ''));

        // velocity_ok: 진입일 속도 < 1% (추격 아님)
        if (!isNaN(velocity1d) && velocity1d < 1) { score += 20; subscores.velocity_ok = 20; }
        else subscores.velocity_ok = 0;

        // ma20_proximity: 진입가 ≤ MA20 × 1.01
        if (!isNaN(entryPrice) && !isNaN(ma20Entry) && ma20Entry > 0 && entryPrice <= ma20Entry * 1.01) {
          score += 20; subscores.ma20_proximity = 20;
        } else subscores.ma20_proximity = 0;

        // volume_confirm: 거래량비율 ≥ 1.2
        if (!isNaN(volRatio) && volRatio >= 1.2) { score += 20; subscores.volume_confirm = 20; }
        else subscores.volume_confirm = 0;

        // t5_positive: T+5 수익률 > 0
        if (!isNaN(t5RetPct) && t5RetPct > 0) { score += 20; subscores.t5_positive = 20; }
        else subscores.t5_positive = 0;

        // t20_alpha: T+20 대비 코어 초과 > 0
        if (!isNaN(t20VsCore) && t20VsCore > 0) { score += 20; subscores.t20_alpha = 20; }
        else subscores.t20_alpha = 0;

        // 피드백 태그
        if (subscores.velocity_ok === 0 && subscores.ma20_proximity === 0) feedbackTag = 'CHASE_ENTRY';
        else if (subscores.t5_positive === 0 && subscores.t20_alpha === 0) feedbackTag = 'DISTRIBUTION_ENTRY';

      } else if (action === 'SELL') {
        // 매도 품질 채점
        var sellPrice    = parseFloat(String(row[COL['sell_price']    != null ? COL['sell_price']    : -1] || ''));
        var ma20Sell     = parseFloat(String(row[COL['ma20_at_sell']  != null ? COL['ma20_at_sell']  : -1] || ''));
        var avgCost      = parseFloat(String(row[COL['average_cost']  != null ? COL['average_cost']  : -1] || ''));
        var priceT5After = parseFloat(String(row[COL['price_t5_after_sell'] != null ? COL['price_t5_after_sell'] : -1] || ''));
        var cashRecov    = parseFloat(String(row[COL['cash_recovered_krw']  != null ? COL['cash_recovered_krw']  : -1] || ''));
        var cashGoal     = parseFloat(String(row[COL['cash_shortfall_min_krw'] != null ? COL['cash_shortfall_min_krw'] : -1] || ''));

        // above_ma20: 매도가 ≥ MA20 × 0.99
        if (!isNaN(sellPrice) && !isNaN(ma20Sell) && ma20Sell > 0 && sellPrice >= ma20Sell * 0.99) {
          score += 25; subscores.above_ma20 = 25;
        } else subscores.above_ma20 = 0;

        // above_cost: 매도가 ≥ 평단
        if (!isNaN(sellPrice) && !isNaN(avgCost) && avgCost > 0 && sellPrice >= avgCost) {
          score += 25; subscores.above_cost = 25;
        } else subscores.above_cost = 0;

        // not_too_early: T+5 사후 종가가 없거나 매도가 이상
        if (isNaN(priceT5After) || priceT5After <= sellPrice) {
          score += 25; subscores.not_too_early = 25;
        } else subscores.not_too_early = 0;

        // cash_goal_met: 실제 회수액 ≥ 목표 부족분
        if (!isNaN(cashRecov) && !isNaN(cashGoal) && cashGoal > 0 && cashRecov >= cashGoal) {
          score += 25; subscores.cash_goal_met = 25;
        } else subscores.cash_goal_met = 0;

        // 피드백 태그
        if (subscores.above_cost === 0) feedbackTag = 'PANIC_EXIT';
        else if (subscores.not_too_early === 0) feedbackTag = 'OVERSOLD_PANIC';
      } else {
        continue; // BUY/SELL 이외 레코드 스킵
      }

      // 등급 결정
      var grade;
      if      (score >= 90) grade = 'EXCELLENT';
      else if (score >= 75) grade = 'GOOD';
      else if (score >= 60) grade = 'ACCEPTABLE';
      else if (score >= 40) grade = 'POOR';
      else                  grade = 'CRITICAL';

      if (grade === 'POOR' || grade === 'CRITICAL') {
        feedbackTag = score < 40 ? 'PATTERN_ALERT' : 'CHASE_ENTRY_OR_PANIC_EXIT';
      } else if (grade === 'EXCELLENT' || grade === 'GOOD') {
        feedbackTag = 'GOOD_EXECUTION';
      }

      // 시트 업데이트
      var scoreCol  = COL['score']        != null ? COL['score']        + 1 : null;
      var gradeCol  = COL['grade']        != null ? COL['grade']        + 1 : null;
      var fbTagCol  = COL['feedback_tag'] != null ? COL['feedback_tag'] + 1 : null;
      var scoredCol = COL['scored']       != null ? COL['scored']       + 1 : null;

      if (scoreCol)  sh.getRange(i + 1, scoreCol).setValue(score);
      if (gradeCol)  sh.getRange(i + 1, gradeCol).setValue(grade);
      if (fbTagCol)  sh.getRange(i + 1, fbTagCol).setValue(feedbackTag);
      if (scoredCol) sh.getRange(i + 1, scoredCol).setValue('SCORED');

      scoredResults.push({
        row:          i,
        ticker:       ticker,
        action:       action,
        score:        score,
        grade:        grade,
        feedback_tag: feedbackTag,
        subscores:    subscores,
        formula_id:   'TRADE_QUALITY_SCORER_V1'
      });
      scoredThisRun++;
    }

    // 전체 기록 집계 (기존 채점 포함)
    var allResults = [];
    var freshData = sh.getDataRange().getValues();
    for (var j = 1; j < freshData.length; j++) {
      var r = freshData[j];
      var sc = String(r[COL['scored']] || '').toUpperCase();
      if (sc !== 'TRUE' && sc !== 'SCORED') continue;
      allResults.push({
        ticker:       String(r[COL['ticker']] || '').trim(),
        action:       String(r[COL['action']] || '').toUpperCase(),
        score:        parseFloat(String(r[COL['score']] || '')) || 0,
        grade:        String(r[COL['grade']] || 'UNKNOWN'),
        feedback_tag: String(r[COL['feedback_tag']] || '')
      });
    }

    Logger.log('[F1] calcTradeQualityScorer_ 완료: 이번 채점=' + scoredThisRun + '건, 전체=' + allResults.length + '건');

    // F2: F1 완료 직후 블랙리스트 자동 갱신 (F1 → F2 파이프라인)
    try {
      calcPatternBlacklistAuto_(allResults);
    } catch (pbErr) {
      Logger.log('[F1] calcPatternBlacklistAuto_ 연동 오류: ' + pbErr.message);
    }

    var f1Result = {
      status:        'OK',
      scored_count:  scoredThisRun,
      total_records: allResults.length,
      trade_quality: allResults,
      last_computed: new Date().toISOString(),
      formula_id:    'TRADE_QUALITY_SCORER_V1'
    };

    // settings 시트에 trade_quality_json 캐시 저장 (harness_rows 일간 출력용)
    // 셀 50K 한도 초과 방지: trade_quality 최근 100건만 저장
    try {
      var setSh = ss.getSheetByName('settings');
      if (setSh) {
        var sData = setSh.getDataRange().getValues();
        var updated = false;
        var f1Slim = Object.assign({}, f1Result,
          { trade_quality: (f1Result.trade_quality || []).slice(-100) });
        var serialized = JSON.stringify(f1Slim);
        for (var si = 0; si < sData.length; si++) {
          if (String(sData[si][0] || '').trim() === 'trade_quality_json') {
            setSh.getRange(si + 1, 2).setValue(serialized);
            updated = true;
            break;
          }
        }
        if (!updated) setSh.appendRow(['trade_quality_json', serialized]);
      }
    } catch(writeErr) {
      Logger.log('[F1] settings 시트 기록 실패: ' + writeErr.message);
    }

    return f1Result;
  } catch(e) {
    Logger.log('[F1] calcTradeQualityScorer_ 오류: ' + e.message);
    return { status: 'ERROR', error: e.message, scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
  }
}


// ═══════════════════════════════════════════════════════════════════════════════
// [PROPOSAL50] F2 — PATTERN_BLACKLIST_AUTO_V1
// 동일 ticker POOR/CRITICAL 3회 누적 → PATTERN_BLACKLIST_TRIGGERED
// 3회 연속 GOOD(75+) 달성 시 해제
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * calcPatternBlacklistAuto_
 * trade_quality_json 배열을 받아 ticker별 POOR/CRITICAL 누적 횟수를 계산.
 * 3회 이상이면 PATTERN_BLACKLIST_TRIGGERED, 3회 연속 GOOD 이상이면 해제.
 * 결과를 settings 시트의 pattern_blacklist_json에 기록.
 */
function calcPatternBlacklistAuto_(tradeQualityHistory) {
  try {
    var history = Array.isArray(tradeQualityHistory) ? tradeQualityHistory : [];

    // ticker별 그룹화
    var tickerMap = {};
    history.forEach(function(rec) {
      var tk = String(rec.ticker || '').trim();
      if (!tk) return;
      if (!tickerMap[tk]) tickerMap[tk] = [];
      tickerMap[tk].push({
        grade: String(rec.grade || '').toUpperCase(),
        score: typeof rec.score === 'number' ? rec.score : (parseFloat(String(rec.score || '')) || 0)
      });
    });

    var blacklistEntries = [];
    var triggeredCount = 0;

    Object.keys(tickerMap).forEach(function(ticker) {
      var records = tickerMap[ticker];

      // POOR/CRITICAL 누적 카운트
      var poorCriticalCount = records.filter(function(r) {
        return r.grade === 'POOR' || r.grade === 'CRITICAL';
      }).length;

      // 해제 조건: 마지막 3건이 모두 GOOD(75+) 이상
      var releaseMet = false;
      if (records.length >= 3) {
        var last3 = records.slice(-3);
        releaseMet = last3.every(function(r) {
          return (r.grade === 'GOOD' || r.grade === 'EXCELLENT') && r.score >= 75;
        });
      }

      var status;
      if (releaseMet && poorCriticalCount >= 3) {
        status = 'CLEAR';  // 블랙리스트 해제
      } else if (poorCriticalCount >= 3) {
        status = 'TRIGGERED';
        triggeredCount++;
      } else {
        status = 'CLEAR';
      }

      blacklistEntries.push({
        ticker:                   ticker,
        pattern_blacklist_status: status,
        accumulated_poor_count:   poorCriticalCount,
        total_records:            records.length,
        release_condition_met:    releaseMet,
        saqg_override:            status === 'TRIGGERED' ? 'EXCLUDED' : 'NO_CHANGE',
        alpha_score_cap:          status === 'TRIGGERED' ? 50 : null,
        formula_id:               'PATTERN_BLACKLIST_AUTO_V1'
      });
    });

    // settings 시트에 pattern_blacklist_json 기록 (wrapper 객체 형태로 저장)
    try {
      var ss = getSpreadsheet_();
      var settingSh = ss.getSheetByName('settings');
      if (settingSh) {
        var sData = settingSh.getDataRange().getValues();
        var updated = false;
        var wrapperObj = {
          status:          'OK',
          triggered_count: triggeredCount,
          total_tickers:   blacklistEntries.length,
          patterns:        blacklistEntries,
          pattern_count:   blacklistEntries.length,
          computed_at:     new Date().toISOString(),
          formula_id:      'PATTERN_BLACKLIST_AUTO_V1'
        };
        var serialized = JSON.stringify(wrapperObj);
        for (var si = 0; si < sData.length; si++) {
          if (String(sData[si][0] || '').trim() === 'pattern_blacklist_json') {
            settingSh.getRange(si + 1, 2).setValue(serialized);
            updated = true;
            break;
          }
        }
        if (!updated) settingSh.appendRow(['pattern_blacklist_json', serialized]);
      }
    } catch(writeErr) {
      Logger.log('[F2] settings 시트 기록 실패: ' + writeErr.message);
    }

    Logger.log('[F2] calcPatternBlacklistAuto_ 완료: TRIGGERED=' + triggeredCount + '/' + blacklistEntries.length + '건');
    return {
      status:           'OK',
      triggered_count:  triggeredCount,
      total_tickers:    blacklistEntries.length,
      patterns:         blacklistEntries,
      pattern_count:    blacklistEntries.length,
      formula_id:       'PATTERN_BLACKLIST_AUTO_V1'
    };
  } catch(e) {
    Logger.log('[F2] calcPatternBlacklistAuto_ 오류: ' + e.message);
    return { status: 'ERROR', error: e.message, triggered_count: 0, patterns: [], pattern_count: 0, formula_id: 'PATTERN_BLACKLIST_AUTO_V1' };
  }
}


// ═══════════════════════════════════════════════════════════════════════════════
// [PROPOSAL50] ALPHA_FEEDBACK_LOOP_V1
// monthly_history의 AEW_V1 성과 데이터를 분석해 SAQG_V1 필터 임계값 조정 권고 생성.
// 임계값 자동 변경 금지 — 권고(RECOMMENDATION)만 출력.
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * calcAlphaFeedbackLoop_
 * alpha_evaluation_window_json (AEW_V1 결과) 에서 ELIGIBLE 케이스를 분석해
 * SAQG F1/F2/F3 임계값 조정 권고를 생성한다.
 * 10건 미만이면 DATA_INSUFFICIENT — 권고 생성 금지.
 */
function calcAlphaFeedbackLoop_() {
  try {
    var ss = getSpreadsheet_();
    var aewRows = [];

    // monthly_history 시트에서 AEW 데이터 수집
    var mhSh = ss.getSheetByName('monthly_history');
    if (mhSh) {
      var mhData = mhSh.getDataRange().getValues();
      if (mhData.length > 1) {
        var mhHeader = mhData[0];
        var COL = {};
        mhHeader.forEach(function(h, i) { COL[String(h).trim()] = i; });

        for (var i = 1; i < mhData.length; i++) {
          var row = mhData[i];
          var saqg   = String(row[COL['saqg_v1'] != null ? COL['saqg_v1'] : -1] || '').toUpperCase();
          var t20Sam = parseFloat(String(row[COL['t20_vs_samsung_pctp'] != null ? COL['t20_vs_samsung_pctp'] : -1] || ''));
          var brtV   = String(row[COL['brt_verdict'] != null ? COL['brt_verdict'] : -1] || '').toUpperCase();
          var regime = String(row[COL['market_regime'] != null ? COL['market_regime'] : -1] || '');
          if (!saqg) continue;
          aewRows.push({ saqg_v1: saqg, t20_vs_samsung_pctp: isNaN(t20Sam) ? null : t20Sam, brt_verdict: brtV, market_regime: regime });
        }
      }
    }

    var eligibleRows = aewRows.filter(function(r) { return r.saqg_v1 === 'ELIGIBLE'; });
    var casesAnalyzed = eligibleRows.length;

    var now = new Date();
    var asOf = now.toISOString().split('T')[0];
    var analysisPeriod = asOf.substring(0, 7); // 'YYYY-MM'

    if (casesAnalyzed < 10) {
      Logger.log('[AFL] calcAlphaFeedbackLoop_: 데이터 부족(' + casesAnalyzed + '건) — 권고 생성 건너뜀');
      return {
        formula_id:                     'ALPHA_FEEDBACK_LOOP_V1',
        as_of:                          asOf,
        analysis_period:                analysisPeriod,
        status:                         'DATA_INSUFFICIENT',
        cases_analyzed:                 casesAnalyzed,
        grade_count:                    0,
        eligible_t20_fail_rate:         null,
        eligible_t60_fail_rate:         null,
        recommended_filter_adjustments: [],
        grade_summary:                  []
      };
    }

    // T+20 알파 실패율 계산 (t20_vs_samsung_pctp < -3)
    var t20WithData = eligibleRows.filter(function(r) { return r.t20_vs_samsung_pctp !== null; });
    var t20FailRows = t20WithData.filter(function(r) { return r.t20_vs_samsung_pctp < -3; });
    var t20PassRows = t20WithData.length - t20FailRows.length;
    var t20FailRate = t20WithData.length > 0
      ? Math.round(t20FailRows.length / t20WithData.length * 1000) / 10
      : null;
    var t20PassRate = t20WithData.length > 0
      ? Math.round(t20PassRows / t20WithData.length * 1000) / 10
      : null;

    // BRT_VERDICT=BROKEN 케이스 비율
    var brokenCount = eligibleRows.filter(function(r) { return r.brt_verdict === 'BROKEN'; }).length;
    var brokenRate  = eligibleRows.length > 0
      ? Math.round(brokenCount / eligibleRows.length * 1000) / 10 : 0;

    // grade_summary — saqg_v1 값별로 집계
    var gradeCounts = {};
    aewRows.forEach(function(r) {
      var g = r.saqg_v1 || 'UNKNOWN';
      if (!gradeCounts[g]) gradeCounts[g] = { t20_total: 0, t20_pass: 0, t20_fail: 0 };
      if (r.t20_vs_samsung_pctp !== null) {
        gradeCounts[g].t20_total++;
        if (r.t20_vs_samsung_pctp >= 0) gradeCounts[g].t20_pass++;
        else gradeCounts[g].t20_fail++;
      }
    });
    var gradeSummary = Object.keys(gradeCounts).map(function(g) {
      var gd = gradeCounts[g];
      var passRate = gd.t20_total > 0 ? Math.round(gd.t20_pass / gd.t20_total * 1000) / 10 : null;
      var failRate = gd.t20_total > 0 ? Math.round(gd.t20_fail / gd.t20_total * 1000) / 10 : null;
      return {
        grade:         g,
        t20_total:     gd.t20_total,
        t20_pass:      gd.t20_pass,
        t20_pass_rate: passRate,
        t20_fail_rate: failRate,
        t60_total:     0,    // T+60 데이터 미수집 — 향후 확장
        t60_pass:      0,
        t60_pass_rate: null,
        t60_fail_rate: null,
        status:        gd.t20_total === 0 ? 'DATA_INSUFFICIENT' : 'OK'
      };
    });

    // 권고 생성 — 렌더러 계약 필드명: filter_id, current, recommended, action, rationale
    var recommendations = [];

    if (t20FailRate !== null && t20FailRate > 50) {
      recommendations.push({
        filter_id:   'SAQG_F1_F2_F3',
        current:     'CURRENT_THRESHOLDS',
        recommended: 'TIGHTEN: F2 recovery_ratio 1.20 → 1.35',
        action:      'TIGHTEN',
        rationale:   'ELIGIBLE T+20 알파 실패율 ' + t20FailRate + '% > 50% 기준 초과'
      });
    }

    if (t20PassRate !== null && t20PassRate > 70 && casesAnalyzed >= 12) {
      recommendations.push({
        filter_id:   'SAQG_F3',
        current:     'excess_drawdown 5%p',
        recommended: 'RELAX: excess_drawdown 5%p → 7%p',
        action:      'RELAX',
        rationale:   'ELIGIBLE T+20 성공률 ' + t20PassRate + '% > 70% (케이스 ' + casesAnalyzed + '건)'
      });
    }

    if (brokenRate > 30) {
      recommendations.push({
        filter_id:   'BRT_VERDICT_GATE',
        current:     'CURRENT_THRESHOLDS',
        recommended: 'TIGHTEN: BRT_BROKEN 진입 차단 강화',
        action:      'TIGHTEN',
        rationale:   'ELIGIBLE 중 BRT_BROKEN 비율 ' + brokenRate + '% > 30%'
      });
    }

    Logger.log('[AFL] calcAlphaFeedbackLoop_ 완료: cases=' + casesAnalyzed + ' t20FailRate=' + t20FailRate + '% recs=' + recommendations.length);

    var result = {
      formula_id:                     'ALPHA_FEEDBACK_LOOP_V1',
      as_of:                          asOf,
      analysis_period:                analysisPeriod,
      status:                         'OK',
      cases_analyzed:                 casesAnalyzed,
      grade_count:                    gradeSummary.length,
      eligible_t20_fail_rate:         t20FailRate,
      eligible_t60_fail_rate:         null,
      t20_pass_rate:                  t20PassRate,
      brt_broken_rate:                brokenRate,
      recommended_filter_adjustments: recommendations,
      grade_summary:                  gradeSummary,
      note:                           '임계값 자동 변경 금지 — 사용자 확인 후 settings 수동 반영'
    };

    // settings 시트에 기록
    try {
      var settingSh = ss.getSheetByName('settings');
      if (settingSh) {
        var sData = settingSh.getDataRange().getValues();
        var updated = false;
        var serialized = JSON.stringify(result);
        for (var si = 0; si < sData.length; si++) {
          if (String(sData[si][0] || '').trim() === 'alpha_feedback_json') {
            settingSh.getRange(si + 1, 2).setValue(serialized);
            updated = true;
            break;
          }
        }
        if (!updated) settingSh.appendRow(['alpha_feedback_json', serialized]);
      }
    } catch(writeErr) {
      Logger.log('[AFL] settings 시트 기록 실패: ' + writeErr.message);
    }

    return result;
  } catch(e) {
    Logger.log('[AFL] calcAlphaFeedbackLoop_ 오류: ' + e.message);
    return { status: 'ERROR', error: e.message, cases_analyzed: 0, recommended_filter_adjustments: [], formula_id: 'ALPHA_FEEDBACK_LOOP_V1' };
  }
}

/** AFL 일간 하네스 호출 래퍼 — calcAlphaFeedbackLoop_ 위임 */
function runAlphaFeedbackLoop_() {
  return calcAlphaFeedbackLoop_();
}

/**
 * AFL 캐시 읽기 — settings 시트에서 마지막 저장된 alpha_feedback_json 반환.
 * calcAlphaFeedbackLoop_ 오류 시 fallback으로 사용.
 */
function getAlphaFeedbackJson_() {
  try {
    var ss = getSpreadsheet_();
    var sh = ss.getSheetByName('settings');
    if (!sh) return { status: 'SETTINGS_NOT_FOUND', formula_id: 'ALPHA_FEEDBACK_LOOP_V1' };
    var data = sh.getDataRange().getValues();
    for (var i = 0; i < data.length; i++) {
      if (String(data[i][0] || '').trim() === 'alpha_feedback_json') {
        var raw = data[i][1];
        if (!raw) break;
        try { return JSON.parse(String(raw)); } catch(pe) { break; }
      }
    }
  } catch(e) {
    Logger.log('[AFL] getAlphaFeedbackJson_ 읽기 실패: ' + e.message);
  }
  return { status: 'CACHE_EMPTY', formula_id: 'ALPHA_FEEDBACK_LOOP_V1' };
}

// FORMULA_STUB: EXPECTED_EDGE_V1 — 기댓값 공식 (calcExpectedEdge_) GAS 미구현, Python pipeline 산출





// --- Source: src/gas_adapter_parts/gdf_06_rebalance.gs ---
// gdf_06_rebalance.gs — REBALANCE_ENGINE_V1 (GAS)
//
// runRebalanceSheet_(): data_feed + account_snapshot 라이브 데이터 기반
// bucket drift → 레짐 적응 밴드 → 비용효익 게이트 → 3단계 분할 실행 계획
// GatherTradingData.xlsx > rebalance 시트에 4섹션(SUMMARY/BUCKETS/TICKERS/ORDERS) 출력.

// ── 버킷 설정 (gdf_01_price_metrics.gs THRESHOLDS 와 동기화) ─────────────────
const RB_BUCKET_CONFIG = {
  Core:      { target: 66.0, min: 60.0, max: 72.0 },
  Satellite: { target: 17.5, min: 10.0, max: 25.0 },
  Cash:      { target: 16.5, min: 10.0, max: 22.0 },
};

// 코어 주도주 (isCoreLeader 기준, gdc_02_account_satellite.gs 와 일치)
const RB_CORE_TICKERS = new Set(["005930", "000660", "000270"]);

// ── 레짐 적응 밴드 (P3) ──────────────────────────────────────────────────────
const RB_REGIME_BANDS = {
  RISK_ON:             { label: "RISK_ON ±15%p",          expand: 15, contract: 15 },
  SECULAR_LEADER_RISK_ON: { label: "RISK_ON ±15%p",       expand: 15, contract: 15 },
  NEUTRAL:             { label: "NEUTRAL ±5%p",           expand:  5, contract:  5 },
  RISK_OFF_CANDIDATE:  { label: "RISK_OFF_CANDIDATE +2/−10%p", expand: 2, contract: 10 },
  RISK_OFF:            { label: "RISK_OFF +2/−10%p",      expand:  2, contract: 10 },
  EVENT_SHOCK:         { label: "RISK_OFF +2/−10%p",      expand:  2, contract: 10 },
  _DEFAULT:            { label: "NEUTRAL ±5%p",           expand:  5, contract:  5 },
};

// ── 비용효익 게이트 (P4) ─────────────────────────────────────────────────────
const RB_TX_COST_ROUNDTRIP     = 0.0070; // 0.35% × 2
const RB_COST_BENEFIT_THRESHOLD = 0.0050; // 0.50%p
const RB_MIN_DRIFT_PCT          = (RB_TX_COST_ROUNDTRIP + RB_COST_BENEFIT_THRESHOLD) * 100; // 1.20%p
const RB_LIMIT_PRICE_DISCOUNT   = 0.002;  // 매도 지정가 = 종가 × (1 - 0.2%)

// ── 3단계 분할 비율 (P5) ─────────────────────────────────────────────────────
const RB_STAGE_RATIOS = [0.30, 0.30, 0.40];


// ═══════════════════════════════════════════════════════════════════════════════
// Public entry point
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * GatherTradingData.xlsx > rebalance 시트에 4섹션 리밸런싱 계획을 기록한다.
 * 메뉴 또는 runDataFeed 후 자동 호출 가능.
 */
function runRebalanceSheet_() {
  const tag = "runRebalanceSheet_";
  const startMs = Date.now();

  try {
    // 1. 데이터 로드
    const dfRows   = _rbLoadDataFeedRows_();
    const settings = readSettingsTab_();
    const regime   = _rbReadRegime_(settings);
    const band     = RB_REGIME_BANDS[regime] || RB_REGIME_BANDS["_DEFAULT"];

    // 2. 보유 종목 필터링 (Weight_Pct > 0 || Account_Market_Value > 0)
    const holdings = _rbFilterHoldings_(dfRows);

    // 3. 버킷별 현재 비중 집계
    const buckets  = _rbComputeBuckets_(holdings, band);

    // 4. 종목별 분석
    const tickers  = _rbComputeTickers_(holdings, band);

    // 5. ORDERS 생성
    const orders   = _rbComputeOrders_(tickers);

    // 6. SUMMARY 생성
    const summary  = _rbComputeSummary_(holdings, buckets, regime, band, orders.length);

    // 7. 시트 쓰기
    _writeRebalanceSheet_(summary, buckets, tickers, orders);

    const elapsed = Math.round((Date.now() - startMs) / 100) / 10;
    Logger.log(`[${tag}] 완료: holdings=${holdings.length} orders=${orders.length} elapsed=${elapsed}s`);

  } catch (e) {
    Logger.log(`[${tag}][ERROR] 오류: ${e.message}\n${e.stack}`);
    throw e;
  }
}


// ═══════════════════════════════════════════════════════════════════════════════
// 데이터 로드
// ═══════════════════════════════════════════════════════════════════════════════

function _rbLoadDataFeedRows_() {
  const raw = sheetToJson("data_feed");
  if (!Array.isArray(raw) || raw.length === 0) {
    throw new Error("data_feed 시트가 비어 있거나 로드 실패");
  }
  return raw;
}

function _rbReadRegime_(settings) {
  const raw = (settings["REGIME_PRELIM"] || settings["regime_prelim"] || "").trim().toUpperCase();
  return raw in RB_REGIME_BANDS ? raw : "_DEFAULT";
}


// ═══════════════════════════════════════════════════════════════════════════════
// 보유 종목 필터링
// ═══════════════════════════════════════════════════════════════════════════════

function _rbFilterHoldings_(dfRows) {
  return dfRows
    .map(row => {
      const ticker    = String(row["Ticker"] ?? "").trim();
      if (!ticker) return null;
      const weightPct = _rbNum_(row["Weight_Pct"]);
      const acctMv    = _rbNum_(row["Account_Market_Value"]);
      if (weightPct <= 0 && acctMv <= 0) return null;

      return {
        ticker:      ticker,
        name:        String(row["Name"] ?? ""),
        bucket:      _rbAssignBucket_(ticker, row),
        weightPct:   weightPct,
        acctMvKrw:   acctMv,
        holdingQty:  _rbInt_(row["Account_Holding_Qty"]),
        close:       _rbNum_(row["Close"]),
        finalAction: String(row["Final_Action"] ?? ""),
        sellReason:  String(row["Sell_Reason"] ?? ""),
        forceSignal: _rbDetectForce_(row),
      };
    })
    .filter(h => h !== null);
}

function _rbAssignBucket_(ticker, row) {
  const pt = String(row["position_type"] || row["Position_Type"] || "").trim().toLowerCase();
  if (pt === "core")      return "Core";
  if (pt === "satellite") return "Satellite";
  return RB_CORE_TICKERS.has(ticker) ? "Core" : "Satellite";
}

function _rbDetectForce_(row) {
  const combined = [
    row["Sell_Reason"], row["Final_Action"], row["Sell_Action"]
  ].join(" ").toUpperCase();
  if (combined.includes("ABS_FLOOR"))  return "ABS_FLOOR";
  if (combined.includes("TIME_STOP") || combined.includes("TIME_EXIT") || combined.includes("TIME_TRIM"))
    return "TIME_STOP";
  return "";
}


// ═══════════════════════════════════════════════════════════════════════════════
// 버킷 계산
// ═══════════════════════════════════════════════════════════════════════════════

function _rbComputeBuckets_(holdings, band) {
  const corePct = holdings.filter(h => h.bucket === "Core").reduce((s, h) => s + h.weightPct, 0);
  const satPct  = holdings.filter(h => h.bucket === "Satellite").reduce((s, h) => s + h.weightPct, 0);
  const cashPct = Math.max(0, 100 - corePct - satPct);
  const current = { Core: corePct, Satellite: satPct, Cash: cashPct };

  return Object.entries(RB_BUCKET_CONFIG).map(([bname, bcfg]) => {
    const target  = bcfg.target;
    const cur     = _rb2_(current[bname] || 0);
    const drift   = _rb2_(cur - target);
    const bandMin = _rb2_(target - band.contract);
    const bandMax = _rb2_(target + band.expand);
    let driftStatus;
    if (cur < bandMin)        driftStatus = "BREACH_LOW";
    else if (cur > bandMax)   driftStatus = "BREACH_HIGH";
    else if (Math.abs(drift) >= RB_MIN_DRIFT_PCT / 2) driftStatus = "WARN";
    else                      driftStatus = "NORMAL";

    return { bucket: bname, targetPct: target, currentPct: cur, driftPct: drift,
             bandMin, bandMax, regimeBand: band.label, driftStatus };
  });
}


// ═══════════════════════════════════════════════════════════════════════════════
// 종목별 분석
// ═══════════════════════════════════════════════════════════════════════════════

function _rbComputeTickers_(holdings, band) {
  // 버킷별 종목 수 집계
  const countMap = {};
  holdings.forEach(h => { countMap[h.bucket] = (countMap[h.bucket] || 0) + 1; });

  return holdings.map(h => {
    const bcfg       = RB_BUCKET_CONFIG[h.bucket] || RB_BUCKET_CONFIG["Satellite"];
    const nTickers   = countMap[h.bucket] || 1;
    const targetPct  = _rb2_(bcfg.target / nTickers);
    const currentPct = _rb2_(h.weightPct);
    const drift      = _rb2_(currentPct - targetPct);
    const bandMin    = _rb2_(targetPct - band.contract);
    const bandMax    = _rb2_(targetPct + band.expand);
    const force      = h.forceSignal;

    let driftStatus, action, gateStatus;
    if (force) {
      driftStatus = "FORCE_" + force;
      action      = "SELL";
      gateStatus  = "FORCE_OVERRIDE";
    } else if (currentPct > bandMax) {
      driftStatus = "BREACH_HIGH";
      action      = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "SELL" : "WATCH";
      gateStatus  = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "PASS" : "BLOCKED_BY_COST";
    } else if (currentPct < bandMin) {
      driftStatus = "BREACH_LOW";
      action      = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "BUY"  : "WATCH";
      gateStatus  = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "PASS" : "BLOCKED_BY_COST";
    } else if (Math.abs(drift) >= RB_MIN_DRIFT_PCT / 2) {
      driftStatus = "WARN";
      action      = "WATCH";
      gateStatus  = "BLOCKED_BY_COST";
    } else {
      driftStatus = "NORMAL";
      action      = "HOLD";
      gateStatus  = "BLOCKED_BY_COST";
    }

    // 3단계 수량 분할 (P5)
    let s1q = 0, s1p = 0, s2q = 0, s2p = 0, s3q = 0, s3p = 0;
    let tradeValueKrw = 0, costEstKrw = 0, netBenefitPct = 0;

    if ((action === "SELL" || action === "BUY") && h.holdingQty > 0 && h.close > 0) {
      let adjustQty;
      if (action === "SELL" && currentPct > 0) {
        const adjustRatio = Math.min(Math.abs(drift) / currentPct, 1.0);
        adjustQty = Math.max(1, Math.round(h.holdingQty * adjustRatio));
      } else {
        adjustQty = Math.max(1, Math.round(h.holdingQty * 0.10));
      }

      const stages = _rbStageSplit_(adjustQty);
      const limitP = _rbLimitPrice_(h.close, action);
      [s1q, s2q, s3q] = stages;
      [s1p, s2p, s3p] = [limitP, limitP, limitP];
      tradeValueKrw = _rb2_((s1q + s2q + s3q) * limitP);
      costEstKrw    = _rb2_(tradeValueKrw * RB_TX_COST_ROUNDTRIP);
      netBenefitPct = _rb2_(Math.abs(drift) - RB_TX_COST_ROUNDTRIP * 100);
    }

    return { ticker: h.ticker, name: h.name, bucket: h.bucket,
             targetPct, currentPct, driftPct: drift, bandMin, bandMax,
             regimeBand: band.label, driftStatus, forceSignal: force,
             gateStatus, action,
             stage1Qty: s1q, stage1Price: s1p,
             stage2Qty: s2q, stage2Price: s2p,
             stage3Qty: s3q, stage3Price: s3p,
             tradeValueKrw, costEstKrw, netBenefitPct, close: h.close };
  });
}


// ═══════════════════════════════════════════════════════════════════════════════
// ORDERS 생성
// ═══════════════════════════════════════════════════════════════════════════════

function _rbComputeOrders_(tickers) {
  const active = tickers
    .filter(t => t.gateStatus === "PASS" || t.gateStatus === "FORCE_OVERRIDE")
    .sort((a, b) => {
      const pa = a.gateStatus === "FORCE_OVERRIDE" ? 0 : 1;
      const pb = b.gateStatus === "FORCE_OVERRIDE" ? 0 : 1;
      if (pa !== pb) return pa - pb;
      return Math.abs(b.driftPct) - Math.abs(a.driftPct);
    });

  const orders = [];
  let orderNo = 1;
  active.forEach(t => {
    const stageDefs = [
      { stage: 1, qty: t.stage1Qty, price: t.stage1Price },
      { stage: 2, qty: t.stage2Qty, price: t.stage2Price },
      { stage: 3, qty: t.stage3Qty, price: t.stage3Price },
    ];
    stageDefs.forEach(({ stage, qty, price }) => {
      if (qty <= 0) return;
      const reason = t.forceSignal || t.driftStatus;
      orders.push({
        orderNo, ticker: t.ticker, name: t.name, bucket: t.bucket,
        action: t.action, stage, qty, limitPriceKrw: price,
        tradeValueKrw: qty * price, reason,
      });
      orderNo++;
    });
  });
  return orders;
}


// ═══════════════════════════════════════════════════════════════════════════════
// SUMMARY 생성
// ═══════════════════════════════════════════════════════════════════════════════

function _rbComputeSummary_(holdings, buckets, regime, band, ordersCount) {
  const corePct  = (buckets.find(b => b.bucket === "Core")      || {}).currentPct || 0;
  const satPct   = (buckets.find(b => b.bucket === "Satellite") || {}).currentPct || 0;
  const cashPct  = (buckets.find(b => b.bucket === "Cash")      || {}).currentPct || 0;
  const rebalNeeded = buckets.some(b => b.driftStatus.startsWith("BREACH"));
  const totalKrw = holdings.reduce((s, h) => s + h.acctMvKrw, 0);
  const nowKst   = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");

  return {
    Run_Date:              nowKst,
    Regime:                regime,
    Regime_Band:           band.label,
    Total_Portfolio_KRW:   totalKrw,
    Core_Pct:              corePct,
    Satellite_Pct:         satPct,
    Cash_Pct:              cashPct,
    Target_Core_Pct:       RB_BUCKET_CONFIG.Core.target,
    Target_Sat_Pct:        RB_BUCKET_CONFIG.Satellite.target,
    Target_Cash_Pct:       RB_BUCKET_CONFIG.Cash.target,
    Rebalance_Needed:      rebalNeeded,
    Holdings_Count:        holdings.length,
    Orders_Count:          ordersCount,
    Min_Actionable_Drift_Pct: RB_MIN_DRIFT_PCT,
  };
}


// ═══════════════════════════════════════════════════════════════════════════════
// 시트 쓰기 — 4섹션 멀티섹션 레이아웃
// ═══════════════════════════════════════════════════════════════════════════════

function _writeRebalanceSheet_(summary, buckets, tickers, orders) {
  const ss    = getSpreadsheet_();
  let sheet   = ss.getSheetByName("rebalance");
  if (!sheet) {
    sheet = ss.insertSheet("rebalance");
  } else {
    sheet.clearContents();
  }

  const rows = [];
  const nowKst = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
  rows.push([`updated: ${nowKst} KST`]);

  // ── SUMMARY 섹션 ──────────────────────────────────────────────────────────
  rows.push(["=== SUMMARY ==="]);
  Object.entries(summary).forEach(([k, v]) => rows.push([k, v]));
  rows.push([""]);

  // ── BUCKETS 섹션 ─────────────────────────────────────────────────────────
  rows.push(["=== BUCKETS ==="]);
  rows.push(["Bucket","Target_Pct","Current_Pct","Drift_Pct","Band_Min","Band_Max","Regime_Band","Drift_Status"]);
  buckets.forEach(b => rows.push([
    b.bucket, b.targetPct, b.currentPct, b.driftPct,
    b.bandMin, b.bandMax, b.regimeBand, b.driftStatus,
  ]));
  rows.push([""]);

  // ── TICKERS 섹션 ─────────────────────────────────────────────────────────
  rows.push(["=== TICKERS ==="]);
  rows.push([
    "Ticker","Name","Bucket","Target_Pct","Current_Pct","Drift_Pct",
    "Band_Min","Band_Max","Regime_Band","Drift_Status","Force_Signal","Gate_Status","Action",
    "Stage1_Qty","Stage1_Price","Stage2_Qty","Stage2_Price","Stage3_Qty","Stage3_Price",
    "Trade_Value_KRW","Cost_Est_KRW","Net_Benefit_Pct","Close",
  ]);
  tickers.forEach(t => rows.push([
    t.ticker, t.name, t.bucket, t.targetPct, t.currentPct, t.driftPct,
    t.bandMin, t.bandMax, t.regimeBand, t.driftStatus, t.forceSignal, t.gateStatus, t.action,
    t.stage1Qty, t.stage1Price, t.stage2Qty, t.stage2Price, t.stage3Qty, t.stage3Price,
    t.tradeValueKrw, t.costEstKrw, t.netBenefitPct, t.close,
  ]));
  rows.push([""]);

  // ── ORDERS 섹션 ──────────────────────────────────────────────────────────
  rows.push(["=== ORDERS ==="]);
  rows.push(["Order_No","Ticker","Name","Bucket","Action","Stage","Qty","Limit_Price_KRW","Trade_Value_KRW","Reason"]);
  orders.forEach(o => rows.push([
    o.orderNo, o.ticker, o.name, o.bucket, o.action,
    o.stage, o.qty, o.limitPriceKrw, o.tradeValueKrw, o.reason,
  ]));

  // 한 번에 쓰기
  if (rows.length > 0) {
    const maxCols = Math.max(...rows.map(r => r.length));
    const padded  = rows.map(r => {
      while (r.length < maxCols) r.push("");
      return r;
    });
    sheet.getRange(1, 1, padded.length, maxCols).setValues(padded);
  }
}


// ═══════════════════════════════════════════════════════════════════════════════
// 내부 유틸
// ═══════════════════════════════════════════════════════════════════════════════

function _rbNum_(v) {
  const n = parseFloat(v);
  return isNaN(n) ? 0 : n;
}

function _rbInt_(v) {
  const n = parseInt(v, 10);
  return isNaN(n) ? 0 : n;
}

function _rb2_(v) {
  return Math.round(v * 100) / 100;
}

function _rbStageSplit_(totalQty) {
  if (totalQty <= 0) return [0, 0, 0];
  if (totalQty < 3)  return [totalQty, 0, 0];
  const s1 = Math.max(1, Math.floor(totalQty * RB_STAGE_RATIOS[0]));
  const s2 = Math.max(1, Math.floor(totalQty * RB_STAGE_RATIOS[1]));
  const s3 = Math.max(0, totalQty - s1 - s2);
  return [s1, s2, s3];
}

function _rbLimitPrice_(close, action) {
  if (close <= 0) return 0;
  return action === "SELL" ? Math.round(close * (1 - RB_LIMIT_PRICE_DISCOUNT)) : Math.round(close);
}

// ── WBS-5.3 일일 자율 실행 타이머 트리거 설정 ─────────────────────────────────

/**
 * setupDailyRunAllTrigger()
 * GAS 편집기에서 수동 1회 실행 → 매일 16:30 run_all 타이머 트리거 등록.
 * 중복 트리거 방지: 동일 함수명 트리거가 존재하면 먼저 삭제.
 */
function setupDailyRunAllTrigger() {
  const TARGET_FN = "run_all";
  const TRIGGER_HOUR = 16; // 오후 4시 (장 마감 30분 후)

  // 기존 동일 함수 트리거 삭제 (중복 방지)
  ScriptApp.getProjectTriggers().forEach(t => {
    if (t.getHandlerFunction() === TARGET_FN) {
      ScriptApp.deleteTrigger(t);
      Logger.log("[WBS-5.3] 기존 트리거 삭제: " + TARGET_FN);
    }
  });

  // 일일 타이머 트리거 등록 (매일 16:00~17:00 사이 실행)
  ScriptApp.newTrigger(TARGET_FN)
    .timeBased()
    .atHour(TRIGGER_HOUR)
    .everyDays(1)
    .inTimezone("Asia/Seoul")
    .create();

  Logger.log("[WBS-5.3] 일일 트리거 등록 완료: " + TARGET_FN + " @ " + TRIGGER_HOUR + ":00 KST");
}

/**
 * listTriggers()
 * 현재 등록된 모든 트리거 목록 출력 (검증용).
 */
function listTriggers() {
  ScriptApp.getProjectTriggers().forEach(t => {
    Logger.log(
      "trigger: fn=" + t.getHandlerFunction() +
      " type=" + t.getEventType() +
      " source=" + t.getTriggerSource()
    );
  });
}

