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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

    if (!isBuyCandidate || buyBlocked) return;

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  return { prices: prices };
}


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  return blueprint;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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


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

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

    var signals = [];

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

