Files
QuantEngineByItz/src/gas_adapter_parts/gdf_02_harness_assembly.gs
T
kjh2064 7786e60daf feat(gas-thin-adapter): Phase 3 thin_adapter — 23개 forbidden 함수에 THIN_ADAPTER 위임 주석 삽입
GAS_THIN_ADAPTER_POLICY_V1 Phase 3 (thin_adapter) 진행:
- tools/gas_thin_adapter_phase3_annotate.py: 23개 GAS forbidden 함수에 THIN_ADAPTER 주석 자동 삽입 스크립트
- src/gas_adapter_parts 7개 파일: 각 forbidden 함수 본문 첫 줄에
  // THIN_ADAPTER: [<responsibility>] delegated to Python — <module>:<function>
  주석 추가 (기능 코드 무변경, additive-only)
- spec/39: thin_adapter phase IN_PROGRESS + thin_adapter_result 블록 추가

⚠ GAS 파일 변경됨 — GAS deploy + 사용자 검증 필요 (runDataFeed 실행)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:32:00 +09:00

2224 lines
94 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
};
}