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