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);
  }
}
