7786e60daf
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>
2224 lines
94 KiB
JavaScript
2224 lines
94 KiB
JavaScript
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'
|
||
};
|
||
}
|
||
|
||
|