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

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

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

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

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

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


function assembleHarnessCoreLayers_(
  ss, now, settings, asResult, dfMap, performance, totalAsset, mrsScore, buyPowerKrw,
  settlementCashPct, totalHeatPct, intradayLock, snapshotFreshness, snapshotGate,
  cashFloorInfo, cashShortfallInfo, capturedAtIso, drawdownGuard, winLossStreakGuard, marketRegime,
  regimeTrimGuidance, regimeTransitionAlert, regimeSizeScale, regimeCashMinPct,
  heatThresholds, heatGate, actions, h1, kospiRet5d, sectorFlowRadar
) {
  // THIN_ADAPTER: [sizing] delegated to Python — src/quant_engine/inject_computed_harness.py:main
  var h2 = calcSellPriority_(asResult.holdings, dfMap, h1);
  var h3 = calcQuantities_(asResult.holdings, dfMap, totalAsset, buyPowerKrw, h1);
  var h4 = calcPrices_(asResult.holdings, dfMap, marketRegime);
  var h5 = runRouteFlow_(asResult.holdings, dfMap, h1);
  var orderBlueprint = buildOrderBlueprint_(asResult.holdings, dfMap, {
    intradayLock: intradayLock,
    heatGate: heatGate,
    cashFloorStatus: cashFloorInfo.status,
    blockedActions: actions.blocked
  }, h3, h4, h5);
  return {
    h2: h2,
    h3: h3,
    h4: h4,
    h5: h5,
    orderBlueprint: orderBlueprint
  };
}


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


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


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


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


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


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

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

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

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


function finalizeHarnessContextRows_(
  ss, now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo,
  heatGate, heatThresholds, mrsScore, asResult, dfMap, settlementCashPct, totalHeatPct,
  buyPowerKrw, totalAsset, actions, performance, h2, h3, h4, h5, orderBlueprint, hAlpha,
  regimeTrimGuidance, cashShortfallInfo, hApex, sectorMomentumRows, drawdownGuard,
  portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows, regimeSizeScale,
  regimeCashMinPct, stopAdequacyRows, staleRows, singlePositionWeightCap,
  semiconductorClusterGate, portfolioDrawdownGate, winLossStreakGuard, positionCountLimit,
  stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert,
  portfolioHealthScore
) {
  var rows = buildHarnessRows_(
    now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo, heatGate, heatThresholds, mrsScore,
    asResult, dfMap, settlementCashPct, totalHeatPct, buyPowerKrw, totalAsset, actions,
    performance, h2, h3, h4, h5, orderBlueprint, hAlpha, regimeTrimGuidance,
    cashShortfallInfo, hApex, sectorMomentumRows,
    drawdownGuard, portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows,
    regimeSizeScale, regimeCashMinPct, stopAdequacyRows, staleRows,
    singlePositionWeightCap, semiconductorClusterGate, portfolioDrawdownGate,
    winLossStreakGuard, positionCountLimit,
    stopBreachAlert, tpTriggerAlert, heatConcentrationAlert,
    regimeTransitionAlert, portfolioHealthScore
  );
  assertHarnessRowsComplete_(rows);
  writeHarnessSheet_(ss, rows, now);
  return rows;
}


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


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

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

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

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

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


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

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

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

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

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


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


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


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


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


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


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


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


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


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


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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

  var deactivationReasons = [];

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

    } else {
      tickerCap = defaultCap;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

  var aewRows = [];

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

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

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

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

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

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

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

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

  return aewRows;
}


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


