From e911f500fa9c17988b91c89791f2865c4cc4c87f Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 14 Jun 2026 17:23:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(gas):=20GAS=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A3=A8=ED=8A=B8=20=EB=A0=88=EB=B2=A8=20stale=20.gs=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gas_apex_alpha_watch.gs: gas_apex_runtime_core.gs의 5개 실 구현을 shadowing하던 stub/구버전 제거 (applyApexMacroAlphaSuiteImpl_, applyApexMacroEventSuiteImpl_, calcConsistencyValidatorV2Impl_, calcMacroEventSynchronizerV1Impl_, calcMacroRegimeAdaptiveGateV2Impl_) - gas_lib.gs: gdf_05_alpha_engines.gs로 이전된 runAlphaFeedbackLoop_, getAlphaFeedbackJson_ 스테일 사본 제거 - 루트 레벨 .gs 8개 삭제: src/gas/ 구조 이전 전 구버전, 배포 경로 밖 dead code Co-Authored-By: Claude Sonnet 4.6 --- gas_apex_alpha_watch.gs | 378 --- gas_apex_runtime_core.gs | 705 ------ gas_data_collect.gs | 8 - gas_data_feed.gs | 21 - gas_event_calendar.gs | 907 ------- gas_harness_rows.gs | 1456 ----------- gas_lib.gs | 2965 ----------------------- gas_report.gs | 446 ---- src/gas/core/gas_lib.gs | 162 -- src/gas/engines/gas_apex_alpha_watch.gs | 124 - 10 files changed, 7172 deletions(-) delete mode 100644 gas_apex_alpha_watch.gs delete mode 100644 gas_apex_runtime_core.gs delete mode 100644 gas_data_collect.gs delete mode 100644 gas_data_feed.gs delete mode 100644 gas_event_calendar.gs delete mode 100644 gas_harness_rows.gs delete mode 100644 gas_lib.gs delete mode 100644 gas_report.gs diff --git a/gas_apex_alpha_watch.gs b/gas_apex_alpha_watch.gs deleted file mode 100644 index ce480e6..0000000 --- a/gas_apex_alpha_watch.gs +++ /dev/null @@ -1,378 +0,0 @@ -/** - * gas_apex_alpha_watch.gs - * ──────────────────────────────────────────────────────────────────────────── - * APEX 행위기반 커버리지 하네스 — 핵심 계산 엔진 (Impl) - * [2026-05-30] BCH-V1 대응을 위해 분리된 순수 함수들 - */ - -/** - * PA2: ANTI_LATE_ENTRY_GATE_V2 - * [Python py_anti_late_entry_gate_v2 미러와 100% 동일 로직] - * - * @param {Array} holdings asResult.holdings - * @param {Object} dfMap 종목별 데이터 피드 - * @return {Array} anti_late_entry_json - */ -function calcAntiLateEntryGateV2Impl_(holdings, dfMap) { - var results = []; - for (var i = 0; i < holdings.length; i++) { - var h = holdings[i]; - var ticker = h.ticker || ''; - var df = dfMap[ticker] || {}; - - var close = Number(h.close || df.close || 0); - var prevClose = Number(df.prevClose || 0); - var ma20 = Number(df.ma20 || 0); - var rsi14 = Number(df.rsi14 != null ? df.rsi14 : 50); - var flowCredit = Number(df.flowCredit != null ? df.flowCredit : 0); - var volume = Number(df.volume || 0); - var avgVol5d = Number(df.avgVolume5d || 0); - var frg5d = Number(df.frg5d || 0); - var inst5d = Number(df.inst5d || 0); - var ret5d = Number(df.ret5d || 0); - var acGate = String(df.acGate || ''); - - var v1d = prevClose > 0 ? (close - prevClose) / prevClose * 100 : 0.0; - var v5d = ret5d; - - var distWs = 0.0; - if (frg5d < 0) distWs += 2.0; - if (inst5d < 0) distWs += 2.0; - if (avgVol5d > 0 && volume > avgVol5d * 1.3) distWs += 1.5; - if (prevClose > 0 && close < prevClose) distWs += 1.5; - if (rsi14 > 70) distWs += 1.0; - if (acGate === 'BLOCK') distWs += 1.0; - - var gate1 = 'PASS'; - if (v1d >= 3.0) gate1 = 'BLOCK_CHASE'; - else if (v1d >= 1.5) gate1 = 'PULLBACK_WAIT'; - - var gate2 = 'PASS'; - if (v5d >= 8.0) gate2 = 'BLOCK_CHASE_5D'; - else if (v5d >= 5.0) gate2 = 'PULLBACK_WAIT_5D'; - - var gate3 = 'PASS'; - if (distWs >= 3.0) gate3 = 'BLOCK_DISTRIBUTION'; - else if (distWs >= 2.0) gate3 = 'PULLBACK_WAIT_DIST'; - - var hasBlock = (gate1 === 'BLOCK_CHASE' || gate2 === 'BLOCK_CHASE_5D' || gate3 === 'BLOCK_DISTRIBUTION'); - var hasPullback = (gate1 === 'PULLBACK_WAIT' || gate2 === 'PULLBACK_WAIT_5D' || gate3 === 'PULLBACK_WAIT_DIST'); - - var finalGate = 'PASS'; - if (hasBlock) finalGate = 'BLOCK'; - else if (hasPullback) finalGate = 'PULLBACK_WAIT'; - - var grade = 'B'; - if (finalGate === 'BLOCK') { - grade = 'F'; - } else if (v1d < 0.5 && ma20 > 0 && close >= ma20 && close <= ma20 * 1.02 && flowCredit >= 0.55) { - grade = 'A'; - } else if (v1d < 1.5 && ma20 > 0 && Math.abs(close - ma20) / ma20 <= 0.05) { - grade = 'B'; - } else if (finalGate === 'PULLBACK_WAIT') { - grade = 'C'; - } else if (v5d > 5.0) { - grade = 'D'; - } - - results.push({ - ticker: ticker, - gate1_status: gate1, - gate2_status: gate2, - gate3_status: gate3, - final_gate_status: finalGate, - anti_late_entry_status: finalGate, - entry_grade: grade, - velocity_1d: Math.round(v1d * 100) / 100, - velocity_5d: Math.round(v5d * 100) / 100, - dist_weighted_sum: Math.round(distWs * 10) / 10 - }); - } - return results; -} - -/** - * PA5: CONSISTENCY_VALIDATOR_V2 - * [P0 GAP 해소 - 데이터 정합성 검증] - */ -function calcConsistencyValidatorV2Impl_(hApex, asResult, cashFloorInfo, capturedAtIso, now) { - var checks = []; - var passed = []; - var failed = []; - var gapList = []; - - // CV_01: sell_priority 방향 일관성 - var sellCandidates = hApex.sell_candidates_json || []; - var tierOk = true; - for (var i = 1; i < sellCandidates.length; i++) { - if (sellCandidates[i].tier < sellCandidates[i-1].tier) { - tierOk = false; - break; - } - } - if (tierOk) passed.push('CV_01'); else failed.push({check_id: 'CV_01', reason: 'tier_reversal'}); - - // CV_02: 가격 순서 검증 - var prices = hApex.prices_json || []; - var priceOk = true; - for (var i = 0; i < prices.length; i++) { - var p = prices[i]; - if (p.stop_price && p.current_price && p.stop_price >= p.current_price) priceOk = false; - } - if (priceOk) passed.push('CV_02'); else failed.push({check_id: 'CV_02', reason: 'price_hierarchy_violation'}); - - // CV_06: 수량 정수 검증 - var qtyOk = true; - var bqi = hApex.buy_qty_inputs_json || []; - for (var i = 0; i < bqi.length; i++) { - if (bqi[i].final_qty && bqi[i].final_qty % 1 !== 0) qtyOk = false; - } - if (qtyOk) passed.push('CV_06'); else failed.push({check_id: 'CV_06', reason: 'float_quantity'}); - - // CV_08: 현금 계산 경로 - if (hApex.cash_ledger_basis === 'D2_ONLY') passed.push('CV_08'); - else failed.push({check_id: 'CV_08', reason: 'invalid_cash_basis'}); - - // Score 계산 - var score = Math.floor((passed.length / 12) * 100); - var status = score >= 90 ? (score === 100 ? 'PASS' : 'WARNING') : 'BLOCK'; - - return { - formula_id: 'CONSISTENCY_VALIDATOR_V2', - consistency_score: score, - cv_verdict: status === 'BLOCK' ? 'ABORT' : 'PASS', - block_status: status, - passed: passed, - failed: failed, - gap_list: gapList, - consistency_report_json: { score: score, passed: passed, failed: failed } - }; -} - -/** - * PA4: MACRO_EVENT_SYNCHRONIZER_V1 - */ -function calcMacroEventSynchronizerV1Impl_(macroJson, eventRows) { - var usdKrw = Number(macroJson.usd_krw || 0); - var foreignSellDays = Number(macroJson.foreign_sell_consecutive_days || 0); - - var score = 0; - if (usdKrw > 1500) score += 20; - else if (usdKrw > 1480) score += 15; - - if (foreignSellDays >= 10) score += 20; - else if (foreignSellDays >= 5) score += 15; - - var regime = 'MACRO_NEUTRAL'; - var heatAdj = 0; - if (score >= 60) { regime = 'MACRO_CRITICAL'; heatAdj = -3; } - else if (score >= 40) { regime = 'MACRO_ELEVATED'; heatAdj = -1; } - else if (score < 20) { regime = 'MACRO_FAVORABLE'; heatAdj = 1; } - - return { - formula_id: 'MACRO_EVENT_SYNCHRONIZER_V1', - macro_risk_score: score, - macro_risk_regime: regime, - effective_heat_gate_adjustment: heatAdj, - mega_sell_alert: false, - macro_event_json: { score: score, regime: regime, heat_gate_adj: heatAdj } - }; -} - -/** - * PA1: PREDICTIVE_ALPHA_ENGINE_V1 - */ -function calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult, weightOverrides) { - var results = []; - for (var i = 0; i < holdings.length; i++) { - var h = holdings[i]; - var ticker = h.ticker; - var df = dfMap[ticker] || {}; - - var thesis = 0; - if (df.close > df.ma20 && df.close < df.ma20 * 1.03) thesis += 20; - if (df.flowCredit >= 0.55) thesis += 20; - - var antithesis = 0; - var v1d = df.prevClose > 0 ? (df.close - df.prevClose) / df.prevClose * 100 : 0; - if (v1d >= 3.0) antithesis += 25; - - var confidence = thesis - antithesis; - var verdict = 'HOLD_NEUTRAL'; - if (confidence >= 40) verdict = 'STRONG_BUY_SIGNAL'; - else if (confidence >= 20) verdict = 'MODERATE_BUY_SIGNAL'; - else if (confidence < -30) verdict = 'EXIT_SIGNAL'; - else if (confidence < -10) verdict = 'TRIM_SIGNAL'; - - results.push({ - ticker: ticker, - direction_confidence: confidence, - thesis_score: thesis, - antithesis_score: antithesis, - synthesis_verdict: verdict, - predictive_alpha_json: { confidence: confidence, verdict: verdict } - }); - } - return results; -} - -/** - * MACRO_REGIME_ADAPTIVE_GATE_V2 - */ -function calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex) { - var totalScore = mesResult.macro_risk_score || 0; - var regime = 'MODERATE_RISK'; - var heatThreshold = 10.0; - var sizeScale = 1.0; - - if (totalScore >= 75) { regime = 'EXTREME_RISK'; heatThreshold = 5.0; sizeScale = 0.25; } - else if (totalScore >= 50) { regime = 'HIGH_RISK'; heatThreshold = 7.0; sizeScale = 0.50; } - else if (totalScore < 25) { regime = 'LOW_RISK'; heatThreshold = 12.0; sizeScale = 1.10; } - - return { - formula_id: 'MACRO_REGIME_ADAPTIVE_GATE_V2', - total_mrag_score: totalScore, - regime_label: regime, - effective_heat_gate_threshold: heatThreshold, - effective_position_size_scale: sizeScale, - mrag_v2_json: { score: totalScore, regime: regime } - }; -} - -/** - * applyAlegGate4And5Impl_ - */ -function applyAlegGate4And5Impl_(alegRows, paeRows, hApex) { - var results = []; - var paeMap = {}; - for (var i = 0; i < paeRows.length; i++) paeMap[paeRows[i].ticker] = paeRows[i]; - - for (var i = 0; i < alegRows.length; i++) { - var row = alegRows[i]; - var pae = paeMap[row.ticker] || {}; - - if (pae.synthesis_verdict === 'EXIT_SIGNAL' || pae.synthesis_verdict === 'TRIM_SIGNAL') { - row.gate4_status = 'BLOCK_PAE'; - row.final_gate_status = 'BLOCK'; - row.anti_late_entry_status = 'BLOCK'; - } else { - row.gate4_status = 'PASS'; - } - results.push(row); - } - return results; -} - -/** - * Suite Aggregators - */ -function applyApexMacroAlphaSuiteImpl_(holdings, dfMap, hApex) { - // Placeholder for macro alpha suite - return hApex; -} - -function applyApexMacroEventSuiteImpl_(hApex) { - // Placeholder for macro event suite - return hApex; -} - -function applyApexPredictiveAlphaSuiteImpl_(holdings, dfMap, hApex) { - var macroJson = hApex.macro_event_json || {}; - var mesResult = hApex.macro_event_json || {}; - var paeRows = calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult, null); - hApex.predictive_alpha_json = paeRows; - - // portfolio_alpha_confidence: mean direction_confidence across all holdings - var sum = 0, n = 0; - (paeRows || []).forEach(function(r) { - if (typeof r.direction_confidence === 'number') { sum += r.direction_confidence; n++; } - }); - hApex.portfolio_alpha_confidence = n > 0 ? Math.round(sum / n * 100) / 100 : 0; - - return hApex; -} - -function applyApexWatchBreakoutSuiteImpl_(holdings, dfMap, hApex) { - var slgRows = hApex.satellite_lifecycle_gate_json || []; - var aleRows = hApex.anti_late_entry_json || []; - hApex.watch_breakout_candidates_json = calcWatchBreakoutRealtimeGateV1_(holdings, dfMap, slgRows, aleRows); - return hApex; -} - -// ---- TASK-006: ANTI_LATE_ENTRY_GATE_V2_CALIBRATED ---- -// [GAS_STUB_ONLY: requires Google Sheets deployment] -function calibrateAntiLateEntryV2_(proposalHistory, captureDate) { - // RC 수정: velocity 버킷별 T+5 승률 계산 (실측 표본 >= 30 충족 후 활성화) - var buckets = { LOW: {n:0,wins:0}, MID: {n:0,wins:0}, HIGH: {n:0,wins:0} }; - var totalBuys = 0, chaseBuys = 0; - (proposalHistory || []).forEach(function(p) { - if (p.origin === 'REPLAY' || p.action !== 'BUY') return; - if (p.realized_return_pct_t5 === undefined) return; // 미채움 제외 - totalBuys++; - var v = parseFloat(p.velocity_1d || 0); - var win = parseFloat(p.realized_return_pct_t5 || 0) > 0; - var bucket = v < 1.0 ? 'LOW' : v < 3.0 ? 'MID' : 'HIGH'; - buckets[bucket].n++; - if (win) buckets[bucket].wins++; - if (v >= 3.0) chaseBuys++; - }); - var minSamples = 30; - var validated = Object.keys(buckets).every(function(k) { return buckets[k].n >= minSamples; }); - return { - formula_id: 'ANTI_LATE_ENTRY_GATE_V2_CALIBRATED', - validated: validated, - unvalidated_label: validated ? null : '[UNVALIDATED_LIVE: n<30 per bucket]', - chase_entry_rate_pct: totalBuys > 0 ? (chaseBuys / totalBuys * 100).toFixed(1) : null, - buckets: buckets, - threshold_source: validated ? 'DYNAMIC' : 'EXPERT_PRIOR', - velocity_1d_block_pct: 3.0 - }; -} - -// ---- TASK-007: DISTRIBUTION_BLOCK_EFFECTIVENESS_V1 ---- -// [GAS_STUB_ONLY: requires Google Sheets deployment] -function trackDistributionBlockEffectiveness_(proposalHistory) { - var blocked = (proposalHistory || []).filter(function(p) { - return p.blocked_reason === 'DISTRIBUTION_CONFIRMED' || p.blocked_reason === 'DISTRIBUTION_BLOCK'; - }); - var avoidedLoss = blocked.filter(function(p) { - return p.t5_return_if_not_blocked !== undefined && parseFloat(p.t5_return_if_not_blocked) < 0; - }); - var blockedN = blocked.length; - var avoidedLossRate = blockedN > 0 ? (avoidedLoss.length / blockedN) : null; - return { - formula_id: 'DISTRIBUTION_BLOCK_EFFECTIVENESS_V1', - blocked_sample_count: blockedN, - avoided_loss_rate: avoidedLossRate, - target_avoided_loss_rate: 0.60, - effectiveness_label: blockedN < 30 - ? '[UNVALIDATED_LOW_N: n=' + blockedN + ' < 30]' - : (avoidedLossRate >= 0.60 ? 'EFFECTIVE' : 'REVIEW_THRESHOLD') - }; -} - -// ---- TASK-010: SMART_MONEY_LIQUIDITY_OUTCOME_LINK_V1 ---- -// [GAS_STUB_ONLY: requires Google Sheets deployment] -function linkSmartMoneyOutcome_(proposalHistory) { - var buckets = {}; - (proposalHistory || []).forEach(function(p) { - if (p.origin === 'REPLAY' || !p.liquidity_label) return; - var lbl = p.liquidity_label; - if (!buckets[lbl]) buckets[lbl] = {returns:[], slippages:[]}; - if (p.realized_return_pct_t5 !== undefined) buckets[lbl].returns.push(parseFloat(p.realized_return_pct_t5)); - if (p.slippage_pct !== undefined) buckets[lbl].slippages.push(parseFloat(p.slippage_pct)); - }); - var table = Object.keys(buckets).map(function(lbl) { - var d = buckets[lbl]; - var n = d.returns.length; - var wins = d.returns.filter(function(r){return r>0;}).length; - return { - liquidity_label: lbl, - sample_count: n, - t5_avg_return_pct: n > 0 ? d.returns.reduce(function(a,b){return a+b;},0)/n : null, - t5_win_rate: n > 0 ? wins/n : null, - label: n < 30 ? '[UNVALIDATED: n=' + n + ' < 30]' : 'VALIDATED' - }; - }); - return {formula_id: 'SMART_MONEY_LIQUIDITY_OUTCOME_LINK_V1', table: table}; -} diff --git a/gas_apex_runtime_core.gs b/gas_apex_runtime_core.gs deleted file mode 100644 index 2bf6704..0000000 --- a/gas_apex_runtime_core.gs +++ /dev/null @@ -1,705 +0,0 @@ -// Consolidated runtime core: macro flow + macro calc + consistency - - -// ---- from gas_apex_macro_flow.gs ---- - -function applyApexMacroAlphaSuiteImpl_(holdings, dfMap, hApex) { - Logger.log('[HARNESS_SUB] L3-B2a-i: applyApexMacroEventSuite_'); - hApex = applyApexMacroEventSuite_(hApex); - Logger.log('[HARNESS_SUB] L3-B2a-ii: applyApexPredictiveAlphaSuite_'); - hApex = applyApexPredictiveAlphaSuite_(holdings, dfMap, hApex); - - // [Phase 2] SMART_MONEY_DISTRIBUTION_GUARD_V1: T+5 예측 적중률 연동 매수 차단 - if (typeof hApex.prediction_accuracy_rate === 'number' && hApex.prediction_accuracy_rate < 50) { - Logger.log('[HARNESS_SUB] Phase 2: prediction_accuracy_rate < 50% (' + hApex.prediction_accuracy_rate + '%). 신규 매수 전면 차단.'); - hApex.global_buy_allowed = false; - (hApex.buy_permission_json || []).forEach(function(bp) { - if (bp.buy_permission_state !== 'BLOCKED') { - bp.buy_permission_state = 'BLOCKED'; - bp.block_reason = (bp.block_reason ? bp.block_reason + ' | ' : '') + 'PREDICTION_ACCURACY_LOW(<50%)'; - } - }); - } - - return hApex; -} - -function applyApexMacroEventSuiteImpl_(hApex) { - var macroJson = getMacroJson(); - var eventRiskFullRows = (function() { - try { return getEventRiskJson().events || []; } catch(e) { return []; } - })(); - var mesResult = calcMacroEventSynchronizerV1_(macroJson, eventRiskFullRows); - hApex.macro_event_json = mesResult; - hApex.macro_risk_score = mesResult.macro_risk_score; - hApex.macro_risk_regime = mesResult.macro_risk_regime; - hApex.mega_sell_alert = mesResult.mega_sell_alert; - - var mragResult = calcMacroRegimeAdaptiveGate_(macroJson, mesResult, hApex); - hApex.mrag_v2_json = mragResult; - if (mesResult.heat_gate_adj && mesResult.heat_gate_adj !== 0) { - var me1Threshold = (hApex.heat_gate_threshold_pct || 12) + mesResult.heat_gate_adj; - hApex.effective_heat_gate_threshold = Math.min(me1Threshold, mragResult.effective_heat_gate_threshold); - } else { - hApex.effective_heat_gate_threshold = mragResult.effective_heat_gate_threshold; - } - hApex.effective_position_size_scale = mragResult.effective_position_size_scale; - if (mragResult.stale_events_count > 0) { - hApex.stale_events_alert = mragResult.stale_events; - } - - var fomcDaysRem = mesResult.fomc_days_remaining; - var usCpiDaysRem = mesResult.us_cpi_days_remaining; - var ipoDaysRem = mesResult.large_ipo_days_remaining; - - var fomcGateActive = typeof fomcDaysRem === 'number' && fomcDaysRem <= 7; - var usCpiGateActive = typeof usCpiDaysRem === 'number' && usCpiDaysRem <= 2; - var ipoGateActive = typeof ipoDaysRem === 'number' && ipoDaysRem <= 3; - - hApex.fomc_position_size_gate = fomcGateActive ? 'ACTIVE' : 'INACTIVE'; - hApex.us_cpi_position_size_gate = usCpiGateActive ? 'ACTIVE' : 'INACTIVE'; - hApex.ipo_position_size_gate = ipoGateActive ? 'ACTIVE' : 'INACTIVE'; - - if (fomcGateActive) { - (hApex.buy_permission_json || []).forEach(function(bp) { - bp.fomc_size_limit = 0.5; - bp.fomc_size_gate_reason = 'FOMC_' + fomcDaysRem + 'D_REMAINING'; - }); - } - if (usCpiGateActive) { - (hApex.buy_permission_json || []).forEach(function(bp) { - bp.us_cpi_size_limit = 0.5; - bp.us_cpi_size_gate_reason = 'US_CPI_' + usCpiDaysRem + 'D_REMAINING'; - }); - } - if (ipoGateActive) { - (hApex.buy_permission_json || []).forEach(function(bp) { - bp.ipo_size_limit = 0.7; - bp.ipo_size_gate_reason = 'LARGE_IPO_' + ipoDaysRem + 'D_REMAINING'; - }); - } - return hApex; -} - -// ---- from gas_apex_macro_calc_core.gs ---- - - - -function calcMacroEventSynchronizerV1Impl_(macroJson, eventRows) { - var indicators = macroJson.indicators || []; - var byName = {}; - indicators.forEach(function(m) { byName[m.Name] = m; }); - - var usdKrw = typeof macroJson.usd_krw === 'number' ? macroJson.usd_krw : 0; - var vix = typeof macroJson.vix === 'number' ? macroJson.vix : 0; - var sp500Ret5d = typeof macroJson.sp500_ret5d === 'number' ? macroJson.sp500_ret5d : 0; - - // 외국인 순매도 연속일 (macro 시트 누적) - var fscRow = byName['Foreign_Sell_Consecutive_Days'] || byName['ForeignSellConsecutiveDays'] || {}; - var foreignSellDays = typeof fscRow.Close === 'number' ? Math.round(fscRow.Close) : 0; - - // 외국인 당일 순매도 금액 - var fskRow = byName['Foreign_Sell_KRW_Today'] || byName['ForeignSellKRWToday'] || {}; - var foreignSellKrwToday = typeof fskRow.Close === 'number' ? fskRow.Close : 0; - - // 국내 CPI - var cpiRow = byName['Domestic_CPI'] || byName['CPI_Domestic'] || {}; - var domesticCpi = typeof cpiRow.Close === 'number' ? cpiRow.Close : 0; - - // FOMC / US_CPI / IPO 잔여 일수 (event_risk 시트) - var fomcDaysRemaining = null; - var usCpiDaysRemaining = null; - var largeIpoDaysRemaining = null; - var eventRowsSafe = Array.isArray(eventRows) ? eventRows : []; - - function _nearestDays(typeStr) { - var list = eventRowsSafe.filter(function(e) { - var t = (e.Type || e.type || '').toUpperCase(); - var d = typeof e.DaysLeft === 'number' ? e.DaysLeft : (typeof e.daysLeft === 'number' ? e.daysLeft : -1); - return t === typeStr && d >= 0; - }); - if (!list.length) return null; - list.sort(function(a, b) { - return (a.DaysLeft || a.daysLeft || 999) - (b.DaysLeft || b.daysLeft || 999); - }); - return list[0].DaysLeft || list[0].daysLeft || null; - } - - fomcDaysRemaining = _nearestDays('FOMC'); - usCpiDaysRemaining = _nearestDays('US_CPI'); - largeIpoDaysRemaining = _nearestDays('IPO'); - - // ── macro_risk_score 산출 (max 100) ───────────────────────────────────────── - var breakdown = []; - var macroRiskScore = 0; - - function addMacroScore(label, condition, score) { - if (condition) macroRiskScore += score; - breakdown.push({ factor: label, score: condition ? score : 0, triggered: !!condition }); - } - - addMacroScore('usd_krw_critical', usdKrw > 1500, 20); - addMacroScore('usd_krw_weak', usdKrw > 1480 && usdKrw <= 1500, 15); - addMacroScore('foreign_mega', foreignSellDays >= 10, 20); - addMacroScore('foreign_high', foreignSellDays >= 5 && foreignSellDays < 10, 15); - addMacroScore('fomc_near', fomcDaysRemaining !== null && fomcDaysRemaining <= 5, 15); - addMacroScore('us_cpi_near', usCpiDaysRemaining !== null && usCpiDaysRemaining <= 2, 10); - addMacroScore('cpi_high', domesticCpi > 2.5, 10); - addMacroScore('vix_elevated', vix > 20, 10); - addMacroScore('us500_drop', sp500Ret5d < -3.0, 10); - macroRiskScore = Math.min(100, macroRiskScore); - - // ── macro_risk_regime 분류 ─────────────────────────────────────────────────── - var macroRiskRegime, heatGateAdj; - if (macroRiskScore >= 60) { macroRiskRegime = 'MACRO_CRITICAL'; heatGateAdj = -3; } - else if (macroRiskScore >= 40) { macroRiskRegime = 'MACRO_ELEVATED'; heatGateAdj = -1; } - else if (macroRiskScore >= 20) { macroRiskRegime = 'MACRO_NEUTRAL'; heatGateAdj = 0; } - else { macroRiskRegime = 'MACRO_FAVORABLE'; heatGateAdj = +1; } - - // ── event_matrix ──────────────────────────────────────────────────────────── - var eventMatrix = []; - if (fomcDaysRemaining !== null && fomcDaysRemaining <= 7) { - eventMatrix.push({ event: 'FOMC_WEEK', buy_gate_downgrade: true, sell_block: false, - days_remaining: fomcDaysRemaining }); - } - // US CPI 발표 2일 이내 — 신규매수 자제 (예상치 상회 시 급락 위험) - if (usCpiDaysRemaining !== null && usCpiDaysRemaining <= 2) { - eventMatrix.push({ event: 'US_CPI_IMMINENT', buy_gate_downgrade: true, sell_block: false, - days_remaining: usCpiDaysRemaining, - note: '미국 CPI 발표 임박 — 예상치 대비 서프라이즈 위험. 신규매수 자제' }); - } - // 대형 IPO 5일 이내 — 공모자금 쏠림으로 시장 유동성 흡수 주의 - if (largeIpoDaysRemaining !== null && largeIpoDaysRemaining <= 5) { - eventMatrix.push({ event: 'LARGE_IPO_WINDOW', buy_gate_downgrade: true, sell_block: false, - days_remaining: largeIpoDaysRemaining, - note: '대형 IPO 상장 임박 — 공모자금 유동성 흡수. 소형주·위성 포지션 매수 자제' }); - } - - // mega_sell_alert: 외국인 순매도 >= 1조원 - var megaSellAlert = foreignSellKrwToday >= 1000000000000; - var buyGateBlockUntil = null; - if (megaSellAlert) { - var blockDate = new Date(); - var bizAdded = 0; - while (bizAdded < 3) { - blockDate.setDate(blockDate.getDate() + 1); - var wd = blockDate.getDay(); - if (wd !== 0 && wd !== 6) bizAdded++; - } - buyGateBlockUntil = Utilities.formatDate(blockDate, 'Asia/Seoul', 'yyyy-MM-dd'); - eventMatrix.push({ event: 'MEGA_SELL_ALERT', foreign_sell_krw: foreignSellKrwToday, - buy_gate_block_until: buyGateBlockUntil }); - } - - return { - macro_risk_score: macroRiskScore, - macro_risk_regime: macroRiskRegime, - macro_risk_breakdown: breakdown, - foreign_sell_consecutive_days: foreignSellDays, - foreign_sell_krw_today: foreignSellKrwToday, - mega_sell_alert: megaSellAlert, - buy_gate_block_until: buyGateBlockUntil, - effective_heat_gate_adjustment: heatGateAdj, - heat_gate_adj: heatGateAdj, - fomc_days_remaining: fomcDaysRemaining, - us_cpi_days_remaining: usCpiDaysRemaining, - large_ipo_days_remaining: largeIpoDaysRemaining, - event_matrix: eventMatrix, - formula_id: 'MACRO_EVENT_SYNCHRONIZER_V1' - }; -} - - -function calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex) { - var macro = macroJson || {}; - var mes = mesResult || {}; - - // ── LAYER_1: 미시 리스크 (Market Internals, 0~25) ────────────────── - var l1 = 0; - var vkospi = toNumber_(macro['vkospi'] || macro.vkospi) || 0; - var mrsScoreL1 = toNumber_(macro['mrs_score'] || macro.mrs_score || (hApex && hApex.mrs_score)) || 0; - var breadthAdv = toNumber_(macro['breadth_advance_decline'] || macro.breadth_advance_decline) || 0; - if (breadthAdv > 0 && breadthAdv < 0.45) l1 += 10; // 하락 종목 비율 55% 초과 - if (vkospi > 30) l1 += 10; // VKOSPI 공포 - if (mrsScoreL1 <= 3) l1 += 5; // MRS 저점 - l1 = Math.min(l1, 25); - - // ── LAYER_2: 거시 리스크 (Macro, 0~25) ──────────────────────────── - var l2 = 0; - var macroRiskScore = toNumber_(mes.macro_risk_score) || 0; - l2 = Math.min(25, Math.round(macroRiskScore / 100 * 25)); - - // ── LAYER_3: 글로벌 리스크 (Global, 0~25) ───────────────────────── - var l3 = 0; - var usRetWeek = toNumber_(macro['us500_1w_change'] || macro.us500_1w_change) || 0; - var vix = toNumber_(macro['vix'] || macro.vix) || 0; - var globalOvrd = String(macro['global_risk_override'] || '').toUpperCase(); - if (usRetWeek < -3) l3 += 10; // S&P500 주간 -3% 이하 - if (vix >= 30) l3 += 10; // VIX 공포 - else if (vix >= 25) l3 += 7; // VIX 경계 - if (globalOvrd === 'MANUAL_HIGH') l3 = 25; // 수동 override - l3 = Math.min(l3, 25); - - // ── LAYER_4: 이벤트 리스크 (Event, 0~25) ────────────────────────── - var l4 = 0; - var fomcDays = typeof mes.fomc_days_remaining === 'number' ? mes.fomc_days_remaining : 99; - var usCpiDays = typeof mes.us_cpi_days_remaining === 'number' ? mes.us_cpi_days_remaining : 99; - var largeIpoDays = typeof mes.large_ipo_days_remaining === 'number' ? mes.large_ipo_days_remaining : 99; - var megaSell = mes.mega_sell_alert === true; - if (fomcDays <= 5) l4 += 15; - else if (fomcDays <= 7) l4 += 8; - if (megaSell) l4 += 10; - // US CPI: 발표 2일 이내 +8, 3일 이내 +4 (금리 경로 재평가 리스크) - if (usCpiDays <= 2) l4 += 8; - else if (usCpiDays <= 3) l4 += 4; - // 대형 IPO: 상장 3일 이내 +5 (공모자금 유동성 흡수) - if (largeIpoDays <= 3) l4 += 5; - l4 = Math.min(l4, 25); - - var totalScore = l1 + l2 + l3 + l4; - - // ── HEAT_GATE 임계값 / POSITION_SIZE_SCALE 조정 ──────────────────── - var effectiveHeatThreshold, effectivePositionScale, regimeLabel; - if (totalScore >= 80) { - effectiveHeatThreshold = 5; effectivePositionScale = 0.25; regimeLabel = 'EVENT_SHOCK'; - } else if (totalScore >= 60) { - effectiveHeatThreshold = 7; effectivePositionScale = 0.50; regimeLabel = 'RISK_OFF'; - } else if (totalScore >= 40) { - effectiveHeatThreshold = 10; effectivePositionScale = 1.00; regimeLabel = 'NEUTRAL'; - } else { - effectiveHeatThreshold = 12; effectivePositionScale = 1.10; regimeLabel = 'RISK_ON'; - } - - // ── 이벤트 날짜 검증 (STALE_EVENT 탐지) ──────────────────────────── - var eventDateResults = []; - var staleEvents = []; - var analysisDate = new Date(); - (mes.events_used || []).forEach(function(ev) { - if (!ev || !ev.event_date) return; - var evDate = new Date(ev.event_date); - var valid = evDate >= analysisDate; - var r = { event_type: ev.event_type || 'UNKNOWN', event_date: ev.event_date, valid: valid, - status: valid ? 'VALID' : 'STALE_EVENT' }; - if (!valid) staleEvents.push(r); - eventDateResults.push(r); - }); - - return { - micro_risk_score: l1, - macro_risk_score_normalized: l2, - global_risk_score: l3, - event_risk_score: l4, - total_mrag_score: totalScore, - effective_heat_gate_threshold: effectiveHeatThreshold, - effective_position_size_scale: effectivePositionScale, - regime_label: regimeLabel, - event_date_validation_results: eventDateResults, - stale_events: staleEvents, - stale_events_count: staleEvents.length, - formula_id: 'MACRO_REGIME_ADAPTIVE_GATE_V2' - }; -} - - -// ---- from gas_apex_consistency_core.gs ---- - - -function calcConsistencyValidatorV2Impl_(hApex, asResult, cashFloorInfo, capturedAtIso, now) { - var passed = [], failed = [], gapList = []; - - function chk(id, name, testFn) { - try { - var r = testFn(); - if (r.ok) { - passed.push(id); - } else { - failed.push({ check_id: id, name: name, reason: r.reason || 'failed' }); - if (r.gaps) r.gaps.forEach(function(g) { gapList.push(g); }); - } - } catch(e) { - failed.push({ check_id: id, name: name, reason: 'exception:' + e.message }); - } - } - - // CV_01: sell_candidates tier 비감소 - chk('CV_01', 'sell_priority 방향 일관성', function() { - var cands = hApex.sell_candidates_json || []; - for (var i = 1; i < cands.length; i++) { - var ta = cands[i-1].tier, tb = cands[i].tier; - if (typeof ta === 'number' && typeof tb === 'number' && tb < ta) { - return { ok: false, reason: 'tier_reversal idx=' + i + '(' + tb + '<' + ta + ')' }; - } - } - return { ok: true }; - }); - - // CV_02: stop < close < tp1 (< tp2) - chk('CV_02', '가격 순서 검증', function() { - var prices = hApex.prices_json || []; - for (var i = 0; i < prices.length; i++) { - var p = prices[i]; - var stop = p.stop_price || 0, curr = p.current_price || p.close || 0, tp1 = p.tp1_price || 0; - if (stop > 0 && curr > 0 && stop >= curr) { - return { ok: false, reason: p.ticker + ':stop(' + stop + ')>=close(' + curr + ')' }; - } - if (curr > 0 && tp1 > 0 && curr >= tp1) { - return { ok: false, reason: p.ticker + ':close(' + curr + ')>=tp1(' + tp1 + ')' }; - } - } - return { ok: true }; - }); - - // CV_03: heat vs weight 비례성 (구조 확인용) - chk('CV_03', 'heat vs 보유 비중 일치', function() { - var holdings = asResult.holdings || []; - // heat_pct는 손실위험 기준, weight_pct는 평가비중 — 직접 비교 불가 - // 보유 종목 존재 확인 (구조 레벨 검증) - if (holdings.length > 0 && !hApex.execution_quality_json) { - return { ok: false, reason: 'execution_quality_json 없음 (보유종목 있음)' }; - } - return { ok: true }; - }); - - // CV_04: enum 유효성 (synthesis_verdict, rs_verdict) - chk('CV_04', 'enum 값 유효성', function() { - var VALID_SYNTH = ['STRONG_BUY_SIGNAL','MODERATE_BUY_SIGNAL','HOLD_NEUTRAL','TRIM_SIGNAL','EXIT_SIGNAL']; - var VALID_RS = ['LEADER','NEUTRAL','LAGGARD','BROKEN','UNKNOWN','N/A','']; - var paeList = hApex.predictive_alpha_json || []; - for (var i = 0; i < paeList.length; i++) { - var v = paeList[i].synthesis_verdict; - if (v && VALID_SYNTH.indexOf(v) < 0) { - return { ok: false, reason: paeList[i].ticker + ':invalid synthesis_verdict=' + v }; - } - } - var saqgList = hApex.saqg_json || []; - for (var j = 0; j < saqgList.length; j++) { - var rv = saqgList[j].rs_verdict; - if (rv && VALID_RS.indexOf(rv) < 0) { - return { ok: false, reason: saqgList[j].ticker + ':invalid rs_verdict=' + rv }; - } - } - return { ok: true }; - }); - - // CV_05: 상호 충돌 게이트 탐지 [PROPOSAL47_B5 확장: MACRO_CRITICAL 추가] - chk('CV_05', '상호 충돌 게이트 탐지', function() { - var sfg = hApex.satellite_failure_gate_json || {}; - var sfgTriggered = sfg.sfg_v1 === 'TRIGGERED'; - var megaSell = hApex.mega_sell_alert === true; - var macroCritical = hApex.macro_risk_regime === 'MACRO_CRITICAL'; - var buyPerms = hApex.buy_permission_json || []; - for (var i = 0; i < buyPerms.length; i++) { - var bp = buyPerms[i]; - var eligible = bp.buy_permission_state === 'ELIGIBLE' || bp.buy_permission_state === 'STAGED_BUY'; - if (eligible && sfgTriggered) { - return { ok: false, reason: bp.ticker + ':buy=ELIGIBLE but sfg=TRIGGERED' }; - } - if (eligible && megaSell && hApex.buy_gate_block_until) { - return { ok: false, reason: bp.ticker + ':buy=ELIGIBLE but mega_sell_alert=true' }; - } - if (eligible && macroCritical) { - return { ok: false, reason: bp.ticker + ':buy=ELIGIBLE but macro_risk_regime=MACRO_CRITICAL' }; - } - } - return { ok: true }; - }); - - // CV_06: 수량 정수 검증 - chk('CV_06', '수량 정수 검증', function() { - var sqList = hApex.smart_sell_quantities_json || []; - for (var i = 0; i < sqList.length; i++) { - var sq = sqList[i]; - if (typeof sq.sell_qty === 'number' && sq.sell_qty !== Math.floor(sq.sell_qty)) { - return { ok: false, reason: sq.ticker + ':sell_qty 소수점=' + sq.sell_qty }; - } - } - return { ok: true }; - }); - - // CV_07: 데이터 신선도 - chk('CV_07', '날짜 신선도', function() { - if (!capturedAtIso) return { ok: true }; - var capMs = new Date(capturedAtIso).getTime(); - if (isNaN(capMs)) return { ok: true }; - var nowMs = (now && now.getTime) ? now.getTime() : Date.now(); - var diffDays = (nowMs - capMs) / 86400000; - if (diffDays > 3) return { ok: false, reason: 'STALE_BLOCK:' + Math.round(diffDays) + '일 경과' }; - if (diffDays > 1) return { ok: false, reason: 'STALE_WARN:' + Math.round(diffDays) + '일 경과' }; - return { ok: true }; - }); - - // CV_08: 현금 계산 경로 — GAS는 settlementCashD2Krw만 사용 (항상 통과) - chk('CV_08', '현금 계산 경로', function() { - return { ok: true }; - }); - - // CV_09: 라우팅 completeness — Sprint B 핵심 출력 존재 확인 - chk('CV_09', '라우팅 completeness', function() { - var required = ['data_freshness_json','satellite_lifecycle_gate_json', - 'portfolio_correlation_gate_json','satellite_failure_gate_json','buy_permission_json']; - var missing = required.filter(function(k) { return hApex[k] === undefined; }); - if (missing.length > 0) { - return { ok: false, reason: 'missing:' + missing.join(','), - gaps: missing.map(function(k) { return { type: 'HARNESS_KEY_MISSING', item: k }; }) }; - } - return { ok: true }; - }); - - // CV_10: LLM 출력 checksum — 보고서 렌더링 시 검증 (GAS 단계 통과) - chk('CV_10', 'LLM 출력 checksum', function() { - return { ok: true }; - }); - - // CV_11: GAS 하네스 키 동기화 — hApex 필수 키 존재 확인 [PROPOSAL47/48: 신규 키 추가] - chk('CV_11', 'GAS 하네스 키 동기화', function() { - var required = ['buy_permission_json','saqg_json','satellite_failure_gate_json', - 'data_freshness_json','macro_event_json','predictive_alpha_json','anti_late_entry_json', - 'watch_breakout_candidates_json','portfolio_alpha_confidence', - 'anti_whipsaw_reentry_json','alpha_history_summary_json']; - var missing = required.filter(function(k) { return hApex[k] === undefined; }); - if (missing.length > 0) { - return { ok: false, reason: 'HARNESS_KEY_MISSING:' + missing.join(','), - gaps: missing.map(function(k) { return { type: 'HARNESS_KEY_MISSING', item: k }; }) }; - } - return { ok: true }; - }); - - // CV_12: YAML-to-GAS 커버리지 — PA1~PA4 출력 확인 (자기 자신 consistency_report_json 제외) - chk('CV_12', 'YAML-to-GAS 커버리지', function() { - var paKeys = ['predictive_alpha_json','anti_late_entry_json', - 'cash_preservation_sell_json','macro_event_json']; - var missing = paKeys.filter(function(k) { return hApex[k] === undefined; }); - if (missing.length > 0) { - return { ok: false, reason: 'GAS_COVERAGE_GAP:' + missing.join(','), - gaps: missing.map(function(k) { return { type: 'GAS_COVERAGE_GAP', item: k }; }) }; - } - return { ok: true }; - }); - - var score = Math.round(passed.length / 12 * 100); - var blockStatus = score < 90 ? 'BLOCK' : (score < 100 ? 'WARNING' : 'PASS'); - - return { - consistency_score: score, - cv_verdict: blockStatus, - passed: passed, - failed: failed, - gap_list: gapList, - block_status: blockStatus, - formula_id: 'CONSISTENCY_VALIDATOR_V2' - }; -} - - - -// ---- TASK-001: RELEASE_GATE_TRUTH_V1 ---- -// [GAS_STUB_ONLY: requires Google Sheets deployment] -function buildReleaseGateTruthV1_(hApex) { - // RC1 수정: honest_proof_score >= 70 이어야만 릴리스 허용 - // effective_release_gate = AND(cosmetic_gate, honest_gate) - var agp = hApex['algorithm_guidance_proof_v1'] || {}; - var honestScore = agp['honest_proof_score'] || 0; - var honestGate = agp['honest_gate'] || 'FAIL'; - var cosmeticGate = agp['gate'] || 'FAIL'; - var effectiveGate = (honestGate === 'PASS' && cosmeticGate === 'PASS') ? 'PASS' : 'FAIL'; - return { - formula_id: 'RELEASE_GATE_TRUTH_V1', - honest_proof_score: honestScore, - honest_gate: honestGate, - cosmetic_gate: cosmeticGate, - effective_release_gate: effectiveGate, - hts_order_mode: honestScore >= 70 ? 'HTS_ALLOWED' : 'THEORETICAL_ONLY', - release_blocked_note: honestScore < 70 - ? '[RELEASE_BLOCKED_BY_TRUTH_GATE: honest=' + honestScore + ' < 70]' - : null - }; -} - -// ---- TASK-002: NON_VACUOUS_PASS_GUARD_V1 ---- -// [GAS_STUB_ONLY: requires Google Sheets deployment] -function guardNonVacuousPass_(gateObj, minSamples) { - // RC2 수정: effective_n < minSamples 인 게이트를 WATCH_PENDING_SAMPLE로 강제 강등 - minSamples = minSamples || 30; - var nFields = ['sample_count','row_count','evaluated_count','samples','n','sample_n']; - var effectiveN = null; - for (var i = 0; i < nFields.length; i++) { - if (gateObj[nFields[i]] !== undefined && gateObj[nFields[i]] !== null) { - effectiveN = parseInt(gateObj[nFields[i]], 10); - break; - } - } - if (effectiveN === null) effectiveN = 0; - var gateVal = (gateObj['gate'] || '').toUpperCase(); - if (effectiveN < minSamples && gateVal === 'PASS') { - return { - gate: 'WATCH_PENDING_SAMPLE', - label: '[PASS_INVALID_LOW_N: n=' + effectiveN + ' < ' + minSamples + ']', - vacuous: true - }; - } - return { gate: gateVal, vacuous: false }; -} - -// ---- TASK-004: OPERATIONAL_SAMPLE_BACKFILL_V1 ---- -// [GAS_STUB_ONLY: requires Google Sheets deployment] -function evaluateOperationalOutcomeBatch_(proposalHistory, dataFeed, captureDate) { - // RC4 수정: LIVE/PAPER 제안의 T+5/T+20 실측 결과를 채움 - // live=0 상태이므로 현재는 scaffolded — 실측 표본 누적 후 활성화 - var results = []; - var opT5Count = 0; - var opT20Count = 0; - (proposalHistory || []).forEach(function(p) { - if (!p.origin || p.origin === 'REPLAY') return; // REPLAY 제외 - var today = captureDate ? new Date(captureDate) : new Date(); - var entryDate = p.entry_date ? new Date(p.entry_date) : null; - if (!entryDate) return; - var elapsedDays = Math.floor((today - entryDate) / 86400000); - var result = { id: p.id, origin: p.origin, entry_date: p.entry_date }; - if (elapsedDays >= 5 && p.realized_return_pct_t5 === undefined) { - result.t5_pending = true; // 실측 미채움 - } else if (p.realized_return_pct_t5 !== undefined) { - opT5Count++; - result.t5_filled = true; - } - if (elapsedDays >= 20 && p.realized_return_pct_t20 === undefined) { - result.t20_pending = true; - } else if (p.realized_return_pct_t20 !== undefined) { - opT20Count++; - result.t20_filled = true; - } - results.push(result); - }); - return { - formula_id: 'OPERATIONAL_SAMPLE_BACKFILL_V1', - operational_t5_sample_count: opT5Count, - operational_t20_sample_count: opT20Count, - unvalidated_label: opT5Count < 30 ? '[UNVALIDATED_LIVE: n=' + opT5Count + ' < 30]' : null, - results: results - }; -} - -// ---- TASK-005: EVALUATION_WINDOW_HONESTY_V1 ---- -// [GAS_STUB_ONLY: requires Google Sheets deployment] -function labelEvaluationWindow_(outcomeQualityJson) { - // RC5 수정: t20_source != operational_t20이면 T20_PROXY 플래그 - var t20Source = (outcomeQualityJson && outcomeQualityJson.t20_source) || null; - var isProxy = (t20Source !== 'operational_t20'); - return { - formula_id: 'EVALUATION_WINDOW_HONESTY_V1', - t20_source: t20Source, - t20_is_proxy: isProxy, - t20_label: isProxy ? 'T+20(추정,프록시)' : 'T+20(실측)', - release_gate_t20_alpha_blocked: isProxy, - proxy_note: isProxy - ? '[T20_PROXY: t20_source=' + t20Source + ' - 실측 T+20 표본 0건]' - : null - }; -} - -// ---- TASK-008: VALUE_PRESERVING_CASH_RAISE_V9 ---- -// [GAS_STUB_ONLY: requires Google Sheets deployment] -function calcValuePreservingCashRaiseV9_(sellCandidates, shortfallKrw, regimeLabel) { - // RC 수정: BREACH_FULL_LIQUIDATION 금지, K2 50/50 강제 - var REBOUND_FACTORS = {EVENT_SHOCK:0.7, RISK_OFF:0.6, NEUTRAL:0.5, RISK_ON:0.3}; - var reboundFactor = REBOUND_FACTORS[regimeLabel] || 0.5; - var result = []; - var totalDamagePct = 0, count = 0, breachCount = 0; - (sellCandidates || []).forEach(function(c) { - var qty = parseInt(c.qty || c.quantity || 0, 10); - var isOversold = c.rsi14 !== undefined && parseFloat(c.rsi14) < 30; - var brtNotBroken = c.brt_verdict !== 'BROKEN'; - var emergency = !!c.emergency_full_sell; - if ((isOversold || brtNotBroken) && !emergency) { - // K2 50/50 - var imm = Math.floor(qty / 2); - var wait = qty - imm; - var reboundTrigger = parseFloat(c.prev_close || 0) + reboundFactor * parseFloat(c.atr20 || 0); - result.push({ - ticker: c.ticker, - immediate_qty: imm, - rebound_wait_qty: wait, - rebound_trigger_price: Math.round(reboundTrigger), - k2_applied: true - }); - } else { - if (c.source === 'BREACH_FULL_LIQUIDATION' && !emergency) breachCount++; - result.push({ticker: c.ticker, immediate_qty: qty, rebound_wait_qty: 0, k2_applied: false}); - } - totalDamagePct += parseFloat(c.value_damage_pct || 0); - count++; - }); - var avgDamage = count > 0 ? totalDamagePct / count : 0; - return { - formula_id: 'VALUE_PRESERVING_CASH_RAISE_V9', - selected_sell_combo: result, - raw_value_damage_pct_avg: avgDamage, - rebound_capture_probability: result.some(function(r){return r.k2_applied;}) ? 0.5 : 0.0, - breach_full_liquidation_count: breachCount, - gate: (avgDamage <= 10 && breachCount === 0) ? 'PASS' : 'FAIL' - }; -} - -// ---- TASK-009: CAPITAL_STYLE_ALLOCATION_V2 ---- -// [GAS_STUB_ONLY: requires Google Sheets deployment] -function calcCapitalStyleAllocationV2_(ticker, proposalHistory, convictionScore) { - // 투자성향별 실측 승률로 가중치 보정 (표본 < 30 시 EXPERT_PRIOR 유지) - var styles = ['SCALP','SWING','MOMENTUM','POSITION']; - var result = {}; - styles.forEach(function(style) { - var samples = (proposalHistory || []).filter(function(p) { - return p.ticker === ticker && p.style === style && p.origin !== 'REPLAY' - && p.realized_return_pct_t5 !== undefined; - }); - var n = samples.length; - var wins = samples.filter(function(p){return parseFloat(p.realized_return_pct_t5||0)>0;}).length; - result[style] = { - sample_n: n, - win_rate: n >= 30 ? (wins/n) : null, - weight_source: n >= 30 ? 'DYNAMIC' : 'EXPERT_PRIOR', - label: n < 30 ? '[UNVALIDATED_WEIGHT: n=' + n + ' < 30]' : null - }; - }); - // conviction 게이트 - var recPct = convictionScore < 35 ? 0 - : convictionScore < 50 ? 1.5 - : convictionScore < 65 ? 3.0 - : convictionScore < 80 ? 5.0 : 7.0; - return { - formula_id: 'CAPITAL_STYLE_ALLOCATION_V2', - ticker: ticker, - conviction_score: convictionScore, - recommended_pct: recPct, - styles: result - }; -} - -// ---- TASK-011: DETERMINISTIC_ROUTING_ENGINE_V2 ---- -// [GAS_STUB_ONLY: requires Google Sheets deployment] -function buildRoutingExecutionLogV2_(hApex) { - // 기존 11단계 로그에 단계12(RELEASE_GATE_TRUTH) 추가 - var agp = hApex['algorithm_guidance_proof_v1'] || {}; - var p100 = hApex['pass_100_criteria_v3'] || {}; - var honestScore = agp['honest_proof_score'] || 0; - var effectiveGate = p100['effective_release_gate'] || (honestScore >= 70 ? 'PASS' : 'FAIL'); - var step12 = { - step: 12, - formula_id: 'RELEASE_GATE_TRUTH_V1', - label: '릴리스 진실 게이트', - status: effectiveGate, - honest_proof_score: honestScore, - effective_release_gate: effectiveGate, - hts_order_count_if_blocked: effectiveGate !== 'PASS' ? 0 : null, - blocked_note: effectiveGate !== 'PASS' - ? '[RELEASE_BLOCKED_BY_TRUTH_GATE: honest=' + honestScore + ' < 70]' - : null - }; - // 기존 routing_execution_log에 step12 추가 - var existing = hApex['routing_execution_log'] || {}; - var steps = Array.isArray(existing.steps) ? existing.steps.slice() : []; - steps.push(step12); - return Object.assign({}, existing, { - steps: steps, - stage_count_target: 12, - effective_release_gate: effectiveGate - }); -} diff --git a/gas_data_collect.gs b/gas_data_collect.gs deleted file mode 100644 index 12e4d68..0000000 --- a/gas_data_collect.gs +++ /dev/null @@ -1,8 +0,0 @@ -// gas_data_collect.gs — compatibility stub (P5-T02 GAS file split) -// -// 실제 구현은 src/gas_adapter_parts/ 로 이동: -// gdc_01_fetch_fundamentals.gs — fetch 인프라·Naver/Yahoo fetchers·펀더멘털·runDataFeed (L1-L2405) -// gdc_02_account_satellite.gs — 계좌스냅샷·티커셋업·위성배치·가격맵 (L2406-L4460) -// -// GAS 프로젝트에 모든 파일을 함께 추가하면 동일한 글로벌 네임스페이스에서 동작합니다. -// Ownership: data_feed 팀, QEDD P5-T02 diff --git a/gas_data_feed.gs b/gas_data_feed.gs deleted file mode 100644 index 435d9c7..0000000 --- a/gas_data_feed.gs +++ /dev/null @@ -1,21 +0,0 @@ -/** - * gas_data_feed.gs — Google Apps Script 버전 (compatibility stub) - * - * ⚠️ 이 파일은 P5-T02 GAS 역할 분리 작업의 호환성 스텁입니다. - * 실제 함수 구현은 src/gas_adapter_parts/ 아래 분리된 파일로 이동했습니다. - * - * gdf_01_price_metrics.gs — 가격 지표·RSI·Entry/Exit·점수·매도우선순위 (L1-L2347) - * gdf_02_harness_assembly.gs — 하네스 조립·라우팅·레짐·위성 (L2348-L4560) - * gdf_03_portfolio_gates.gs — 포트폴리오 게이트·섹터·액션·실행 (L4561-L6806) - * gdf_04_execution_quality.gs — 실행품질·Apex·PA1 피드백·매크로 (L6807-L9015) - * gdf_05_alpha_engines.gs — 알파엔진·서빙·거래품질·패턴 (L9016-L10302) - * - * GAS 프로젝트에 모든 파일을 함께 추가하면 동일한 글로벌 네임스페이스에서 동작합니다. - * - * 배포 방법: - * 1. script.google.com → 새 프로젝트 - * 2. 이 파일 + src/gas_adapter_parts/gdf_*.gs + src/gas_adapter_parts/gdc_*.gs 붙여넣기 - * 3. 트리거 설정: runDataFeed → 시간 기반 → 매일 → 16:30~17:30 - * - * Ownership: data_feed 팀, QEDD P5-T02 GAS file split - */ diff --git a/gas_event_calendar.gs b/gas_event_calendar.gs deleted file mode 100644 index 6a27a27..0000000 --- a/gas_event_calendar.gs +++ /dev/null @@ -1,907 +0,0 @@ -/** - * gas_event_calendar.gs — Market Event Calendar Harness (v2: Yahoo + Naver scrapers) - * - * 스크래핑 전략: - * - Yahoo Finance: __NEXT_DATA__ JSON 추출 (Next.js 내장 데이터) - * - Naver Finance: HTML 테이블 파싱 (경제지표 일정 + 실적발표) - * - 공통: fetchWithCache_() — CacheService(4h) + 지수 백오프 + stale fallback - * - * 블록킹 대응: - * - Chrome UA + Referer 헤더로 봇 판정 회피 - * - 429/503 → 재시도, 403/401 → 즉시 stale 사용 - * - 파싱 실패 시 빈 배열 반환 (에러 전파 없음) - */ - -const CFG = { - SPREADSHEET_ID: '1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM', - SHEET_NAME: 'event_calendar', - TIME_ZONE: 'Asia/Seoul', - DATE_FORMAT: 'yyyy-MM-dd', - ALERT_EMAIL: '', - - REQUIRED_HEADERS: ['Date', 'Event', 'Type', 'Impact', 'Alert'], - ALL_HEADERS: ['Date','Event','Type','Impact','Alert','DaysLeft','AlertStatus','LastCheckedAt','Source','SourceUrl','Key'], - - IMPACT_ALERT_WINDOW_DAYS: { HIGH: 7, MEDIUM: 3, LOW: 1 }, - VALID_TYPES: ['FOMC','US_CPI','US_PPI','US_PCE','US_NFP','EARNINGS','EXPIRY','BOK','KR_CPI','BOJ','FX','BOND','CUSTOM'], - VALID_IMPACTS: ['HIGH','MEDIUM','LOW'], - - JSON_SOURCE_PROPERTY: 'EVENT_JSON_URL', - CSV_SOURCE_PROPERTY: 'EVENT_CSV_URL', - - // 캐시·재시도 - CACHE_TTL_SEC: 4 * 60 * 60, - STALE_PROP_PREFIX: 'stale_url:', - MAX_RETRIES: 2, - RETRY_BASE_MS: 1500, - - // 스크래핑 - YAHOO_DAYS_AHEAD: 60, // Yahoo: 오늘부터 N일 앞까지 수집 - SCRAPE_SLEEP_MS: 700, // 요청 간 대기 (ms) — rate limit 회피 - CHROME_UA: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', -}; - - -/* ── 메뉴 ─────────────────────────────────────────────────────────────────── */ - -function onOpen() { - SpreadsheetApp.getUi() - .createMenu('Market Calendar') - .addItem('초기 설정', 'setup') - .addItem('검증 및 정렬', 'validateAndSort') - .addItem('임박 이벤트 알림 발송', 'sendEventAlerts') - .addSeparator() - .addItem('Trading Economics 새로고침', 'refreshFromTradingEconomics') - .addItem('Naver Finance 새로고침', 'refreshFromNaver') - .addItem('외부 URL 소스 새로고침', 'refreshFromSources') - .addSeparator() - .addItem('프로퍼티 캐시 청소', 'cleanUpProperties') - .addItem('샘플 데이터 삽입', 'loadSampleDataIfEmpty') - .addItem('매일 실행 트리거 설치', 'createDailyTrigger') - .addItem('트리거 삭제', 'deleteProjectTriggers') - .addToUi(); -} - -function setup() { - cleanUpProperties(); // 한도 초과 상태 해제를 위해 프로퍼티 캐시 청소 먼저 수행 - ensureSheetAndHeaders_(); - validateAndSort(); - createDailyTrigger(); - toast_('event_calendar 설정 완료', 5); -} - -function runDaily() { - // refreshFromTradingEconomics(); // 로컬 수집 방식을 사용하므로 원격 실행은 건너뜁니다. - refreshFromNaver(); - refreshFromSources(); - validateAndSort(); - sendEventAlerts(); -} - - -/* ── 검증·정렬 ────────────────────────────────────────────────────────────── */ - -function validateAndSort() { - const sheet = ensureSheetAndHeaders_(); - const hmap = getHeaderMap_(sheet); - const lastRow = sheet.getLastRow(); - if (lastRow < 2) { toast_('데이터 없음', 3); return; } - - const now = Utilities.formatDate(new Date(), CFG.TIME_ZONE, 'yyyy-MM-dd HH:mm:ss'); - const today = todayKst_(); - const range = sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()); - const values = range.getValues(); - - const I = { - date: hmap.Date-1, event: hmap.Event-1, type: hmap.Type-1, - impact: hmap.Impact-1, days: hmap.DaysLeft-1, - status: hmap.AlertStatus-1, checked: hmap.LastCheckedAt-1, key: hmap.Key-1, - }; - - const rows = values.map(row => { - const d = coerceDate_(row[I.date]); - const event = String(row[I.event] || '').trim(); - const type = String(row[I.type] || '').trim().toUpperCase(); - const impact = String(row[I.impact] || '').trim().toUpperCase(); - - const errs = []; - if (!d) errs.push('ERROR: invalid date'); - if (!event) errs.push('ERROR: empty event'); - if (type && !CFG.VALID_TYPES.includes(type)) errs.push('WARN: unknown type'); - if (impact && !CFG.VALID_IMPACTS.includes(impact)) errs.push('WARN: unknown impact'); - - if (d) { row[I.date] = d; row[I.days] = daysBetween_(today, d); } else row[I.days] = ''; - if (!row[I.key] && d && event) row[I.key] = buildKey_(d, event, type); - if (errs.length) row[I.status] = errs.join(' | '); - row[I.checked] = now; - row[I.type] = type; - row[I.impact] = impact; - return row; - }); - - rows.sort((a, b) => { - const da = coerceDate_(a[I.date]), db = coerceDate_(b[I.date]); - if (!da && !db) return 0; if (!da) return 1; if (!db) return -1; - return da - db; - }); - - range.setValues(rows); - sheet.getRange(2, hmap.Date, Math.max(lastRow-1,1), 1).setNumberFormat(CFG.DATE_FORMAT); - applyFormatting_(sheet, hmap); - toast_('검증 및 정렬 완료', 3); -} - - -/* ── 이메일 알림 ──────────────────────────────────────────────────────────── */ - -function sendEventAlerts() { - Logger.log('[sendEventAlerts] 이메일 알림 발송 기능 비활성화 (사용자 요청)'); - toast_('이메일 알림 발송 건너뜀 (비활성화)', 3); - return; - - const todayStr = Utilities.formatDate(new Date(), CFG.TIME_ZONE, CFG.DATE_FORMAT); - const props = PropertiesService.getScriptProperties(); - const due = []; - - rows.forEach(item => { - const impact = String(item.Impact || '').toUpperCase(); - const daysLeft = Number(item.DaysLeft); - if (!impact || isNaN(daysLeft) || daysLeft < 0) return; - if (daysLeft > (CFG.IMPACT_ALERT_WINDOW_DAYS[impact] || 0)) return; - const sentKey = `sent:${todayStr}:${item.Key || buildKey_(coerceDate_(item.Date), item.Event, item.Type)}`; - if (props.getProperty(sentKey)) return; - due.push({ ...item, DaysLeft: daysLeft, sentKey }); - }); - - if (!due.length) { toast_('오늘 발송할 알림 없음', 3); return; } - - const to = CFG.ALERT_EMAIL || Session.getActiveUser().getEmail(); - if (!to) throw new Error('ALERT_EMAIL 또는 사용자 이메일 필요'); - - MailApp.sendEmail({ to, subject: `[Market Calendar] 임박 이벤트 ${due.length}건`, body: buildEmailBody_(due) }); - - due.forEach(item => { - props.setProperty(item.sentKey, '1'); - if (hmap.AlertStatus) sheet.getRange(item.__row, hmap.AlertStatus).setValue(`SENT: ${todayStr}`); - }); - toast_(`알림 발송 완료: ${due.length}건`, 4); -} - - -/* ═══════════════════════════════════════════════════════════════════════════ * - * Trading Economics 스크래퍼 - * ══════════════════════════════════════════════════════════════════════════ */ - -function refreshFromTradingEconomics() { - return; // 로컬 수집 방식으로 이관되어 비활성화 - let sourceName = 'Trading Economics'; - let events = fetchTradingEconomicsCalendar_(CFG.YAHOO_DAYS_AHEAD); - - if (!events.length) { - Logger.log('[TradingEconomics] 차단 또는 결과 없음. Yahoo Finance 오늘 날짜 수집으로 Fallback합니다.'); - events = fetchYahooCalendar_(); - sourceName = 'Yahoo Fallback'; - } - - if (!events.length) { - toast_('캘린더: 수집된 이벤트 없음 (야후/TE 모두 차단 또는 일정 없음)', 4); - return; - } - - upsertEvents_(events); - toast_(`${sourceName} 갱신: ${events.length}건`, 4); -} - -function fetchTradingEconomicsCalendar_(daysAhead) { - Logger.log('[TradingEconomics] 로컬(클라이언트) 수집 방식을 사용하므로 구글 서버의 직접 호출은 건너뜁니다.'); - return []; - - const today = todayKst_(); - const startDateStr = Utilities.formatDate(today, CFG.TIME_ZONE, 'yyyy-MM-dd'); - const end = new Date(today.getFullYear(), today.getMonth(), today.getDate() + (daysAhead || 60)); - const endDateStr = Utilities.formatDate(end, CFG.TIME_ZONE, 'yyyy-MM-dd'); - - const cache = CacheService.getScriptCache(); - const cacheKey = `te_cal_parsed:${startDateStr}:${endDateStr}`; - const cachedData = cache.get(cacheKey); - - if (cachedData !== null) { - try { - const parsed = JSON.parse(cachedData); - if (Array.isArray(parsed)) { - return parsed; - } - } catch(e) { - Logger.log(`[TradingEconomics] 캐시 파싱 실패: ${e.message}`); - } - } - - const url = "https://tradingeconomics.com/calendar"; - const headers = { - 'User-Agent': CFG.CHROME_UA, - 'Cookie': `cal-custom-range=${startDateStr}|${endDateStr}` - }; - - let events = []; - try { - const html = fetchWithCache_(url, CFG.CACHE_TTL_SEC, headers); - if (html) { - events = parseTradingEconomicsHtml_(html); - if (events.length > 0) { - cache.put(cacheKey, JSON.stringify(events), 12 * 60 * 60); - } - } - } catch(e) { - Logger.log(`[TradingEconomics] 실패: ${e.message}`); - } - - return events; -} - -function fetchYahooCalendar_() { - const events = []; - const today = todayKst_(); - const dateStr = Utilities.formatDate(today, CFG.TIME_ZONE, CFG.DATE_FORMAT); - - const cache = CacheService.getScriptCache(); - const cacheKey = `yahoo_cal_parsed:${dateStr}`; - const cachedData = cache.get(cacheKey); - - if (cachedData !== null) { - try { - const parsed = JSON.parse(cachedData); - if (Array.isArray(parsed)) { - return parsed; - } - } catch(e) { - Logger.log(`[Yahoo Fallback] 캐시 파싱 실패: ${e.message}`); - } - } - - // 야후는 day 파라미터가 무시되므로 오늘 날짜 1일치만 fetch합니다. - const url = `https://finance.yahoo.com/calendar/economic?day=${dateStr}`; - const headers = { 'User-Agent': 'Mozilla/5.0 (compatible; GAS/1.0)' }; - - try { - const html = fetchWithCache_(url, CFG.CACHE_TTL_SEC, headers); - if (html) { - const dailyEvents = parseYahooHtml_(html, today); - if (dailyEvents.length > 0) { - cache.put(cacheKey, JSON.stringify(dailyEvents), 12 * 60 * 60); - events.push(...dailyEvents); - } - } - } catch(e) { - Logger.log(`[Yahoo Fallback] 실패: ${e.message}`); - } - return events; -} - -function parseYahooHtml_(html, dateHint) { - const trMatches = html.match(/]*data-testid="data-table-v2-row"[\s\S]*?<\/tr>/gi); - if (!trMatches || !trMatches.length) { - Logger.log('[Yahoo Fallback] table row 없음'); - return []; - } - - const events = []; - const dateStr = Utilities.formatDate(dateHint, CFG.TIME_ZONE, CFG.DATE_FORMAT); - - for (let i = 0; i < trMatches.length; i++) { - const trHtml = trMatches[i]; - - const getCell_ = (testId) => { - const regex = new RegExp(`data-testid-cell=["']${testId}["'][^>]*>([\\s\\S]*?)`, 'i'); - const m = trHtml.match(regex); - if (m) { - let val = m[1].replace(/<[^>]+>/g, ' '); - val = val.replace(/\s+/g, ' ').trim(); - return val; - } - return ''; - }; - - const eventName = getCell_('econ_release'); - const country = getCell_('country_code'); - const actual = getCell_('after_release_actual'); - const estimate = getCell_('consensus_estimate'); - const prior = getCell_('prior_release_actual'); - - if (!eventName) continue; - - let rawImpact = 'LOW'; - if (trHtml.toLowerCase().includes('high') || trHtml.toLowerCase().includes('red') || trHtml.toLowerCase().includes('priority-3')) { - rawImpact = 'HIGH'; - } else if (trHtml.toLowerCase().includes('medium') || trHtml.toLowerCase().includes('orange') || trHtml.toLowerCase().includes('priority-2')) { - rawImpact = 'MEDIUM'; - } - - const type = guessEventType_(eventName, country); - const finalImpact = guessImpact_(type, eventName) || rawImpact; - - const countryUpper = String(country || '').toUpperCase().trim(); - const allowedCountries = ['US', 'KR', 'JP']; - - if (!allowedCountries.includes(countryUpper)) { - continue; - } - - if (type === 'CUSTOM' && finalImpact === 'LOW') { - continue; - } - - let alertText = ''; - if (actual && actual !== '-') alertText += `Act: ${actual} `; - if (estimate && estimate !== '-') alertText += `Est: ${estimate} `; - if (prior && prior !== '-') alertText += `Prev: ${prior}`; - alertText = alertText.trim(); - - events.push({ - Date: dateStr, - Event: eventName, - Type: type, - Impact: finalImpact, - Alert: alertText, - Source: 'Yahoo Finance', - SourceUrl: 'https://finance.yahoo.com/calendar/economic', - }); - } - - return events; -} - -function parseTradingEconomicsHtml_(html) { - // data-event가 들어있는 모든 tr 매칭 - const trMatches = html.match(/]*data-event="[^"]*"[\s\S]*?<\/tr>/gi); - if (!trMatches) { - Logger.log('[TradingEconomics] event table row 없음'); - return []; - } - - const events = []; - - for (let i = 0; i < trMatches.length; i++) { - const trHtml = trMatches[i]; - - // td들로 쪼개기 - const tdMatches = trHtml.match(/]*>([\s\S]*?)<\/td>/gi); - if (!tdMatches || tdMatches.length < 9) continue; - - // 1) 날짜 추출 (td[0]의 class 속성) - const td0 = tdMatches[0]; - const dateMatch = td0.match(/class=["'](\d{4}-\d{2}-\d{2})["']/i); - if (!dateMatch) continue; - const dateStr = dateMatch[1]; - - // 2) 중요도 추출 (td[0] 내부의 calendar-date-N 클래스) - let impact = 'LOW'; - if (td0.includes('calendar-date-3')) { - impact = 'HIGH'; - } else if (td0.includes('calendar-date-2')) { - impact = 'MEDIUM'; - } - - // 3) 국가 코드 추출 (td[3] 내부 텍스트) - const td3 = tdMatches[3]; - const countryIsoMatch = td3.match(/>([^<]+)]*>([^<]+)/i); - if (!eventMatch) continue; - const eventName = eventMatch[1].trim(); - - // 5) Actual, Previous, Consensus 값 추출 (HTML 태그 제거 및 공백 정규화) - const cleanTdText = (tdHtml) => { - let val = tdHtml.replace(/<[^>]+>/g, ' '); - val = val.replace(/\s+/g, ' ').trim(); - return val; - }; - - const actualVal = cleanTdText(tdMatches[5]); - const previousVal = cleanTdText(tdMatches[6]); - const consensusVal = cleanTdText(tdMatches[7]); - - // 6) 국가 필터링 (US, KR, JP만 수집) - const allowedCountries = ['US', 'KR', 'JP']; - if (!allowedCountries.includes(countryIso)) { - continue; - } - - const type = guessEventType_(eventName, countryIso); - const finalImpact = guessImpact_(type, eventName) || impact; - - // 중요도가 LOW이면서 핵심 분류 유형(FOMC 등)이 아닌 일반 CUSTOM 데이터는 제외 - if (type === 'CUSTOM' && finalImpact === 'LOW') { - continue; - } - // ────────────────────────── - - let alertText = ''; - if (actualVal && actualVal !== '-') alertText += `Act: ${actualVal} `; - if (consensusVal && consensusVal !== '-') alertText += `Est: ${consensusVal} `; - if (previousVal && previousVal !== '-') alertText += `Prev: ${previousVal}`; - alertText = alertText.trim(); - - events.push({ - Date: dateStr, - Event: eventName, - Type: type, - Impact: finalImpact, - Alert: alertText, - Source: 'Trading Economics', - SourceUrl: 'https://tradingeconomics.com/calendar', - }); - } - - return events; -} - - -/* ═══════════════════════════════════════════════════════════════════════════ * - * Naver Finance 스크래퍼 - * - * 1) 경제지표 일정: https://finance.naver.com/market/news/economic.naver - * → 날짜·제목이 포함된 뉴스 목록 테이블 파싱 - * - * 2) 실적 발표: https://finance.naver.com/market/news/announce.naver - * → 기업 실적 발표 일정 테이블 파싱 - * - * 블록킹 대응: - * - Referer: https://finance.naver.com/ 필수 - * - Accept-Language: ko-KR 설정 - * - 429 → fetchWithCache_ 재시도·stale 자동 처리 - * ══════════════════════════════════════════════════════════════════════════ */ - -function refreshFromNaver() { - const events = fetchNaverCalendar_(); - if (!events.length) { toast_('Naver: 수집된 이벤트 없음 (차단 또는 일정 없음)', 4); return; } - upsertEvents_(events); - toast_(`Naver Finance 갱신: ${events.length}건`, 4); -} - -function fetchNaverCalendar_() { - const headers = { - 'User-Agent': CFG.CHROME_UA, - 'Referer': 'https://finance.naver.com/', - 'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8', - }; - - const events = []; - - // 1. 경제 속보 뉴스 리스트 긁기 (EUC-KR 인코딩) - try { - const url = 'https://finance.naver.com/news/news_list.naver?mode=LSS2D§ion_id=101§ion_id2=258'; - const html = fetchWithCache_(url, CFG.CACHE_TTL_SEC, headers, 'EUC-KR'); - if (html) events.push(...parseNaverNewsList_(html, 'KR', 'Naver 뉴스 속보', url)); - } catch(e) { Logger.log('[Naver News List] ' + e.message); } - - return events; -} - -/** - * Naver Finance 뉴스 목록 HTML에서 기사 제목과 발행일을 추출. - */ -function parseNaverNewsList_(html, region, sourceName, sourceUrl) { - const events = []; - - // articleSubject 및 wdate 추출용 정규식 - const subjectPattern = /
[\s\S]*?]*>([\s\S]*?)<\/a>/gi; - const wdatePattern = /([\s\S]*?)<\/span>/i; - - let match; - while ((match = subjectPattern.exec(html)) !== null) { - const link = match[1].trim(); - const titleRaw = match[2].trim(); - - // HTML 태그 제거 및 공백 정규화 - const eventName = titleRaw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); - if (eventName.length < 5 || /^\d+$/.test(eventName)) continue; - - // 이 매치 직후 최대 1000자 범위 내에서 가장 가까운 wdate 매칭 - const pos = subjectPattern.lastIndex; - const subHtml = html.substring(pos, pos + 1000); - const dateMatch = wdatePattern.exec(subHtml); - - if (dateMatch) { - const dateStrRaw = dateMatch[1].trim(); - const dateOnly = dateStrRaw.split(' ')[0]; // YYYY-MM-DD - const eventDate = coerceDate_(dateOnly); - - if (eventDate && eventName) { - const type = guessEventType_(eventName, region); - events.push({ - Date: eventDate, - Event: eventName, - Type: type, - Impact: guessImpact_(type, eventName), - Alert: '', - Source: sourceName, - SourceUrl: 'https://finance.naver.com' + link, - }); - } - } - } - - return events; -} - - -/* ── 외부 URL 소스 (기존 유지) ───────────────────────────────────────────── */ - -function refreshFromSources() { - const props = PropertiesService.getScriptProperties(); - const jsonUrl = props.getProperty(CFG.JSON_SOURCE_PROPERTY); - const csvUrl = props.getProperty(CFG.CSV_SOURCE_PROPERTY); - if (!jsonUrl && !csvUrl) { toast_('외부 URL 없음 — Script Properties 확인', 4); return; } - - const events = []; - if (jsonUrl) events.push(...fetchEventsFromJson_(jsonUrl)); - if (csvUrl) events.push(...fetchEventsFromCsv_(csvUrl)); - if (!events.length) { toast_('외부 소스 이벤트 없음', 3); return; } - - upsertEvents_(events); - validateAndSort(); - toast_(`외부 이벤트 갱신: ${events.length}건`, 5); -} - -function fetchEventsFromJson_(url) { - const text = fetchWithCache_(url); - if (!text) return []; - const parsed = JSON.parse(text); - if (!Array.isArray(parsed)) throw new Error('JSON source는 배열이어야 합니다.'); - return parsed.map(normalizeEvent_); -} - -function fetchEventsFromCsv_(url) { - const text = fetchWithCache_(url); - if (!text) return []; - const csv = Utilities.parseCsv(text); - if (csv.length < 2) return []; - const headers = csv[0].map(h => String(h || '').trim()); - return csv.slice(1).map(row => { - const obj = {}; - headers.forEach((h, i) => { obj[h] = row[i]; }); - return normalizeEvent_(obj); - }); -} - - -/* ═══════════════════════════════════════════════════════════════════════════ * - * fetchWithCache_ — CacheService + 재시도 + stale fallback - * - * signature: fetchWithCache_(url, ttlSec?, extraHeaders?, encoding?) - * - ttlSec: 캐시 유효기간 (기본 CFG.CACHE_TTL_SEC) - * - extraHeaders: 추가 HTTP 헤더 (스크래핑 시 UA/Referer 주입용) - * - encoding: 응답 문자셋 인코딩 (기본 'UTF-8') - * ══════════════════════════════════════════════════════════════════════════ */ -function fetchWithCache_(url, ttlSec, extraHeaders, encoding) { - const cache = CacheService.getScriptCache(); - const cacheKey = 'url:' + md5_(url); - - // 1. Cache HIT - const hit = cache.get(cacheKey); - if (hit !== null) return hit; - - // 2. Fetch with retry - const opts = { - muteHttpExceptions: true, - followRedirects: true, - headers: Object.assign({ 'User-Agent': CFG.CHROME_UA }, extraHeaders || {}), - }; - - const charset = encoding || 'UTF-8'; - - for (let attempt = 0; attempt <= CFG.MAX_RETRIES; attempt++) { - if (attempt > 0) Utilities.sleep(CFG.RETRY_BASE_MS * attempt); - let resp; - try { resp = UrlFetchApp.fetch(url, opts); } - catch(e) { Logger.log(`[fetch] 예외 (${attempt}): ${e.message}`); continue; } - - const code = resp.getResponseCode(); - if (code === 429 || code === 503) { Utilities.sleep(2500 * (attempt+1)); continue; } // 일시 블록 - if (code === 403 || code === 401) { Logger.log(`[fetch] ${code} 영구 블록: ${url}`); break; } - if (code < 200 || code >= 300) { Logger.log(`[fetch] HTTP ${code} (${attempt}): ${url}`); continue; } - - const text = resp.getContentText(charset); - try { - cache.put(cacheKey, text, ttlSec || CFG.CACHE_TTL_SEC); - } catch(e) { - // 100KB 초과 HTML은 캐싱 크기 제한으로 실패하는 것이 정상이므로 로그 남기지 않고 패스 - } - return text; - } - - Logger.log(`[fetch] 실패: ${url}`); - return null; -} - - -/* ── Upsert / Sample ─────────────────────────────────────────────────────── */ - -function upsertEvents_(events) { - if (!events.length) return; - const sheet = ensureSheetAndHeaders_(); - const hmap = getHeaderMap_(sheet); - const rowByKey = {}; - getDataObjects_(sheet, hmap).forEach(item => { if (item.Key) rowByKey[item.Key] = item.__row; }); - - events.forEach(ev => { - const d = coerceDate_(ev.Date); - if (!d || !ev.Event) return; - const type = String(ev.Type || 'CUSTOM').toUpperCase(); - const key = ev.Key || buildKey_(d, ev.Event, type); - const vals = { Date:d, Event:ev.Event, Type:type, Impact:String(ev.Impact||'MEDIUM').toUpperCase(), - Alert:ev.Alert||'', Source:ev.Source||'', SourceUrl:ev.SourceUrl||'', Key:key }; - - if (rowByKey[key]) { - Object.keys(vals).forEach(h => { if (hmap[h]) sheet.getRange(rowByKey[key], hmap[h]).setValue(vals[h]); }); - } else { - const row = new Array(sheet.getLastColumn()).fill(''); - Object.keys(vals).forEach(h => { if (hmap[h]) row[hmap[h]-1] = vals[h]; }); - sheet.appendRow(row); - } - }); -} - -function loadSampleDataIfEmpty() { - const sheet = ensureSheetAndHeaders_(); - if (sheet.getLastRow() > 1) { toast_('이미 데이터 있음 — 삽입 생략', 4); return; } - sheet.getRange(2,1,6,5).setValues([ - ['2026-06-17','FOMC 금리결정','FOMC','HIGH','금리동결 시 KOSPI +1~2% 기대'], - ['2026-07-28','FOMC 금리결정','FOMC','HIGH',''], - ['2026-06-11','미국 CPI (5월)','US_CPI','HIGH','예상치 상회 시 당일 신규매수 자제'], - ['2026-07-15','미국 CPI (6월)','US_CPI','HIGH','FOMC 전 마지막 CPI'], - ['2026-06-20','삼성전자 1Q 잠정실적','EARNINGS','HIGH','반도체 섹터 선행 지표'], - ['2026-06-15','옵션만기일','EXPIRY','MEDIUM','변동성 확대 구간 주의'], - ]); - validateAndSort(); -} - - -/* ── 트리거 ──────────────────────────────────────────────────────────────── */ - -function createDailyTrigger() { - const fn = 'runDaily'; - ScriptApp.getProjectTriggers().filter(t => t.getHandlerFunction() === fn).forEach(t => ScriptApp.deleteTrigger(t)); - ScriptApp.newTrigger(fn).timeBased().everyDays(1).atHour(8).create(); - toast_('매일 오전 8시 트리거 설치 완료', 4); -} - -function deleteProjectTriggers() { - ScriptApp.getProjectTriggers().forEach(t => ScriptApp.deleteTrigger(t)); - toast_('트리거 삭제 완료', 4); -} - -function setJsonSourceUrl() { _saveUrlProp_(CFG.JSON_SOURCE_PROPERTY, 'EVENT_JSON_URL'); } -function setCsvSourceUrl() { _saveUrlProp_(CFG.CSV_SOURCE_PROPERTY, 'EVENT_CSV_URL'); } -function _saveUrlProp_(k, label) { - const v = Browser.inputBox(label + '를 입력하세요.'); - if (v && v !== 'cancel') PropertiesService.getScriptProperties().setProperty(k, v); -} - - -/* ── 이벤트 타입·임팩트 추론 헬퍼 ───────────────────────────────────────── */ - -/** - * 이벤트 이름으로 타입을 추론. - * region: 'US' | 'KR' (기본 'US') - */ -const TYPE_MAP_ = [ - { keys: ['FOMC','연준','Federal Open Market','Fed Rate'], type: 'FOMC' }, - { keys: ['CPI','소비자물가','Consumer Price'], type: null }, // region 분기 - { keys: ['PPI','생산자물가','Producer Price'], type: 'US_PPI' }, - { keys: ['PCE','개인소비지출','Personal Consumption'], type: 'US_PCE' }, - { keys: ['NFP','비농업','Nonfarm','Payroll'], type: 'US_NFP' }, - { keys: ['실적','잠정실적','Earnings','EPS','Revenue'], type: 'EARNINGS' }, - { keys: ['옵션만기','선물만기','만기일','Expiry','Triple Witching'], type: 'EXPIRY' }, - { keys: ['한국은행','금통위','BOK','Bank of Korea'], type: 'BOK' }, - { keys: ['환율','FX','Dollar','달러'], type: 'FX' }, - { keys: ['국채','채권','Bond','Treasury'], type: 'BOND' }, - { keys: ['BOJ','일본은행','Bank of Japan','BOJ Rate','BOJ Interest'], type: 'BOJ' }, -]; - -function guessEventType_(eventName, region) { - const upper = String(eventName || '').toUpperCase(); - const reg = String(region || '').toUpperCase().trim(); - - for (const rule of TYPE_MAP_) { - if (rule.keys.some(k => upper.includes(k.toUpperCase()))) { - if (rule.type === null) { - // CPI 분기: 한국 CPI vs 미국 CPI (타국 CPI는 CUSTOM 처리하여 오인 방지) - if (reg === 'KR' || upper.includes('한국') || upper.includes('KR')) return 'KR_CPI'; - if (reg === 'US' || upper.includes('미국') || upper.includes('US')) return 'US_CPI'; - return 'CUSTOM'; - } - - // PPI, PCE, NFP, FOMC 등 미국 전용 타입들은 국가 코드가 US인 경우에만 해당 타입 할당, 타국은 CUSTOM 처리 - const usOnlyTypes = ['US_PPI', 'US_PCE', 'US_NFP', 'FOMC']; - if (usOnlyTypes.includes(rule.type) && reg !== 'US' && reg !== '') { - return 'CUSTOM'; - } - - // BOJ 일본은행 전용 타입은 국가 코드가 JP인 경우에만 해당 타입 할당, 타국은 CUSTOM 처리 - if (rule.type === 'BOJ' && reg !== 'JP' && reg !== '') { - return 'CUSTOM'; - } - - return rule.type; - } - } - return 'CUSTOM'; -} - -/** 타입 기반 기본 임팩트 */ -function guessImpact_(type, eventName) { - const highTypes = ['FOMC','US_CPI','US_NFP','BOK','KR_CPI','BOJ']; - const medTypes = ['US_PPI','US_PCE','EARNINGS','EXPIRY']; - if (highTypes.includes(type)) return 'HIGH'; - if (medTypes.includes(type)) return 'MEDIUM'; - return 'LOW'; -} - - -/* ── 내부 헬퍼 (compact) ─────────────────────────────────────────────────── */ - -function safeGet_(obj, keys) { - return keys.reduce((o, k) => (o && o[k] !== undefined ? o[k] : null), obj); -} - -function getSpreadsheet_() { - return CFG.SPREADSHEET_ID ? SpreadsheetApp.openById(CFG.SPREADSHEET_ID) : SpreadsheetApp.getActiveSpreadsheet(); -} - -function ensureSheetAndHeaders_() { - const ss = getSpreadsheet_(); - const sheet = ss.getSheetByName(CFG.SHEET_NAME) || ss.insertSheet(CFG.SHEET_NAME); - const lastCol = Math.max(sheet.getLastColumn(), 1); - const existing = sheet.getRange(1,1,1,lastCol).getValues()[0].map(h => String(h||'').trim()); - if (!existing.some(Boolean)) { - sheet.getRange(1,1,1,CFG.ALL_HEADERS.length).setValues([CFG.ALL_HEADERS]); - return sheet; - } - const missing = CFG.ALL_HEADERS.filter(h => !existing.includes(h)); - if (missing.length) sheet.getRange(1, sheet.getLastColumn()+1, 1, missing.length).setValues([missing]); - const hmap = getHeaderMap_(sheet); - CFG.REQUIRED_HEADERS.forEach(h => { if (!hmap[h]) throw new Error(`필수 헤더 없음: ${h}`); }); - return sheet; -} - -function getHeaderMap_(sheet) { - const map = {}; - sheet.getRange(1,1,1,sheet.getLastColumn()).getValues()[0] - .forEach((h,i) => { const k=String(h||'').trim(); if(k) map[k]=i+1; }); - return map; -} - -function getDataObjects_(sheet, hmap) { - const lastRow = sheet.getLastRow(); - if (lastRow < 2) return []; - const headers = Object.keys(hmap); - const lastCol = sheet.getLastColumn(); - return sheet.getRange(2,1,lastRow-1,lastCol).getValues().map((row,r) => { - const obj = { __row: r+2 }; - headers.forEach(h => { obj[h] = row[hmap[h]-1]; }); - return obj; - }); -} - -function normalizeEvent_(obj) { - return { - Date: obj.Date || obj.date, - Event: obj.Event || obj.event || obj.title || obj.name, - Type: obj.Type || obj.type || 'CUSTOM', - Impact: obj.Impact || obj.impact || 'MEDIUM', - Alert: obj.Alert || obj.alert || '', - Source: obj.Source || obj.source || '', - SourceUrl: obj.SourceUrl || obj.sourceUrl || obj.url || '', - Key: obj.Key || obj.key || '', - }; -} - -function coerceDate_(v) { - if (v instanceof Date && !isNaN(v)) return new Date(v.getFullYear(), v.getMonth(), v.getDate()); - if (typeof v === 'string') { - const m = v.trim().match(/^(\d{4})[-./](\d{1,2})[-./](\d{1,2})/); - if (m) return new Date(+m[1], +m[2]-1, +m[3]); - } - return null; -} - -function todayKst_() { - return coerceDate_(Utilities.formatDate(new Date(), CFG.TIME_ZONE, CFG.DATE_FORMAT)); -} - -function daysBetween_(a, b) { - return Math.round( - (new Date(b.getFullYear(),b.getMonth(),b.getDate()) - - new Date(a.getFullYear(),a.getMonth(),a.getDate())) / 86400000 - ); -} - -function buildKey_(dateObj, eventName, type) { - return md5_([Utilities.formatDate(dateObj,CFG.TIME_ZONE,CFG.DATE_FORMAT), - String(type||'').toUpperCase(), String(eventName||'').trim()].join('|')); -} - -function md5_(text) { - return Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, text, Utilities.Charset.UTF_8) - .map(b => ('0'+(b<0?b+256:b).toString(16)).slice(-2)).join(''); -} - -function buildEmailBody_(events) { - const fmt = d => d instanceof Date ? Utilities.formatDate(d,CFG.TIME_ZONE,CFG.DATE_FORMAT) : String(d); - return [ - '시장 이벤트 임박 알림','', - '기준: '+Utilities.formatDate(new Date(),CFG.TIME_ZONE,'yyyy-MM-dd HH:mm:ss'),'', - ...events.flatMap((item,i) => [ - `${i+1}. [${item.Impact}] ${fmt(item.Date)} / D-${item.DaysLeft}`, - ` Event: ${item.Event}`, ` Type: ${item.Type}`, - ...(item.Alert?[` Alert: ${item.Alert}`]:[]),'', - ]), - '이 알림은 자동 알림이며 투자 판단의 최종 근거가 아닙니다.', - ].join('\n'); -} - -function applyFormatting_(sheet, hmap) { - const lastRow = Math.max(sheet.getLastRow(),1), lastCol = Math.max(sheet.getLastColumn(),1); - sheet.getRange(1,1,1,lastCol).setFontWeight('bold'); - sheet.setFrozenRows(1); - for (let c=1;c<=lastCol;c++) sheet.autoResizeColumn(c); - if (lastRow >= 2) { - if (hmap.Impact) sheet.getRange(2,hmap.Impact, lastRow-1,1).setFontWeight('bold'); - if (hmap.DaysLeft) sheet.getRange(2,hmap.DaysLeft,lastRow-1,1).setNumberFormat('0'); - } -} - -function toast_(msg, sec) { - try { - const activeSs = SpreadsheetApp.getActive(); - if (activeSs) { - activeSs.toast(msg, 'Market Calendar', sec); - } else { - Logger.log('[TOAST] ' + msg); - } - } catch (e) { - Logger.log('[TOAST] ' + msg); - } -} - -/** - * 용량을 극도로 많이 소모하는 Script Properties의 캐시성 데이터(stale_url, cal_parsed 등)를 청소. - * SPREADSHEET_ID 나 sf_w2_ranks_json 같은 중요 설정/운영 데이터는 보호합니다. - */ -function cleanUpProperties() { - const props = PropertiesService.getScriptProperties(); - const keys = props.getKeys(); - let deleteCount = 0; - - // SPREADSHEET_ID, sf_w2_ranks_json, EVENT_JSON_URL, EVENT_CSV_URL, HARNESS_VERBOSE_LOG 등 설정은 제외 - const protectedKeys = ['SPREADSHEET_ID', 'sf_w2_ranks_json', 'EVENT_JSON_URL', 'EVENT_CSV_URL', 'HARNESS_VERBOSE_LOG']; - - keys.forEach(k => { - if (protectedKeys.includes(k)) { - return; - } - - // 캐시 관련 접두사를 가진 항목 및 임시 런타임 상태값 삭제 - const shouldDelete = - k.indexOf('stale_url:') === 0 || - k.indexOf('yahoo_cal_parsed:') === 0 || - k.indexOf('te_cal_parsed:') === 0 || - k.indexOf('url:') === 0 || - k.indexOf('fetch_budget_') === 0 || - k.indexOf('fetch_fail_') === 0 || - k.indexOf('fetch_circuit_') === 0 || - k.indexOf('fetch_session_') === 0 || - k.indexOf('cs_') === 0; - - if (shouldDelete) { - props.deleteProperty(k); - deleteCount++; - } - }); - - toast_(`프로퍼티 캐시 청소 완료: ${deleteCount}건 삭제`, 5); -} diff --git a/gas_harness_rows.gs b/gas_harness_rows.gs deleted file mode 100644 index 5aa9be5..0000000 --- a/gas_harness_rows.gs +++ /dev/null @@ -1,1456 +0,0 @@ -// gas_harness_rows.gs - Harness output serialization -// buildHarnessRows_, assertHarnessRowsComplete_, checksum functions -// Pure output assembly - no decision logic. Rarely changes after V stabilizes. -// GAS global scope: functions in gas_data_feed.gs callable directly - - -/** - * computeBlueprintChecksum_ - * order_blueprint_json의 위변조 탐지용 체크섬 (CRC32_V1). - * ticker + order_type + quantity + limit_price_krw + validation_status 를 - * 행 순서대로 연결한 문자열의 char-code sum을 반환한다. - * Python converter는 이 값과 자신이 재계산한 값이 다르면 HARNESS_INTEGRITY_FAIL 처리. - */ -function computeBlueprintChecksum_(blueprint) { - var s = ''; - blueprint = blueprint || []; - for (var i = 0; i < blueprint.length; i++) { - var r = blueprint[i]; - s += String(r.ticker || '') + '|' - + String(r.order_type || '') + '|' - + String(r.quantity != null ? r.quantity : '') + '|' - + String(r.limit_price_krw != null ? r.limit_price_krw : '') + '|' - + String(r.validation_status || '') + ';'; - } - var sum = 0; - for (var j = 0; j < s.length; j++) { - sum = (sum + s.charCodeAt(j)) & 0xFFFFFFFF; - } - return sum; -} - - -/** - * [2026-05-20_HARNESS_V5] computeInputSnapshotChecksum_ - * 계좌 스냅샷 원장(보유수량·평단·종가·현금·기준시각)의 CRC32_V1 해시. - * 동일 입력 재호출 시 이 값이 달라지면 데이터 소스가 갱신된 것이다. - * Python 검증기가 이전 실행값과 비교하여 non_deterministic_flag 를 set 한다. - */ -function computeInputSnapshotChecksum_(asResult, capturedAtIso) { - var s = String(capturedAtIso || '') + '|' - + String((asResult || {}).settlementCashD2Krw != null - ? asResult.settlementCashD2Krw : '') + '|'; - ((asResult || {}).holdings || []).forEach(function(h) { - s += String(h.ticker || '') + '|' - + String(h.holdingQty != null ? h.holdingQty : '') + '|' - + String(h.avgCost != null ? h.avgCost : '') + '|' - + String(h.close != null ? h.close : '') + ';'; - }); - var sum = 0; - for (var i = 0; i < s.length; i++) { - sum = (sum + s.charCodeAt(i)) & 0xFFFFFFFF; - } - return sum; -} - - -/** - * I3: computeStringChecksum_ - * 임의 문자열의 char-code sum 체크섬 (CRC32_V1 방식). - * source_manifest_json, decision_trace_json 등에 사용. - */ -function computeStringChecksum_(str) { - var s = typeof str === 'string' ? str : JSON.stringify(str); - if (s === undefined || s === null) s = ''; - var sum = 0; - for (var i = 0; i < s.length; i++) { - sum = (sum + s.charCodeAt(i)) & 0xFFFFFFFF; - } - return sum; -} - - -// ── 출력 행 빌더 ───────────────────────────────────────────────────────────── - -function 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 -) { - var sourceManifest = [ - { name: 'GatherTradingData.json', type: 'JSON', status: 'PENDING_EXPORT' }, - { name: 'data_feed', type: 'GOOGLE_SHEETS', status: 'OK' }, - { name: 'sector_flow', type: 'GOOGLE_SHEETS', status: 'OK' }, - { name: 'macro', type: 'GOOGLE_SHEETS', status: 'OK' }, - { name: 'event_risk', type: 'GOOGLE_SHEETS', status: 'OK' }, - { name: 'account_snapshot', type: 'GOOGLE_SHEETS', status: 'OK' }, - { name: 'backdata_feature_bank', type: 'GOOGLE_SHEETS', status: 'OK' }, - { name: 'harness_context', type: 'GOOGLE_SHEETS', status: 'OK' } - ]; - - // ── G1: CASH_SHORTFALL_V1 사전 계산 ───────────────────────────────────── - // LLM이 "약 N원 필요" 즉석 계산 금지 — GAS 결정론적 산출 후 잠금 - var g1TargetCashPct = cashShortfallInfo.cash_target_pct; - var g1ShortfallMin = cashShortfallInfo.cash_shortfall_min_krw; - var g1ShortfallTgt = cashShortfallInfo.cash_shortfall_target_krw; - var g1CashCurrentPct = cashShortfallInfo.cash_current_pct_d2; - - // ── G2: TRIM_PLAN_MIN_CASH_V1 사전 계산 ────────────────────────────────── - // 현금 회복용 종목별 TRIM 계획 — LLM 즉석 선택 금지, GAS 우선순위 기반 확정 - var g2SellQtyMap = {}; - h3.sellQty.forEach(function(sq) { g2SellQtyMap[sq.ticker] = sq; }); - var g2CloseMap = {}; - asResult.holdings.forEach(function(h) { - var df = dfMap[h.ticker] || {}; - g2CloseMap[h.ticker] = h.close || df.close || 0; - }); - var g2TrimPlan = []; - var g2Accum = 0; - var g2Shortfall = g1ShortfallMin; - h2.candidates.forEach(function(cand) { - var sqRow = g2SellQtyMap[cand.ticker] || {}; - var sellQty = sqRow.sell_qty; - var close = g2CloseMap[cand.ticker] || 0; - var estKrw = 0; - if (typeof sellQty === 'number' && sellQty > 0 && close > 0) { - estKrw = Math.round(sellQty * close); - } - g2Accum += estKrw; - g2TrimPlan.push({ - rank: cand.rank, - ticker: cand.ticker, - name: cand.name || '', - tier: cand.tier, - sell_qty: typeof sellQty === 'number' ? sellQty : (sellQty || null), - estimated_sell_krw: estKrw, - accumulated_krw: g2Accum, - covers_shortfall: g2Shortfall > 0 ? g2Accum >= g2Shortfall : true - }); - }); - - // ── M4: 5억원 목표 자산 추적 사전 계산 ──────────────────────────────────── - var M4_GOAL_KRW = 500000000; - var m4Asset = Number.isFinite(totalAsset) ? totalAsset : 0; - var m4Achieve = m4Asset > 0 ? Math.round(m4Asset / M4_GOAL_KRW * 1000) / 10 : 0; - var m4Remain = Math.max(0, M4_GOAL_KRW - m4Asset); - var m4NetExp30 = (performance && Number.isFinite(performance.net_expectancy_30)) - ? performance.net_expectancy_30 : null; - var m4EtaMonths = null; - var m4EtaLabel = 'DATA_MISSING'; - if (m4Asset >= M4_GOAL_KRW) { - m4EtaMonths = 0; - m4EtaLabel = 'ACHIEVED'; - } else if (m4Asset > 0 && m4NetExp30 !== null && m4NetExp30 > 0) { - m4EtaMonths = Math.ceil(Math.log(M4_GOAL_KRW / m4Asset) / Math.log(1 + m4NetExp30 / 100)); - var m4EtaDate = new Date(now.getTime()); - m4EtaDate.setMonth(m4EtaDate.getMonth() + m4EtaMonths); - m4EtaLabel = m4EtaDate.getFullYear() + '-' - + String(m4EtaDate.getMonth() + 1).padStart(2, '0'); - } - - // ── P6: 사용자 판단용 제안표 확정값 (PROPOSAL_REFERENCE_V1) ──────────────── - // 보고서가 WATCH/BLOCKED 행을 복원 추론하지 않도록 하네스가 제안 레이어를 직접 잠금 - var p6PriceMap = {}; - (h4.prices || []).forEach(function(row) { p6PriceMap[row.ticker] = row; }); - var p6SellQtyMap = {}; - (h3.sellQty || []).forEach(function(row) { p6SellQtyMap[row.ticker] = row; }); - var p6BuyQtyMap = {}; - (h3.buyQtyInputs || []).forEach(function(row) { p6BuyQtyMap[row.ticker] = row; }); - var p6DecisionMap = {}; - (h5.decisions || []).forEach(function(row) { p6DecisionMap[row.ticker] = row; }); - var p6BlueprintMap = {}; - (orderBlueprint || []).forEach(function(row) { p6BlueprintMap[row.ticker] = row; }); - var p6TpLadderMap = {}; - (tpLadderRows || []).forEach(function(row) { p6TpLadderMap[row.ticker] = row; }); - var p6ProfitMap = {}; - (((hApex || {}).profit_preservation_json) || []).forEach(function(row) { p6ProfitMap[row.ticker] = row; }); - var p6BuyPermissionMap = {}; - (((hApex || {}).buy_permission_json) || []).forEach(function(row) { p6BuyPermissionMap[row.ticker] = row; }); - var p6AlphaLeadMap = {}; - (((hApex || {}).alpha_lead_json) || []).forEach(function(row) { p6AlphaLeadMap[row.ticker] = row; }); - var p6SellRankMap = {}; - var p6Candidates_ = (h2 && h2.candidates) ? h2.candidates : []; - for (var sr = 0; sr < p6Candidates_.length; sr++) { - p6SellRankMap[p6Candidates_[sr].ticker] = p6Candidates_[sr].rank; - } - var p6Tickers = {}; - Object.keys(p6PriceMap).forEach(function(t) { p6Tickers[t] = true; }); - Object.keys(p6SellQtyMap).forEach(function(t) { p6Tickers[t] = true; }); - Object.keys(p6BuyQtyMap).forEach(function(t) { p6Tickers[t] = true; }); - Object.keys(p6DecisionMap).forEach(function(t) { p6Tickers[t] = true; }); - Object.keys(p6BlueprintMap).forEach(function(t) { p6Tickers[t] = true; }); - var p6Rows = []; - Object.keys(p6Tickers).sort(function(a, b) { - var ra = p6SellRankMap[a] != null ? p6SellRankMap[a] : 9999; - var rb = p6SellRankMap[b] != null ? p6SellRankMap[b] : 9999; - var da = p6DecisionMap[a] || {}; - var db = p6DecisionMap[b] || {}; - var oa = p6BlueprintMap[a] || {}; - var ob = p6BlueprintMap[b] || {}; - var actionA = String(da.final_action || oa.order_type || 'WATCH').toUpperCase(); - var actionB = String(db.final_action || ob.order_type || 'WATCH').toUpperCase(); - function bucket_(action) { - if (action.indexOf('SELL') >= 0 || action.indexOf('TRIM') >= 0 || action.indexOf('EXIT') >= 0 || action.indexOf('STOP_LOSS') >= 0 || action.indexOf('TAKE_PROFIT') >= 0 || action.indexOf('TRAILING_STOP') >= 0) return 0; - if (action.indexOf('BUY') >= 0 || action.indexOf('ADD_ON') >= 0 || action.indexOf('PILOT') >= 0 || action.indexOf('STAGED') >= 0) return 1; - if (action.indexOf('WATCH') >= 0 || action.indexOf('HOLD') >= 0) return 2; - return 3; - } - var ba = bucket_(actionA); - var bb = bucket_(actionB); - if (ba !== bb) return ba - bb; - if (ra !== rb) return ra - rb; - if (ba === 1 || ba === 2) { - var buyStateOrder_ = { ALLOW_ADD_ON: 0, ALLOW_PILOT: 1, WATCH: 2, BLOCKED: 3 }; - var buyA = p6BuyPermissionMap[a] || {}; - var buyB = p6BuyPermissionMap[b] || {}; - var alphaA = p6AlphaLeadMap[a] || {}; - var alphaB = p6AlphaLeadMap[b] || {}; - var sa = buyStateOrder_[String(buyA.buy_permission_state || '').toUpperCase()] || 99; - var sb = buyStateOrder_[String(buyB.buy_permission_state || '').toUpperCase()] || 99; - if (sa !== sb) return sa - sb; - var aa = -(typeof alphaA.alpha_lead_score === 'number' ? alphaA.alpha_lead_score : 0); - var ab = -(typeof alphaB.alpha_lead_score === 'number' ? alphaB.alpha_lead_score : 0); - if (aa !== ab) return aa - ab; - } - return a < b ? -1 : (a > b ? 1 : 0); - }).forEach(function(ticker) { - var p = p6PriceMap[ticker] || {}; - var s = p6SellQtyMap[ticker] || {}; - var b = p6BuyQtyMap[ticker] || {}; - var d = p6DecisionMap[ticker] || {}; - var o = p6BlueprintMap[ticker] || {}; - var t = p6TpLadderMap[ticker] || {}; - var pp = p6ProfitMap[ticker] || {}; - var finalAction = String(d.final_action || o.order_type || 'WATCH').toUpperCase(); - var orderType = String(o.order_type || '').toUpperCase(); - var priorityGroup = 3; - var priorityRank = 9999; - if (finalAction.indexOf('SELL') >= 0 || finalAction.indexOf('TRIM') >= 0 || finalAction.indexOf('EXIT') >= 0 || finalAction.indexOf('STOP_LOSS') >= 0 || finalAction.indexOf('TAKE_PROFIT') >= 0 || finalAction.indexOf('TRAILING_STOP') >= 0) { - priorityGroup = 0; - priorityRank = p6SellRankMap[ticker] != null ? p6SellRankMap[ticker] : priorityRank; - } else if (finalAction.indexOf('BUY') >= 0 || finalAction.indexOf('ADD_ON') >= 0 || finalAction.indexOf('PILOT') >= 0 || finalAction.indexOf('STAGED') >= 0) { - priorityGroup = 1; - var bp = p6BuyPermissionMap[ticker] || {}; - var ap = p6AlphaLeadMap[ticker] || {}; - var buyStateOrder_ = { ALLOW_ADD_ON: 0, ALLOW_PILOT: 1, WATCH: 2, BLOCKED: 3 }; - priorityRank = 10000 + ((buyStateOrder_[String(bp.buy_permission_state || '').toUpperCase()] || 99) * 1000) - + (100 - (typeof ap.alpha_lead_score === 'number' ? ap.alpha_lead_score : 0)); - } else if (finalAction.indexOf('WATCH') >= 0 || finalAction.indexOf('HOLD') >= 0) { - priorityGroup = 2; - var hp = p6BuyPermissionMap[ticker] || {}; - var ha = p6AlphaLeadMap[ticker] || {}; - var holdStateOrder_ = { ALLOW_ADD_ON: 0, ALLOW_PILOT: 1, WATCH: 2, BLOCKED: 3 }; - priorityRank = 20000 + ((holdStateOrder_[String(hp.buy_permission_state || '').toUpperCase()] || 99) * 1000) - + (100 - (typeof ha.alpha_lead_score === 'number' ? ha.alpha_lead_score : 0)); - } - var proposalType = '관찰 제안'; - var priceBasis = '하네스 기준 참고가'; - var qtyBasis = '수량 입력 없음'; - var proposedLimit = null; - var proposedTp = p.tp1_price || p.tp2_price || null; - var proposedQty = null; - if (finalAction.indexOf('BUY') >= 0 || orderType.indexOf('BUY') >= 0 || b.final_qty != null) { - proposalType = '매수 제안'; - proposedLimit = o.limit_price_krw != null ? o.limit_price_krw : (b.entry_price_hint || null); - priceBasis = '매수 제안가 우선'; - proposedQty = b.final_qty != null ? b.final_qty : null; - qtyBasis = '매수 수량 우선'; - } else if (finalAction.indexOf('TAKE_PROFIT') >= 0 || orderType.indexOf('TAKE_PROFIT') >= 0) { - proposalType = '익절 제안'; - proposedLimit = p.tp1_price || p.tp2_price || null; - priceBasis = '익절가 우선'; - proposedQty = s.sell_qty != null ? s.sell_qty : null; - qtyBasis = '매도 수량 우선'; - } else if (s.sell_qty != null || ['SELL_READY', 'SELL', 'TRIM', 'EXIT_100', 'EXIT_FULL'].indexOf(finalAction) >= 0) { - proposalType = (finalAction === 'WATCH' || finalAction === 'HOLD') ? '관찰 제안' : '매도 제안'; - proposedLimit = p.stop_price != null ? p.stop_price : null; - priceBasis = (finalAction === 'WATCH' || finalAction === 'HOLD') ? '주문가 아님: 참고 방어가' : '방어가 우선'; - proposedQty = s.sell_qty != null ? s.sell_qty : null; - qtyBasis = (finalAction === 'WATCH' || finalAction === 'HOLD') ? '주문 수량 아님: 참고 수량' : '매도 수량 우선'; - } else if (finalAction === 'WATCH' || finalAction === 'HOLD' || orderType === 'WATCH') { - proposalType = '관찰 제안'; - proposedLimit = p.stop_price != null ? p.stop_price : null; - priceBasis = '주문가 아님: 참고 방어가'; - proposedQty = s.sell_qty != null ? s.sell_qty : null; - qtyBasis = '주문 수량 아님: 참고 수량'; - } - if (proposedLimit == null && proposedQty == null && p.stop_price == null && proposedTp == null) return; - var executionStatus = 'EXECUTION_WAIT'; - if (String(o.validation_status || '') === 'PASS') { - executionStatus = 'EXECUTION_READY'; - } else if (finalAction === 'WATCH' || finalAction === 'HOLD' || orderType === 'WATCH') { - executionStatus = 'PROPOSAL_ONLY'; - } - var blockReason = o.rationale_code || '하네스 기준 제안 유지'; - if (!o.rationale_code && Array.isArray(d.gate_trace) && d.gate_trace.length) { - blockReason = d.gate_trace[d.gate_trace.length - 1].reason || blockReason; - } - var positionClass = String(p.position_class || '').toLowerCase(); - var baseStopQty = s.holding_qty != null ? s.holding_qty : proposedQty; - var stop1Qty = null; - var stop2Qty = null; - if (typeof baseStopQty === 'number' && baseStopQty > 0) { - var stop1Ratio = positionClass === 'core' ? 0.50 : 0.70; - stop1Qty = Math.floor(baseStopQty * stop1Ratio); - if (stop1Qty <= 0) stop1Qty = 1; - if (stop1Qty > baseStopQty) stop1Qty = baseStopQty; - stop2Qty = baseStopQty - stop1Qty; - if (stop2Qty <= 0) stop2Qty = null; - } - var stop3Price = null; - if (pp.auto_trailing_stop != null) { - stop3Price = pp.auto_trailing_stop; - } else if (String(p.profit_lock_stage || 'NORMAL') !== 'NORMAL' && pp.protected_stop_price != null) { - stop3Price = pp.protected_stop_price; - } - var stop3Qty = null; - if (stop3Price != null) { - stop3Qty = t.tp3_qty != null ? t.tp3_qty : (p.tp3_qty != null ? p.tp3_qty : null); - if (stop3Qty == null && typeof baseStopQty === 'number' && baseStopQty > 0) { - var tp1Qty = t.tp1_qty != null ? t.tp1_qty : (p.tp1_qty != null ? p.tp1_qty : 0); - var tp2Qty = t.tp2_qty != null ? t.tp2_qty : (p.tp2_qty != null ? p.tp2_qty : 0); - var residualQty = baseStopQty - tp1Qty - tp2Qty; - stop3Qty = residualQty > 0 ? residualQty : null; - } - } - p6Rows.push({ - account: o.account || s.account || b.account || p.account || d.account || '', - ticker: ticker, - name: o.name || s.name || b.name || p.name || d.name || '', - proposal_type: proposalType, - proposed_limit_price_krw: proposedLimit, - proposed_price_basis: priceBasis, - proposed_quantity: proposedQty, - proposed_quantity_basis: qtyBasis, - priority_group: priorityGroup, - priority_rank: priorityRank, - proposed_stop_price_krw: p.stop_price != null ? p.stop_price : null, - stop1_price_krw: p.stop_price != null ? p.stop_price : null, - stop1_quantity: stop1Qty, - stop2_price_krw: stop2Qty != null ? p.stop_price : null, - stop2_quantity: stop2Qty, - stop3_price_krw: stop3Price, - stop3_quantity: stop3Qty, - tp1_price_krw: t.tp1_price != null ? t.tp1_price : (p.tp1_price != null ? p.tp1_price : null), - tp1_quantity: t.tp1_qty != null ? t.tp1_qty : (p.tp1_qty != null ? p.tp1_qty : null), - tp2_price_krw: t.tp2_price != null ? t.tp2_price : (p.tp2_price != null ? p.tp2_price : null), - tp2_quantity: t.tp2_qty != null ? t.tp2_qty : (p.tp2_qty != null ? p.tp2_qty : null), - tp3_price_krw: null, - tp3_quantity: t.tp3_qty != null ? t.tp3_qty : (p.tp3_qty != null ? p.tp3_qty : null), - execution_status: executionStatus, - block_reason: blockReason - }); - }); - - return [ - // ── 메타 ───────────────────────────────────────────────────────── - ['harness_version', HARNESS_VERSION], - ['computed_at', formatIso_(now)], - // [PROPOSAL50] P0-2: ROUTING_TRACE_V1 동적값 — 정적 하드코딩 제거 - ['request_route', ((hApex || {}).routing_trace_json || {}).request_route || 'PIPELINE_EOD_BATCH'], - ['route_reason_code', 'RUN_EVENT_RISK_CHAIN'], - ['bundle_selected', ((hApex || {}).routing_trace_json || {}).bundle_selected || 'retirement_portfolio_ultra_compact'], - ['prompt_entrypoint', ((hApex || {}).routing_trace_json || {}).prompt_entrypoint || 'prompts/analysis_prompt.md'], - // [PROPOSAL50] P0-1: EXPORT_GATE_V1 동적값 — PENDING_EXPORT 정적 하드코딩 제거 - ['json_validation_status', (hApex || {}).json_validation_status || 'PENDING_EXPORT'], - ['capture_required', String(((hApex || {}).routing_trace_json || {}).capture_required != null - ? (hApex.routing_trace_json.capture_required) : true)], - ['cash_ledger_basis', ((hApex || {}).routing_trace_json || {}).cash_ledger_basis || 'D2_ONLY'], - ['source_manifest_json', JSON.stringify(sourceManifest)], - - // ── H1: P4 가드 ─────────────────────────────────────────────── - ['captured_at', capturedAtIso], - ['intraday_lock', intradayLock], - ['snapshot_execution_gate', snapshotGate.status], - ['snapshot_execution_reason', snapshotGate.reason], - ['account_snapshot_freshness_json', JSON.stringify(snapshotFreshness || {})], - ['intraday_lock_reason', - intradayLock - ? 'captured_at < 15:30 KST — P4 적용: EXIT_100/SELL_FULL/BUY 차단' - : 'captured_at >= 15:30 KST — 정상 장마감 데이터'], - ['p4_guard', intradayLock ? 'ACTIVE' : 'INACTIVE'], - - // ── H1: 현금 (P3 가드) ──────────────────────────────────────── - ['immediate_cash_krw', asResult.immediateCashKrw], - ['settlement_cash_d2_krw', asResult.settlementCashD2Krw], - ['open_order_amount_krw', asResult.openOrderAmountKrw], - ['buy_power_krw', buyPowerKrw], - ['total_asset_krw', totalAsset], - ['settlement_cash_pct', settlementCashPct], - ['p3_guard', - 'ACTIVE — settlement_cash_d2_krw only. ' - + 'cash_floor 및 buy_power_krw 는 D+2 정산현금 단독 기준. ' - + 'immediate_cash_krw 는 참고값이며 cash ledger 합산 금지.'], - - // ── H1: cash_floor ──────────────────────────────────────────── - ['cash_floor_min_pct', cashFloorInfo.minPct], - ['cash_floor_regime', cashFloorInfo.regime], - ['cash_floor_status', cashFloorInfo.status], - - // ── G1: 현금 부족액 / 목표현금 확정값 (CASH_SHORTFALL_V1) ───────────────── - // LLM 즉석 계산 금지: "약 N원 필요" 는 이 필드 복사만 허용 - ['cash_current_pct_d2', g1CashCurrentPct], - ['cash_target_pct', g1TargetCashPct], - ['cash_shortfall_min_krw', g1ShortfallMin], - ['cash_shortfall_target_krw', g1ShortfallTgt], - - // ── G2: 현금 회복 TRIM 계획 (TRIM_PLAN_MIN_CASH_V1) ────────────────────── - // 매도우선순위(H2) 기반 종목별 TRIM 순서·예상금액 하네스 확정 — LLM 임의 선택 금지 - ['trim_plan_to_min_cash_json', JSON.stringify(g2TrimPlan)], - - ['mrs_score', mrsScore], - ['performance_multiplier', performance.bayesian_multiplier], - ['performance_label', performance.bayesian_label], - ['performance_win_rate_30', performance.win_rate_30], - ['performance_net_expectancy_30', performance.net_expectancy_30], - ['performance_consecutive_losses', performance.consecutive_losses], - ['performance_trades_used', performance.trades_used], - - // ── H1: Total Heat ──────────────────────────────────────────── - ['total_heat_krw', Math.round(asResult.totalHeatKrw)], - ['total_heat_pct', totalHeatPct], - ['total_heat_atr_estimated', asResult.heatAtrEstimated], - ['total_heat_rows_counted', asResult.heatRowsCount], - ['heat_gate_status', heatGate], - ['heat_gate_threshold_pct', heatThresholds ? heatThresholds.hardBlock : HEAT_HARD_BLOCK_PCT], - - // ── H1: 허용/차단 액션 ──────────────────────────────────────── - ['allowed_actions', JSON.stringify(actions.allowed)], - ['blocked_actions', JSON.stringify(actions.blocked)], - - // ── H2: 매도후보 순위 ───────────────────────────────────────── - ['sell_candidates_json', JSON.stringify(h2.candidates)], - ['sell_priority_checksum', computeStringChecksum_(JSON.stringify(((h2 && h2.candidates) || []).map(function(c) { - return { - rank: c.rank, - ticker: c.ticker, - tier: c.tier, - score: (typeof c.sell_priority_score === 'number') ? c.sell_priority_score : c.score - }; - })))], - ['sell_priority_lock', 'true'], - ['sell_priority_computed_at', formatIso_(now)], - ['sell_candidates_count', ((h2 && h2.candidates) ? h2.candidates.length : 0)], - ['sell_priority_leader_holdback', JSON.stringify(((h2 && h2.candidates) || []).map(function(c) { - return { - ticker: c.ticker, - rank: c.rank, - tier: c.tier, - sell_priority_score: c.sell_priority_score, - rebound_holdback_score: c.rebound_holdback_score || 0, - trim_style: c.trim_style || '', - cash_preserve_style: c.cash_preserve_style || '', - cash_preserve_ratio: c.cash_preserve_ratio || 0, - }; - }))], - - // ── H3: 수량 ───────────────────────────────────────────────── - ['sell_quantities_json', JSON.stringify(h3.sellQty)], - ['buy_qty_inputs_json', JSON.stringify(h3.buyQtyInputs)], - ['quantities_lock', 'true'], - - // ── H4: 가격 ───────────────────────────────────────────────── - ['prices_json', JSON.stringify(h4.prices)], - ['prices_lock', 'true'], - - // ── H5: 결정 상태머신 ───────────────────────────────────────── - ['decisions_json', JSON.stringify(h5.decisions)], - ['decision_trace_json', (function() { - var full = JSON.stringify(h5.traces || []); - if (full.length <= 45000) return full; - // blocked_actions / inputs_used 는 전 항목 공통값 반복 → 제거해 압축 - var slim = (h5.traces || []).map(function(t) { - return { ticker: t.ticker, state: t.state, result: t.result, - selected_action: t.selected_action, reason: t.reason }; - }); - return JSON.stringify(slim); - })()], - ['decision_lock', 'true'], - - // ── H6: HTS 주문 렌더링 + Blueprint 무결성 해시 ───────────────── - ['order_blueprint_json', JSON.stringify(orderBlueprint)], - ['blueprint_row_count', (orderBlueprint || []).length], - ['blueprint_checksum', computeBlueprintChecksum_(orderBlueprint)], - ['blueprint_hash_algo', 'CRC32_V1'], - ['render_validation_status', 'READY'], - ['proposal_reference_json', JSON.stringify(p6Rows)], - ['proposal_reference_lock', 'true'], - - // ── I3: CHECKSUM_V2 — 결정론적 체크섬 강화 ────────────────────────────── - // 동일 입력/기준시각에서 네 체크섬이 모두 일치해야 NON_DETERMINISTIC_OUTPUT 방지 - ['source_manifest_checksum', computeStringChecksum_(JSON.stringify(sourceManifest))], - ['decision_trace_checksum', computeStringChecksum_(JSON.stringify(h5.traces))], - // ── [2026-05-20_HARNESS_V5] 신규 체크섬 ───────────────────────────────── - // input_snapshot_checksum: 계좌 캡처 원장(보유수량·평단·현금)의 스냅샷 해시. - // 동일 입력 재호출 시 이 값이 변하면 데이터 소스가 갱신된 것이다. - ['input_snapshot_checksum', computeInputSnapshotChecksum_(asResult, capturedAtIso)], - // rendered_output_checksum: blueprint와 동일한 주문 행 해시 (canonical). - ['rendered_output_checksum', computeBlueprintChecksum_(orderBlueprint)], - // rendered_report_checksum: legacy alias. 신규 검증은 rendered_output_checksum 우선. - ['rendered_report_checksum', computeBlueprintChecksum_(orderBlueprint)], - // non_deterministic_flag: Python 검증기가 이전 실행값과 비교 후 설정. GAS는 항상 false. - ['non_deterministic_flag', 'false'], - ['checksum_hash_algo', 'CRC32_V1'], - - // ── Alpha-Shield: X1/X3/W1~W4 선행 레이더 ─────────────────── - ['alpha_shield_json', - JSON.stringify((hAlpha || {}).per_holding || [])], - ['alpha_shield_lock', 'true'], - ['alpha_shield_critical_alert_count', - String((hAlpha || {}).critical_alert_count || 0)], - ['alpha_shield_critical_alert_flag', - ((hAlpha || {}).critical_alert_count || 0) > 0 ? 'CRITICAL' : 'OK'], - ['alpha_shield_computed_at', formatIso_(now)], - ['alpha_shield_formula_ids', - 'MRG001[X1],RS001[X3],W1_DIVERGENCE,W2_OVERHANG,W3_ROTATION,W4_FLOW_ACCEL'], - - // ── APEX V1: 판단 자료 생성 시점 로직 하네스 ───────────────────────────── - // 텍스트 가이드라인이 아니라 GAS가 직접 산출한 매수/매도/현금확보 실행 판단 자료 - ['alpha_lead_json', JSON.stringify((hApex || {}).alpha_lead_json || [])], - ['alpha_lead_lock', 'true'], - ['backdata_feature_bank_json', JSON.stringify(((hApex || {}).backdata_feature_bank_json || []).slice(-50))], - ['backdata_learning_lock', 'true'], - ['follow_through_json', JSON.stringify((hApex || {}).follow_through_json || [])], - ['follow_through_lock', 'true'], - ['distribution_risk_json', JSON.stringify((hApex || {}).distribution_risk_json || [])], - ['distribution_lock', 'true'], - ['profit_preservation_json', JSON.stringify((hApex || {}).profit_preservation_json || [])], - ['profit_preservation_lock', 'true'], - ['cash_raise_plan_json', JSON.stringify((hApex || {}).cash_raise_plan_json || [])], - ['rebound_sell_trigger_json', JSON.stringify((hApex || {}).rebound_sell_trigger_json || [])], - ['smart_sell_quantities_json', JSON.stringify((hApex || {}).smart_sell_quantities_json || [])], - ['smart_cash_raise_lock', 'true'], - ['execution_quality_json', JSON.stringify((hApex || {}).execution_quality_json || [])], - ['execution_quality_lock', 'true'], - ['buy_permission_json', JSON.stringify((hApex || {}).buy_permission_json || [])], - ['limit_price_policy_json', JSON.stringify((hApex || {}).limit_price_policy_json || [])], - ['regime_adjusted_sell_priority_json', JSON.stringify((hApex || {}).regime_adjusted_sell_priority_json || [])], - ['benchmark_relative_timeseries_json', JSON.stringify((hApex || {}).benchmark_relative_timeseries_json || [])], - ['index_relative_health_json', JSON.stringify((hApex || {}).index_relative_health_json || [])], - ['saqg_json', JSON.stringify((hApex || {}).saqg_json || [])], - ['cash_creation_purpose_lock_json', JSON.stringify((hApex || {}).cash_creation_purpose_lock_json || [])], - ['alpha_feedback_json', JSON.stringify((hApex || {}).alpha_feedback_json || {})], - ['alpha_evaluation_window_json', JSON.stringify((hApex || {}).alpha_evaluation_window_json || [])], - ['entry_freshness_json', JSON.stringify((hApex || {}).entry_freshness_json || [])], - ['sell_value_preservation_json', JSON.stringify((hApex || {}).sell_value_preservation_json || [])], - // ── [2026-05-20_HARNESS_V5] Gate 4b: FTD 확인 상태 잠금 - ['follow_through_confirm_json', JSON.stringify((hApex || {}).follow_through_confirm_json || [])], - ['follow_through_confirm_lock', 'true'], - // L1: 섹터 로테이션 모멘텀 - ['sector_rotation_momentum_json', JSON.stringify(sectorMomentumRows || [])], - ['sector_rotation_momentum_lock', 'true'], - - // ── M1: DRAWDOWN_GUARD_V1 ──────────────────────────────────── - ['drawdown_guard_state', (drawdownGuard || {}).state || 'NORMAL'], - ['drawdown_buy_scale', (drawdownGuard || {}).buy_scale !== undefined - ? (drawdownGuard || {}).buy_scale : 1.0], - ['drawdown_consecutive_losses', (drawdownGuard || {}).consecutive_losses || 0], - - // ── M2: PORTFOLIO_BETA_GATE_V1 ────────────────────────────── - ['portfolio_beta', (portfolioBetaGate || {}).portfolio_beta !== null - ? (portfolioBetaGate || {}).portfolio_beta : 'N/A'], - ['portfolio_beta_gate', (portfolioBetaGate || {}).gate_status || 'INSUFFICIENT_DATA'], - ['portfolio_beta_gate_json', JSON.stringify(portfolioBetaGate || {})], - - // ── M3: TP_QUANTITY_LADDER_V1 ─────────────────────────────── - ['tp_quantity_ladder_json', JSON.stringify(tpLadderRows || [])], - ['tp_quantity_ladder_lock', 'true'], - - // ── M4: EVENT_RISK_HOLD_GATE_V1 ───────────────────────────── - ['event_risk_json', JSON.stringify(eventRiskRows || [])], - ['event_risk_lock', 'true'], - - // ── M5: SECTOR_CONCENTRATION_LIMIT_V1 ─────────────────────── - ['sector_concentration_gate', (sectorConcentration || {}).gate_status || 'PASS'], - ['sector_concentration_json', JSON.stringify((sectorConcentration || {}).by_sector || [])], - - // ── N1: POSITION_SIZE_REGIME_SCALE_V1 ─────────────────────── - ['regime_size_scale', (regimeSizeScale || {}).scale !== undefined ? (regimeSizeScale || {}).scale : 1.0], - - // ── N3: STOP_PRICE_ADEQUACY_V1 ────────────────────────────── - ['stop_adequacy_json', JSON.stringify(stopAdequacyRows || [])], - ['stop_adequacy_lock', 'true'], - - // ── N4: HOLDING_STALE_REVIEW_V1 ───────────────────────────── - ['holding_stale_json', JSON.stringify(staleRows || [])], - ['holding_stale_lock', 'true'], - - // ── N5: REGIME_CASH_UPLIFT_V1 ─────────────────────────────── - ['regime_cash_uplift_min_pct', typeof regimeCashMinPct === 'number' ? regimeCashMinPct : cashFloorInfo.minPct], - - // ── O1: SINGLE_POSITION_WEIGHT_CAP_V1 ─────────────────────── - ['single_position_weight_gate', (singlePositionWeightCap || {}).gate_status || 'PASS'], - ['single_position_weight_json', JSON.stringify((singlePositionWeightCap || {}).by_position || [])], - - // ── O2: SEMICONDUCTOR_CLUSTER_GATE_V1 ─────────────────────── - ['semiconductor_cluster_gate', (semiconductorClusterGate || {}).gate_status || 'PASS'], - ['semiconductor_cluster_json', JSON.stringify(semiconductorClusterGate || {})], - - // ── O3: PORTFOLIO_DRAWDOWN_GATE_V1 ────────────────────────── - ['portfolio_drawdown_gate', (portfolioDrawdownGate || {}).gate || 'INSUFFICIENT_DATA'], - ['portfolio_drawdown_pct', (portfolioDrawdownGate || {}).drawdown_pct !== null ? (portfolioDrawdownGate || {}).drawdown_pct : null], - ['portfolio_peak_krw', (portfolioDrawdownGate || {}).peak_krw || null], - - // ── O4: WIN_LOSS_STREAK_GUARD_V1 ──────────────────────────── - ['win_loss_streak_state', (winLossStreakGuard || {}).state || 'INSUFFICIENT_HISTORY'], - ['win_loss_streak_buy_scale', (winLossStreakGuard || {}).buy_scale !== undefined ? (winLossStreakGuard || {}).buy_scale : 1.0], - ['win_loss_streak_win_rate_pct', (winLossStreakGuard || {}).win_rate_pct !== null ? (winLossStreakGuard || {}).win_rate_pct : null], - - // ── O5: POSITION_COUNT_LIMIT_V1 ───────────────────────────── - ['position_count_gate', (positionCountLimit || {}).gate_status || 'PASS'], - ['position_count', (positionCountLimit || {}).position_count !== undefined ? (positionCountLimit || {}).position_count : 0], - ['position_count_max', (positionCountLimit || {}).max_count !== undefined ? (positionCountLimit || {}).max_count : 8], - - // ── P1: STOP_BREACH_ALERT_V1 ───────────────────────────────── - ['stop_breach_gate', (stopBreachAlert || {}).gate || 'PASS'], - ['stop_breach_alert_json', JSON.stringify((stopBreachAlert || {}).alerts || [])], - - // ── P1-BIS: RELATIVE_STOP_SIGNAL_V1 ───────────────────────── - ['relative_stop_gate', ((hApex || {}).relative_stop_signal || {}).gate || 'PASS'], - ['relative_stop_signal_json', JSON.stringify(((hApex || {}).relative_stop_signal || {}).signals || [])], - - // ── P2: TP_TRIGGER_ALERT_V1 ────────────────────────────────── - ['tp_trigger_gate', (tpTriggerAlert || {}).gate || 'PASS'], - ['tp_trigger_alert_json', JSON.stringify((tpTriggerAlert || {}).triggered || [])], - - // ── P3: HEAT_CONCENTRATION_ALERT_V1 ───────────────────────── - ['heat_concentration_gate', (heatConcentrationAlert || {}).gate || 'PASS'], - ['heat_concentration_json', JSON.stringify((heatConcentrationAlert || {}).by_holding || [])], - - // ── P4: REGIME_TRANSITION_ALERT_V1 ────────────────────────── - ['regime_transition_type', (regimeTransitionAlert || {}).transition_type || 'NO_CHANGE'], - ['regime_transition_json', JSON.stringify(regimeTransitionAlert || {})], - - // ── P5: PORTFOLIO_HEALTH_SCORE_V1 ──────────────────────────── - ['portfolio_health_label', (portfolioHealthScore || {}).label || 'CAUTION'], - ['portfolio_health_score', (portfolioHealthScore || {}).score !== undefined ? (portfolioHealthScore || {}).score : 50], - ['portfolio_health_critical_count', (portfolioHealthScore || {}).critical_count || 0], - ['portfolio_health_caution_count', (portfolioHealthScore || {}).caution_count || 0], - ['portfolio_health_blocked_json', JSON.stringify((portfolioHealthScore || {}).blocked_gates || [])], - - // ── [2026-05-20_HARNESS_V5] H6/H7/H8 신규 게이트 ──────────────────── - ['breakout_quality_gate_json', JSON.stringify((hApex || {}).breakout_quality_gate_json || [])], - ['breakout_quality_gate_lock', 'true'], - ['anti_whipsaw_gate_json', JSON.stringify((hApex || {}).anti_whipsaw_gate_json || [])], - ['anti_whipsaw_gate_lock', 'true'], - ['smart_cash_raise_json', JSON.stringify((hApex || {}).smart_cash_raise_json || [])], - ['smart_cash_raise_route', (hApex || {}).smart_cash_raise_route || 'NO_ACTION'], - - // ── [2026-05-21_CLA_HARNESS_V1] SFG 하네스 출력 ────────────────────────── - ['satellite_failure_gate_json', JSON.stringify((hApex || {}).satellite_failure_gate_json || {})], - ['sapg_json', JSON.stringify((hApex || {}).sapg_json || {})], - ['sfg_v1_lock', 'true'], - - // ── [SPRINT2_REGIME_CLA_V1] CONCENTRATED_LEADER_ADVANCE 게이트 ────────── - ['regime_cla_json', (function() { - var phase = (regimeTrimGuidance || {}).phase || 'UNKNOWN'; - var cla_active = phase === 'CONCENTRATED_LEADER_ADVANCE'; - var sc = semiconductorClusterGate || {}; - var combined_pct = sc.combined_pct || 0; - var cluster_state = cla_active ? 'CLUSTER_HOLD_ONLY' - : (sc.cluster_state || 'CLUSTER_OPEN'); - var cla_exit = cla_active ? 'CLA_ACTIVE' : 'CLA_EXIT_CONFIRMED'; - var rag_pass = !cla_active || combined_pct < 60.0; - return JSON.stringify({ - cla_active: cla_active, - market_regime: phase, - cluster_state: cluster_state, - cluster_combined_pct: combined_pct, - cla_exit_status: cla_exit, - core_sell_blocked: cla_active, - satellite_buy_gate: (cla_active && combined_pct >= 60.0) - ? 'CLUSTER_HOLD_ONLY' : 'CLUSTER_OPEN', - cash_raise_priority: cla_active ? 'LAGGARD_BROKEN_FIRST' : 'H2_RANK', - rag_v1: rag_pass ? 'PASS' : 'FAIL', - rag_reason: rag_pass - ? 'CLA 비활성 또는 반도체 합산 비중 60% 미만 — 위성 BUY 허용' - : 'CLA 활성 + 반도체 합산 비중 ≥60% — 위성 신규 BUY 차단', - formula_id: 'CONCENTRATED_LEADER_ADVANCE_V1', - }); - })()], - ['cla_exit_status', (function() { - var phase = (regimeTrimGuidance || {}).phase || 'UNKNOWN'; - return phase === 'CONCENTRATED_LEADER_ADVANCE' ? 'CLA_ACTIVE' : 'CLA_EXIT_CONFIRMED'; - })()], - ['rag_v1', (function() { - var phase = (regimeTrimGuidance || {}).phase || 'UNKNOWN'; - var cla_active = phase === 'CONCENTRATED_LEADER_ADVANCE'; - var combined_pct = (semiconductorClusterGate || {}).combined_pct || 0; - return (!cla_active || combined_pct < 60.0) ? 'PASS' : 'FAIL'; - })()], - ['rag_reason', (function() { - var phase = (regimeTrimGuidance || {}).phase || 'UNKNOWN'; - var cla_active = phase === 'CONCENTRATED_LEADER_ADVANCE'; - var combined_pct = (semiconductorClusterGate || {}).combined_pct || 0; - if (!cla_active) return 'CLA 비활성 — RAG 조건 불필요'; - return combined_pct < 60.0 - ? 'CLA 활성이나 반도체 합산 60% 미만 — 위성 BUY 허용' - : 'CLA 활성 + 반도체 합산 ≥60% — 위성 신규 BUY 차단'; - })()], - - ['apex_formula_ids', - 'ALPHA_LEAD_SCORE_V1,FOLLOW_THROUGH_CONFIRM_V1,DISTRIBUTION_RISK_SCORE_V1,' - + 'PROFIT_PRESERVATION_STATE_V1,SMART_CASH_RAISE_PLAN_V1,REBOUND_SELL_TRIGGER_V1,' - + 'EXECUTION_QUALITY_GUARD_V1,BUY_PERMISSION_MATRIX_V1,SELL_QUANTITY_ALLOCATOR_V1,' - + 'LIMIT_PRICE_POLICY_V1,STAGED_ENTRY_TRANCHE_V1,K2_STAGED_REBOUND_SELL,K3_REGIME_SELL_PRIORITY_V1,' - + 'SECTOR_ROTATION_MOMENTUM_V1,RATCHET_TRAILING_AUTO_V1,PRE_DISTRIBUTION_EARLY_WARNING_V1,' - + 'DYNAMIC_HEAT_GATE_V1,DRAWDOWN_GUARD_V1,PORTFOLIO_BETA_GATE_V1,TP_QUANTITY_LADDER_V1,' - + 'EVENT_RISK_HOLD_GATE_V1,SECTOR_CONCENTRATION_LIMIT_V1,' - + 'POSITION_SIZE_REGIME_SCALE_V1,VOLUME_BREAKOUT_CONFIRM_V1,STOP_PRICE_ADEQUACY_V1,' - + 'HOLDING_STALE_REVIEW_V1,REGIME_CASH_UPLIFT_V1,' - + 'SINGLE_POSITION_WEIGHT_CAP_V1,SEMICONDUCTOR_CLUSTER_GATE_V1,' - + 'PORTFOLIO_DRAWDOWN_GATE_V1,WIN_LOSS_STREAK_GUARD_V1,POSITION_COUNT_LIMIT_V1,' - + 'STOP_BREACH_ALERT_V1,TP_TRIGGER_ALERT_V1,HEAT_CONCENTRATION_ALERT_V1,' - + 'REGIME_TRANSITION_ALERT_V1,PORTFOLIO_HEALTH_SCORE_V1,' - + 'BREAKOUT_QUALITY_GATE_V2,ANTI_WHIPSAW_HOLD_GATE_V1,SMART_CASH_RAISE_V2,' - + 'BENCHMARK_RELATIVE_TIMESERIES_V1,RS_VERDICT_V2,SATELLITE_ALPHA_QUALITY_GATE_V1,' - + 'CASH_CREATION_PURPOSE_LOCK_V1,SATELLITE_AGGREGATE_PNL_GATE_V1,ALPHA_EVALUATION_WINDOW_V1,' - + 'ALPHA_FEEDBACK_LOOP_V1,ENTRY_FRESHNESS_GATE_V1,SELL_VALUE_PRESERVATION_GATE_V1,' - + 'INDEX_RELATIVE_HEALTH_GATE_V1,' - + 'RS_VERDICT_V1,COMPOSITE_VERDICT_V1,REPLACEMENT_ALPHA_GATE_V1,SATELLITE_FAILURE_GATE_V1,' - + 'CONCENTRATED_LEADER_ADVANCE_V1,' - // ── [2026-05-23_PROPOSAL46] PA1~PA5 - + 'PREDICTIVE_ALPHA_ENGINE_V1,ANTI_LATE_ENTRY_GATE_V2,CASH_PRESERVATION_SELL_ENGINE_V2,' - + 'MACRO_EVENT_SYNCHRONIZER_V1,CONSISTENCY_VALIDATOR_V2'], - - // ── [2026-05-23_PROPOSAL46] PA1~PA5 신규 하네스 출력 ───────────────────────── - ['predictive_alpha_json', JSON.stringify((hApex || {}).predictive_alpha_json || [])], - ['anti_late_entry_json', JSON.stringify((hApex || {}).anti_late_entry_json || [])], - ['cash_preservation_sell_json', JSON.stringify((hApex || {}).cash_preservation_sell_json || [])], - ['macro_event_json', JSON.stringify((hApex || {}).macro_event_json || {})], - ['macro_risk_score', (hApex || {}).macro_risk_score !== undefined ? String((hApex || {}).macro_risk_score) : ''], - ['macro_risk_regime', (hApex || {}).macro_risk_regime || ''], - ['mega_sell_alert', (hApex || {}).mega_sell_alert === true ? 'true' : 'false'], - ['consistency_report_json', JSON.stringify((hApex || {}).consistency_report_json || {})], - ['consistency_score', (hApex || {}).consistency_score !== undefined ? String((hApex || {}).consistency_score) : ''], - ['cv_verdict', (hApex || {}).cv_verdict || ''], - ['portfolio_alpha_confidence', (hApex || {}).portfolio_alpha_confidence !== null && (hApex || {}).portfolio_alpha_confidence !== undefined ? String((hApex || {}).portfolio_alpha_confidence) : ''], - ['fomc_position_size_gate', (hApex || {}).fomc_position_size_gate || 'INACTIVE'], - ['prediction_accuracy_rate', (hApex || {}).prediction_accuracy_rate !== null && (hApex || {}).prediction_accuracy_rate !== undefined ? String((hApex || {}).prediction_accuracy_rate) : ''], - ['watch_breakout_candidates_json', JSON.stringify((hApex || {}).watch_breakout_candidates_json || [])], - ['anti_whipsaw_reentry_json', JSON.stringify((hApex || {}).anti_whipsaw_reentry_json || [])], - ['alpha_history_summary_json', JSON.stringify((hApex || {}).alpha_history_summary_json || {})], - - // ── P4 허용 목록 (LLM 참조용) ──────────────────────────────── - ['p4_intraday_allowed_actions', JSON.stringify(INTRADAY_ALLOWED_ACTIONS)], - - // ── M1: 국면별 감축 가이던스 (REGIME_TRIM_WEIGHT_V1) ────────── - // LLM의 주관적 국면 판단 및 임의 감축비율 산출을 차단 - ['market_regime_state', (regimeTrimGuidance || {}).phase || 'UNKNOWN'], - ['regime_trim_guidance_json', JSON.stringify(regimeTrimGuidance || {})], - ['regime_trim_lock', 'true'], - - // ── H3: 주도주 승자 포지션 보호 게이트 (SECULAR_LEADER_REGIME_GATE_V1) ─ - // 삼성전자·SK하이닉스 secular_leader_profit_lock 발동 여부 결정론적 확정 - ['secular_leader_gate_json', JSON.stringify( - (h4.prices || []).reduce(function(acc, p) { - if (p.secular_leader_gate_status && p.secular_leader_gate_status !== 'NOT_APPLICABLE') { - acc[p.ticker] = { - active: p.secular_leader_gate_active, - status: p.secular_leader_gate_status, - reasons: p.secular_leader_gate_reasons - }; - } - return acc; - }, {}) - )], - - // ── M4: 5억원 목표 자산 추적 대시보드 ────────────────────────────────────── - // GOAL_RETIREMENT_V1: 은퇴자산 5억원 목표 — 하네스 결정론적 산출 (LLM 재판단 금지) - ['goal_asset_krw', M4_GOAL_KRW], - ['goal_current_asset_krw', Math.round(m4Asset)], - ['goal_achievement_pct', m4Achieve], - ['goal_remaining_krw', Math.round(m4Remain)], - ['goal_eta_months', m4EtaMonths], - ['goal_eta_label', m4EtaLabel], - ['goal_monthly_growth_pct', m4NetExp30], - ['goal_status', m4Asset >= M4_GOAL_KRW ? 'ACHIEVED' : 'IN_PROGRESS'], - - // ── [3RD_HARNESS_V1] 커버리지 완성 — GAS 30.2% → 100% ─────────────────────────── - // 목표: LLM 자유도 69.8% → 0% (완전 결정론적) - // 43/43 필수 필드를 GAS가 직접 산출 — LLM 추정 불필요 - - // HARNESS_DATA_FRESHNESS_GATE_V1 - ['data_freshness_status', - (((hApex || {}).data_freshness_json) || {}).data_freshness_status - || (snapshotGate.status === 'PASS' ? 'FRESH' : 'STALE')], - - // INTRADAY_ACTION_MATRIX_V1 - ['intraday_scope', intradayLock ? 'INTRADAY_RESTRICTED' : 'FULL_EOD'], - - // PROFIT_LOCK_RATCHET_V1 — profit_preservation_json 최고 단계 - ['profit_lock_stage', (function() { - var pp = (hApex || {}).profit_preservation_json || []; - var order = { APEX_SUPER: 7, APEX_TRAILING: 6, PROFIT_LOCK_30: 5, PROFIT_LOCK_20: 4, - PROFIT_LOCK_10: 3, BREAKEVEN_RATCHET: 2, NORMAL: 1 }; - var best = 'NORMAL'; - pp.forEach(function(r) { - var st = String(r.profit_preservation_state || 'NORMAL'); - if ((order[st] || 1) > (order[best] || 1)) best = st; - }); - return best; - })()], - ['auto_trailing_stop', (function() { - var pp = (hApex || {}).profit_preservation_json || []; - var maxStop = null; - pp.forEach(function(r) { - if (typeof r.auto_trailing_stop === 'number' - && (maxStop === null || r.auto_trailing_stop > maxStop)) { - maxStop = r.auto_trailing_stop; - } - }); - return maxStop !== null ? maxStop : 0; - })()], - - // PROFIT_RATCHET_TIERED_V2 — APEX_SUPER(+60%) ATR×1.2 trailing - // profit_pct >= 60 → APEX_SUPER; inject_computed_harness.py 가 정밀값 교체 - ['ratchet_stage_v2', (function() { - var pp = (hApex || {}).profit_preservation_json || []; - var order = { APEX_SUPER: 7, APEX_TRAILING: 6, PROFIT_LOCK_30: 5, PROFIT_LOCK_20: 4, - PROFIT_LOCK_10: 3, BREAKEVEN_RATCHET: 2, NORMAL: 1 }; - var best = 'NORMAL'; - pp.forEach(function(r) { - var pct = typeof r.profit_pct === 'number' ? r.profit_pct : 0; - var st = pct >= 60 ? 'APEX_SUPER' - : String(r.profit_preservation_state || 'NORMAL'); - if ((order[st] || 1) > (order[best] || 1)) best = st; - }); - return best; - })()], - ['auto_trailing_stop_v2', (function() { - var pp = (hApex || {}).profit_preservation_json || []; - var maxStop = null; - pp.forEach(function(r) { - // APEX_SUPER 종목: 기존 auto_trailing_stop 그대로 사용 (Python inject로 ATR×1.2 보정) - if (typeof r.auto_trailing_stop === 'number' - && (maxStop === null || r.auto_trailing_stop > maxStop)) { - maxStop = r.auto_trailing_stop; - } - }); - return maxStop !== null ? maxStop : 0; - })()], - - // FLOW_ACCELERATION_V1 — W4 신호 집계 - ['flow_acceleration_status', (function() { - var ph = (hAlpha || {}).per_holding || []; - return ph.some(function(h) { return h.w4_status === 'FLOW_DECEL_WARNING'; }) - ? 'FLOW_DECEL_DETECTED' : 'NORMAL'; - })()], - - // DISTRIBUTION_SELL_DETECTOR_V1 — distribution_risk_json 집계 - ['distribution_sell_detector_status', (function() { - var dist = (hApex || {}).distribution_risk_json || []; - if (dist.some(function(d) { return d.anti_distribution_state === 'BLOCK_BUY'; })) - return 'DISTRIBUTION_DETECTED'; - if (dist.some(function(d) { return d.anti_distribution_state === 'TRIM_REVIEW'; })) - return 'TRIM_REVIEW_ALERT'; - return 'NORMAL'; - })()], - ['signals_count', (function() { - var dist = (hApex || {}).distribution_risk_json || []; - return dist.filter(function(d) { return d.anti_distribution_state !== 'PASS'; }).length; - })()], - - // BREAKOUT_QUALITY_GATE_V2 — breakout_quality_gate_json 최소 점수 - ['breakout_quality_score', (function() { - var bqg = (hApex || {}).breakout_quality_gate_json || []; - if (!bqg.length) return 0; - var min = null; - bqg.forEach(function(b) { - if (typeof b.breakout_quality_score === 'number' - && (min === null || b.breakout_quality_score < min)) min = b.breakout_quality_score; - }); - return min !== null ? min : 0; - })()], - - // ANTI_CHASING_VELOCITY_V1 — entry_freshness_json 집계 (worst-case) - ['anti_chasing_verdict', (function() { - var ef = (hApex || {}).entry_freshness_json || []; - var worst = 'CLEAR'; - ef.forEach(function(r) { - var fs = String(r.freshness_state || '').toUpperCase(); - if (fs === 'BLOCK_LATE_CHASE') { worst = 'BLOCK_CHASE'; } - else if (fs === 'PULLBACK_WAIT' && worst !== 'BLOCK_CHASE') { worst = 'PULLBACK_WAIT'; } - }); - return worst; - })()], - ['anti_chasing_velocity_status', (function() { - var ef = (hApex || {}).entry_freshness_json || []; - var worst = 'PASS'; - ef.forEach(function(r) { - var fs = String(r.freshness_state || '').toUpperCase(); - if (fs === 'BLOCK_LATE_CHASE') { worst = 'BLOCKED'; } - else if (fs === 'PULLBACK_WAIT' && worst === 'PASS') { worst = 'WAIT'; } - }); - return worst; - })()], - - // PULLBACK_ENTRY_TRIGGER_V1 - ['pullback_entry_verdict', (function() { - var ef = (hApex || {}).entry_freshness_json || []; - return ef.some(function(r) { - return String(r.freshness_state || '').toUpperCase() === 'PULLBACK_WAIT'; - }) ? 'PULLBACK_ZONE' : 'ABOVE_PULLBACK_ZONE'; - })()], - // per-ticker only; Python inject가 종목별 기준가 제공. 0 = 활성 눌림목 없음. - ['pullback_entry_trigger_price', 0], - - // CASH_RECOVERY_OPTIMIZER_V1 — cash_raise_plan_json이 GAS 확정 현금회복 계획 - ['cash_recovery_plan_json', JSON.stringify((hApex || {}).cash_raise_plan_json || [])], - - // SELL_WATERFALL_ENGINE_V1 — 동일 계획(폭포수 매도 순서) - ['waterfall_plan_json', JSON.stringify((hApex || {}).cash_raise_plan_json || [])], - - // ── SPRINT 1-3 Python-computed fields: GAS placeholder (inject.py가 덮어씀) ── - // ANTI_CHASING_VELOCITY_V1 — per-ticker 속도 게이트 (inject.py 교체) - ['anti_chasing_velocity_json', '[]'], - // DISTRIBUTION_SELL_DETECTOR_V1 — per-ticker 6신호 배급형 탐지 (inject.py 교체) - ['distribution_sell_detector_json', '[]'], - // K2_STAGED_REBOUND_SELL_V1 — 현금확보 K2 분할 계획 (inject.py 교체) - ['k2_staged_rebound_sell_json', '[]'], - // PRE_DISTRIBUTION_EARLY_WARNING_V1 — distribution_risk_json 선행경보 집계 (inject.py 교체) - ['pre_distribution_warning', JSON.stringify({ status: 'NONE', affected_count: 0, affected_tickers: [], - buy_gate: 'PASS', formula_id: 'PRE_DISTRIBUTION_EARLY_WARNING_V1' })], - - // SELL_EXECUTION_TIMING_V1 - ['sell_timing_verdict', - intradayLock ? 'TIMING_BLOCKED_INTRADAY' - : (snapshotGate.status === 'PASS' ? 'SELL_READY' : 'SELL_BLOCKED_DATA')], - ['sell_execution_window', intradayLock ? 'NEXT_DAY_OPEN' : 'EOD_30MIN'], - - // SELL_VALUE_PRESERVATION_TIERED_V2 — sell_value_preservation_json 집계 - ['preservation_verdict', (function() { - var svp = (hApex || {}).sell_value_preservation_json || []; - if (!svp.length) return 'NO_DATA'; - if (svp.some(function(r) { return r.sell_value_preservation_state === 'EMERGENCY_EXIT'; })) - return 'EMERGENCY_EXIT'; - if (svp.some(function(r) { return r.sell_value_preservation_state === 'TRIM_ONLY'; })) - return 'TRIM_ONLY'; - if (svp.some(function(r) { - return r.sell_value_preservation_state === 'REBOUND_CONFIRM_HOLD'; - })) return 'REBOUND_CONFIRM_HOLD'; - return 'HOLD'; - })()], - - // TICK_NORMALIZER_V1 — GAS는 모든 가격에 tickNormalize_() 적용 - ['tick_normalized_price', true], - - // SELL_PRICE_SANITY_V1 — H4 prices 호가단위 검증 (GAS 생성 가격은 항상 PASS) - // inject_computed_harness.py 가 스프레드시트 원본 입력값 검증 후 교체 - ['sell_price_sanity_status', (function() { - var prices = (h4 || {}).prices || []; - var worst = 'PASS'; - prices.forEach(function(p) { - var candidates = [p.stop_price, p.tp1_price, p.tp2_price]; - candidates.forEach(function(price) { - if (price == null || price <= 0) return; - var tick = getTickSize_(price); - if (price % tick !== 0) { worst = 'INVALID_TICK'; } - }); - }); - return worst; - })()], - - // BENCHMARK_RELATIVE_TIMESERIES_V1 — BRT 집계 - ['brt_verdict', (function() { - var brt = (hApex || {}).benchmark_relative_timeseries_json || []; - if (!brt.length) return 'NO_DATA'; - if (brt.some(function(r) { return r.brt_verdict === 'BROKEN'; })) return 'BROKEN'; - if (brt.some(function(r) { return r.brt_verdict === 'LEADER'; })) return 'LEADER'; - return 'MARKET'; - })()], - ['brt_rs_slope', (function() { - var brt = (hApex || {}).benchmark_relative_timeseries_json || []; - var slopes = brt.map(function(r) { return r.rs_line_20d_slope; }) - .filter(function(v) { return v != null && isFinite(v); }); - if (!slopes.length) return 0; - return parseFloat((slopes.reduce(function(a, b) { return a + b; }, 0) - / slopes.length).toFixed(4)); - })()], - - // RS_VERDICT_V2 FUSION — buy_permission_json + BRT 융합 집계 - ['rs_verdict', (function() { - var bp = (hApex || {}).buy_permission_json || []; - var brt = (hApex || {}).benchmark_relative_timeseries_json || []; - if (!bp.length) return 'NO_DATA'; - // V1 raw - var v1_broken = bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'BROKEN'; }); - var v1_laggard = bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'LAGGARD'; }); - var v1_leader = bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'LEADER'; }); - // RS_VERDICT-5: brt_verdict=BROKEN AND v1=LEADER → V2 결과는 LAGGARD - if (brt.some(function(r) { return r.brt_verdict === 'BROKEN'; }) && v1_leader && !v1_broken) { - return 'LAGGARD'; - } - if (v1_broken) return 'BROKEN'; - if (v1_laggard) return 'LAGGARD'; - if (v1_leader) return 'LEADER'; - return 'MARKET'; - })()], - ['rs_verdict_source', (function() { - var brt = (hApex || {}).benchmark_relative_timeseries_json || []; - return brt.length ? 'V2_FUSION' : 'V1_ONLY'; - })()], - ['rs_verdict_v1_raw', (function() { - var bp = (hApex || {}).buy_permission_json || []; - if (!bp.length) return 'NO_DATA'; - if (bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'BROKEN'; })) return 'BROKEN'; - if (bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'LAGGARD'; })) return 'LAGGARD'; - if (bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'LEADER'; })) return 'LEADER'; - return 'MARKET'; - })()], - - // SATELLITE_ALPHA_QUALITY_GATE_V1 — saqg_json 집계 - ['saqg_verdict', (function() { - var saqg = (hApex || {}).saqg_json || []; - if (!saqg.length) return 'NO_DATA'; - if (saqg.some(function(r) { return r.saqg_v1 === 'ELIGIBLE'; })) return 'ELIGIBLE'; - if (saqg.every(function(r) { return r.saqg_v1 === 'EXCLUDED'; })) return 'ALL_EXCLUDED'; - return 'WATCHLIST_ONLY'; - })()], - - // SATELLITE_AGGREGATE_PNL_GATE_V1 - ['sapg_verdict', ((hApex || {}).sapg_json || {}).sapg_status || 'INSUFFICIENT_DATA'], - - // LLM_SERVING_CONSTRAINT_V1 - ['serving_constraint_check', 'PASS'], - - // DETERMINISTIC_ROUTING_ENGINE_V1 — 9단계 라우팅 완료 로그 - ['routing_execution_log', JSON.stringify({ - stages_completed: [ - 'STAGE_0_FRESHNESS', 'STAGE_1_CASH_RATIOS', 'STAGE_2_RATCHET', - 'STAGE_3_DISTRIBUTION', 'STAGE_4_BUY_TIMING', 'STAGE_5_SELL_WATERFALL', - 'STAGE_6_PRICE_VALIDATION', 'STAGE_7_RS_BRT', 'STAGE_8_SATELLITE', - 'STAGE_9_LLM_SERVING' - ], - routing_completed: true, - formula_id: 'DETERMINISTIC_ROUTING_ENGINE_V1' - })], - - // TRADE_QUALITY_SCORER_V1 — 월간 배치 결과 캐시 읽기 (없으면 MONTHLY_BATCH_PENDING) - ['trade_quality_json', (function() { - try { - var ss2 = getSpreadsheet_(); - var setSh = ss2.getSheetByName('settings'); - if (!setSh) return JSON.stringify({ status: 'MONTHLY_BATCH_PENDING', last_computed: null, formula_id: 'TRADE_QUALITY_SCORER_V1' }); - var sData = setSh.getDataRange().getValues(); - for (var si = 0; si < sData.length; si++) { - if (String(sData[si][0] || '').trim() === 'trade_quality_json') { - var raw = sData[si][1]; - if (raw) { - var s = String(raw); - return s.length <= 45000 ? s : JSON.stringify({ status: 'OVERSIZED_TRIMMED', formula_id: 'TRADE_QUALITY_SCORER_V1' }); - } - break; - } - } - } catch(e) { Logger.log('[HARNESS_ROWS] trade_quality_json 읽기 오류: ' + e.message); } - return JSON.stringify({ status: 'MONTHLY_BATCH_PENDING', last_computed: null, formula_id: 'TRADE_QUALITY_SCORER_V1' }); - })()], - - // PATTERN_BLACKLIST_AUTO_V1 — 월간 배치 결과 캐시 읽기 - ['pattern_blacklist_status', (function() { - try { - var ss3 = getSpreadsheet_(); - var setSh3 = ss3.getSheetByName('settings'); - if (!setSh3) return 'INACTIVE'; - var sData3 = setSh3.getDataRange().getValues(); - for (var si3 = 0; si3 < sData3.length; si3++) { - if (String(sData3[si3][0] || '').trim() === 'pattern_blacklist_json') { - try { - var parsed = JSON.parse(String(sData3[si3][1] || '{}')); - var hasTriggered = Array.isArray(parsed.patterns) && - parsed.patterns.some(function(p) { return p.pattern_blacklist_status === 'TRIGGERED'; }); - return hasTriggered ? 'TRIGGERED' : 'INACTIVE'; - } catch(pe) { break; } - } - } - } catch(e) { Logger.log('[HARNESS_ROWS] pattern_blacklist_status 읽기 오류: ' + e.message); } - return 'INACTIVE'; - })()], - ['pattern_blacklist_json', (function() { - try { - var ss4 = getSpreadsheet_(); - var setSh4 = ss4.getSheetByName('settings'); - if (!setSh4) return JSON.stringify({ status: 'INACTIVE', patterns: [], pattern_count: 0, formula_id: 'PATTERN_BLACKLIST_AUTO_V1' }); - var sData4 = setSh4.getDataRange().getValues(); - for (var si4 = 0; si4 < sData4.length; si4++) { - if (String(sData4[si4][0] || '').trim() === 'pattern_blacklist_json') { - var raw4 = sData4[si4][1]; - if (raw4) return String(raw4); - break; - } - } - } catch(e) { Logger.log('[HARNESS_ROWS] pattern_blacklist_json 읽기 오류: ' + e.message); } - return JSON.stringify({ status: 'INACTIVE', patterns: [], pattern_count: 0, formula_id: 'PATTERN_BLACKLIST_AUTO_V1' }); - })()], - - // ── [SPRINT4_SFG_SCALARS] SFG 스칼라 (inject.py 교체) ──────────────────── - ['sfg_v1', 'CLEAR'], - ['sfg_broken_count', 0], - ['sfg_failure_rate', 0], - - // ── [SPRINT4_PCG] PORTFOLIO_CORRELATION_GATE_V1 (inject.py 교체) ───────── - ['portfolio_correlation_gate_json', - JSON.stringify({ correlation_gate_status: 'INSUFFICIENT_DATA', satellite_cluster_beta: null, - effective_portfolio_beta: null, regime_beta_limit: 1.0, - reason: 'GAS 초기값 — inject.py 교체 대상', - formula_id: 'PORTFOLIO_CORRELATION_GATE_V1' })], - ['correlation_gate_status', 'INSUFFICIENT_DATA'], - - // TICK_NORMALIZER_V1 — 종목별 호가 정규화 가격 맵 (Python inject 보완) - ['tick_normalized_prices_json', (function() { - var prices4 = (h4 || {}).prices || []; - var map = {}; - prices4.forEach(function(p) { - if (!p.ticker) return; - var sp = p.stop_price ? tickNormalize_(p.stop_price) : null; - var tp = p.tp1_price ? tickNormalize_(p.tp1_price) : null; - map[p.ticker] = { stop: sp, tp1: tp }; - }); - return JSON.stringify(map); - })()], - - // SELL_PRICE_SANITY — 종목별 상세 (ratchet_v2 per-ticker) - ['ratchet_v2_per_ticker_json', (function() { - var pp = (hApex || {}).profit_preservation_json || []; - return JSON.stringify(pp.map(function(r) { - return { ticker: r.ticker || '', profit_pct: r.profit_pct || 0, - ratchet_stage_v2: r.profit_preservation_state || 'NORMAL', - auto_trailing_stop_v2: r.auto_trailing_stop || null }; - })); - })()], - - // ── LLM 종합 지침 V6 (SPRINT 1: D1-ROUTING·D2-LLM·A2-ANTI_CHASE·K2-REBOUND 추가) ──── - ['llm_instruction', - 'HARNESS_AUTHORITATIVE_V4(H4): ' - + '▶ 재계산 금지: sell_priority_lock·quantities_lock·prices_lock·decision_lock·alpha_shield_lock·regime_trim_lock=true — ' - + 'GAS 확정값을 LLM이 재계산·수정·추가·삭제하는 행위는 HARNESS_VIOLATION으로 보고서 전체 무효. ' - + '▶ [HS009] TP 유효성 잠금: prices_json의 tp1_price/tp2_price가 null이면 INVALID_TP_STALE — ' - + 'LLM이 대체 TP 가격을 임의 산출하는 것 절대 금지. tp1_state/tp2_state 그대로 보고. ' - + '▶ [HS010] WATCH/BLOCKED 출력 잠금: order_blueprint_json의 validation_status!=PASS인 행은 ' - + '지정가·손절가·익절가·수량 전부 null. LLM이 참고값이라도 HTS 주문 표에 숫자 기재 금지. ' - + '감시값은 별도 "WATCH 감시 원장(주문 아님)" 섹션으로만 표시. ' - + '▶ [HS011] LLM 즉석 공식 정의 금지: spec/13_formula_registry.yaml에 등록되지 않은 공식명·알고리즘명을 ' - + '즉석 정의하고 이에 기반한 원화 가격·정수 수량을 산출하는 것 절대 금지. ' - + '하네스 미구현 영역은 "DATA_MISSING — 하네스 업데이트 필요"로만 표시. ' - + '▶ [M1] 국면별 감축: regime_trim_guidance_json의 satellite_trim_pct/leader_trim_pct 범위를 그대로 인용. ' - + 'LLM이 임의 감축비율을 제시하는 것 금지. ' - + '▶ [H3] 주도주 게이트: secular_leader_gate_json의 active/status를 그대로 보고. ' - + '005930·000660 종목에서 secular_leader_gate_active=true이면 ' - + 'tp1_state=DEFERRED_SECULAR_LEADER 구간에서 TP 매도 신호 생성 금지. ' - + '하네스가 null로 전달한 tp1_price를 LLM이 임의 복원하는 것 절대 금지. ' - + '▶ Blueprint 무결성: order_blueprint_json 수정 절대 금지. blueprint_checksum(CRC32_V1) Python 검증. ' - + '▶ 구조화 출력 강제: [Rule_ID:X, Value:Y, Threshold:Z, Result:R] 포맷만 허용. ' - + '▶ Zero-Adjective: 감성 형용사·부사 금지. 수치와 Rule_ID만 허용. ' - + '▶ P4 장중 모드(intraday_lock=true): p4_intraday_allowed_actions 외 액션 출력 금지. ' - + '▶ CLAMP 발동 종목은 clamp_label 표기 필수. ' - + '▶ [M4] 목표 자산 추적: goal_achievement_pct·goal_remaining_krw·goal_eta_label은 하네스 산출값 그대로 보고. ' - + 'LLM이 5억원 달성 여부·ETA를 독자적으로 재계산하는 것 절대 금지. ' - + '▶ [G1] 현금 부족액 잠금(CASH_SHORTFALL_V1): cash_shortfall_min_krw·cash_shortfall_target_krw는 하네스 확정값. ' - + '"약 N원 필요" 형태의 LLM 즉석 계산 절대 금지. cash_current_pct_d2·cash_target_pct도 하네스 복사 전용. ' - + '▶ [G2] TRIM 계획 잠금(TRIM_PLAN_MIN_CASH_V1): trim_plan_to_min_cash_json은 H2 매도우선순위 기반 GAS 확정. ' - + 'LLM이 현금 회복을 위해 임의로 종목·수량·순서를 선택하는 것 절대 금지. 하네스 plan 복사만 허용. ' - + '▶ [APEX_V1] 판단 자료 생성시점 로직: alpha_lead_json·distribution_risk_json·buy_permission_json·' - + 'cash_raise_plan_json·smart_sell_quantities_json·execution_quality_json은 GAS 확정값. ' - + '뒷북매수/설거지/현금확보 매도 방식은 LLM 해석 금지, *_lock=true 값 그대로 복사. ' - + 'buy_permission_state가 ALLOW_*가 아니면 BUY 수량 출력 금지. ' - + 'execution_style=OVERSOLD_REBOUND_SELL이면 rebound_wait_qty를 immediate_qty로 이동 금지. ' - + '▶ [ENTRY_FRESHNESS_GATE_V1] entry_freshness_json 없이 뒷북/추격 BUY 승인 금지. ' - + 'BLOCK_LATE_CHASE/PULLBACK_WAIT는 BUY/STAGED_BUY/ADD_ON 차단. ' - + '▶ [SELL_VALUE_PRESERVATION_GATE_V1] sell_value_preservation_json 없이 현금확보 매도와 수익보호 매도 혼용 금지. ' - + 'EMERGENCY_EXIT 외에는 반등대기 수량을 즉시매도로 승격 금지. ' - + '▶ [INDEX_RELATIVE_HEALTH_GATE_V1] index_relative_health_json 없이 지수 대비 괴리 종목을 BUY 승인 금지. ' - + 'DECOUPLED/OVER_EXTENDED는 신규 BUY 차단, UNDERPERFORMING은 WATCH 우선. ' - + '▶ [HS010-B] 종합 판단 제안표 필수 출력: comprehensive_proposal_json을 "종합 판단 제안표(PROPOSAL)" 표로 ' - + '항상 출력. PENDING_EXPORT·BLOCKED·DATA_MISSING 상태와 무관하게 생략 금지. ' - + '판단은 사용자 몫이므로 reference_stop_price·reference_tp1_price·tp1_state·reference_tp2_price·tp2_state·' - + 'proposed_immediate_qty·proposed_staged_qty·expected_cash_krw를 그대로 표시. ' - + '이 표에서 LLM이 가격·수량을 임의로 변경하거나 새 수치를 추가하는 것 절대 금지. ' - + '▶ [HS010-C] 위성 후보 스크리닝 표 필수 출력: satellite_candidate_json을 "위성 후보 스크리닝(SATELLITE_CANDIDATE_SCREEN_V1)" 표로 ' - + '항상 출력. 후보가 0개여도 표를 출력하고 "현재 추가 적합 후보 없음"을 명시. ' - + 'satellite_candidate_summary.watch_candidates를 항상 표 제목에 병기. ' - + 'LLM이 universe 외 종목을 임의 추가하거나 grade를 변경하는 것 금지. ' - + '▶ [D1-ROUTING] 9단계 결정론적 라우팅 의무: 보고서는 routing_execution_log의 ' - + '9단계 순서(①신선도→②장중판별→③포트폴리오상태→④매도레이더→⑤매수타이밍→' - + '⑥현금확보→⑦가격정규화→⑧RS/위성→⑨LLM서빙) 결과를 먼저 표 형태로 출력하고 ' - + '이후 분석을 진행한다. routing_execution_log 생략 시 INCOMPLETE_ROUTING_LOG 처리. ' - + '▶ [D2-LLM] LLM 8금지(위반 시 INVALID_LLM_OVERRIDE): ' - + '①미등록공식 지정가/수량 산출 금지 ' - + '②하네스BLOCK 판정 우회("그래도매수") 금지 ' - + '③SELL_PRICE_SANITY INVALID 가격 복원 금지 ' - + '④cash_shortfall LLM 즉석계산 금지 ' - + '⑤K2 반등대기 수량을 "현금급함"으로 즉시전환 금지 ' - + '⑥APEX_SUPER 구간 trailing_stop 미병기 금지 ' - + '⑦DISTRIBUTION_CONFIRMED 매수 우회 금지 ' - + '⑧routing_execution_log 생략 금지. ' - + '▶ [A2-ANTI_CHASE] anti_chasing_velocity_json의 anti_chase_verdict=BLOCK_CHASE인 ' - + '종목은 당일 신규 BUY 절대 금지. PULLBACK_WAIT는 pullback_entry_trigger_price 도달 전 매수 금지. ' - + 'distribution_sell_detector_json의 distribution_verdict=DISTRIBUTION_CONFIRMED인 종목 BUY 절대 금지. ' - + '▶ [K2-REBOUND] cash_recovery_plan_json의 rebound_wait_qty는 ' - + 'rebound_trigger_price 도달 전 즉시매도 전환 금지. "현금이 급하니까" 이유로 ' - + 'Stage 2 즉시전환 금지. emergency_full_sell=true일 때만 전량 즉시 허용. ' - + '▶ [PA47-A1] watch_breakout_candidates_json 필수 출력: promotion_eligible=true 항목을 ' - + '"급등 탐지 — 라이프사이클 재검토 권고" 표로 출력. ' - + 'lifecycle_stage=EXIT이어도 breakout_signal=WATCH_BREAKOUT_DETECTED면 즉시 매도 금지; ' - + 'satellite_lifecycle_gate_json의 breakout_promotion_recommendation=PROMOTE_TO_WATCH 참조. ' - + '후보가 0건이면 표 생략 가능. ' - + '▶ [PA47-PA1] buy_permission_json의 pa1_synthesis_verdict·pa1_direction_confidence 반드시 인용: ' - + 'EXIT_SIGNAL(dc<-30) 종목은 "방향성 부적합—보유 재검토", TRIM_SIGNAL(dc<-10) 종목은 ' - + '"비중 축소 검토"로 표시. STRONG_BUY/MODERATE_BUY 종목은 신규 진입 우선순위 상향. ' - + 'pa1_synthesis_verdict가 없는 종목은 PA1 미적용으로 명시. ' - + '▶ [PA47-A3] anti_whipsaw_reentry_json의 reentry_signal=REENTRY_CANDIDATE 종목은 ' - + '"매도 재검토 — 반등 감지" 경고로 표시. 매도 실행 전 재확인 의무. ' - + 'reentry_grade=A/B이면 매도 보류 후 다음날 재평가 권고. ' - + '▶ [PA47-B4] harness_generation_status=BLOCKED_STALE_DATA 또는 BLOCKED_CV_FAIL이면 ' - + '보고서 생성을 거부하고 "하네스 BLOCK — 데이터 갱신 후 재실행 요망"만 출력. ' - + '▶ [PROPOSAL50-EG] export_gate_json의 json_validation_status=PENDING_EXPORT이면 ' - + 'hts_entry_allowed=false — HTS 주문 입력 절대 금지. failed_checks와 resolution_guide를 출력. ' - + '▶ [PROPOSAL50-EJCE] ejce_json의 consensus_result=NO_BUY 종목은 3개 관점 중 2개 이상 BLOCK — ' - + 'buy_permission이 ALLOW여도 EJCE NO_BUY 종목 BUY 실행 금지. block_reasons 인용 필수. ' - + '▶ [PROPOSAL50-SCRS] scrs_v2_json의 selected_combo만 현금확보 매도 기재 허용. ' - + 'immediate_sell_qty와 rebound_wait_qty 구분 표시 의무. ' - + 'emergency_level=TRIM_ONLY이면 추가 매도 금지. ' - + '▶ [PROPOSAL50-DSLE] serving_lock_json의 llm_serving_budget.numeric_generation_allowed=0 — ' - + 'LLM이 가격·수량·수익률 등 숫자를 자체 생성하는 것 절대 금지. ' - + '▶ [PROPOSAL50-H10] shadow_ledger_json은 BLOCKED/INVALID 블루프린트를 투명하게 보존. ' - + '산출 지정가·손절가·익절가·이론수량을 null 처리하거나 은폐 금지(HS010). ' - + '사용자의 사후 평가·오버라이드를 위해 "투명한 감시 원장" 표로 출력. ' - + '▶ [PROPOSAL50-D2] llm_serving_constraint_json의 constraint_status=INVALID_LLM_OVERRIDE이면 ' - + '보고서 조립 중단 — violations 목록 전체를 "[INVALID_LLM_OVERRIDE: 사유]"로 표시 후 재실행 요망.'], - - // ── [PROPOSAL50] MRAG-V2 + M5 V1.1 의무감축계획 ───────────────────────────────────────── - ['mrag_v2_json', JSON.stringify((hApex || {}).mrag_v2_json || {})], - ['effective_heat_gate_threshold', (hApex || {}).effective_heat_gate_threshold || null], - ['effective_position_size_scale', (hApex || {}).effective_position_size_scale || null], - ['mandatory_reduction_json', JSON.stringify((hApex || {}).mandatory_reduction_json || {})], - - // ── [PROPOSAL50] Export Gate / Routing Trace / Watch Ledger / EJCE / SCRS-V2 / DSLE ────── - ['export_gate_json', JSON.stringify((hApex || {}).export_gate_json || {})], - ['hts_entry_allowed', String((hApex || {}).hts_entry_allowed || false)], - ['routing_trace_json', JSON.stringify((hApex || {}).routing_trace_json || {})], - ['watch_ledger_json', JSON.stringify((hApex || {}).watch_ledger_json || [])], - ['ejce_json', JSON.stringify((hApex || {}).ejce_json || [])], - ['scrs_v2_json', JSON.stringify((hApex || {}).scrs_v2_json || {})], - ['serving_lock_json', JSON.stringify((hApex || {}).serving_lock_json || {})], - - // ── [PROPOSAL50-P0-GAP] H10/D2 신규 필드 ─────────────────────────────────────────────────── - ['shadow_ledger_json', JSON.stringify((hApex || {}).shadow_ledger_json || { shadow_ledger: [], blocked_count: 0 })], - ['llm_serving_constraint_json', JSON.stringify((hApex || {}).llm_serving_constraint_json || { constraint_status: 'DATA_MISSING' })], - - // ── [PROPOSAL51] SU_51_K 신규 필드 ──────────────────────────────────────────────────────── - ['cluster_sync_result_json', JSON.stringify((hApex || {}).cluster_sync_result_json || {})], - ['proactive_sell_radar_json', JSON.stringify((hApex || {}).proactive_sell_radar_json || [])], - ['sell_pass_accuracy_rate', (hApex || {}).sell_pass_accuracy_rate !== undefined - ? (hApex || {}).sell_pass_accuracy_rate : null], - ['sell_execution_quality_json', JSON.stringify((hApex || {}).sell_execution_quality_json || [])], - // ── [PROPOSAL51] P0-D / P1-B / P1-C 신규 필드 ────────────────────────────────────────── - ['price_hierarchy_json', JSON.stringify((hApex || {}).price_hierarchy_json || [])], - ['data_quality_gate_v2_json', JSON.stringify((hApex || {}).data_quality_gate_v2_json || {})], - ['cash_recovery_display_json', JSON.stringify((hApex || {}).cash_recovery_display_json || {})], - ['portfolio_health_json', JSON.stringify((hApex || {}).portfolio_health_json || {})], - // [PROPOSAL53] 신규 P0 하네스 - ['fundamental_quality_json', JSON.stringify((hApex || {}).fundamental_quality_json || {})], - ['horizon_allocation_json', JSON.stringify((hApex || {}).horizon_allocation_json || {})], - ['smart_money_liquidity_json', JSON.stringify((hApex || {}).smart_money_liquidity_json || {})], - ['routing_serving_trace_v2_json',JSON.stringify((hApex || {}).routing_serving_trace_v2_json|| {})], - ['fundamental_multifactor_json', JSON.stringify((hApex || {}).fundamental_multifactor_json || {})], - ['earnings_growth_quality_json', JSON.stringify((hApex || {}).earnings_growth_quality_json || {})], - ['market_share_proxy_json', JSON.stringify((hApex || {}).market_share_proxy_json || {})], - ['cashflow_stability_json', JSON.stringify((hApex || {}).cashflow_stability_json || {})], - ['routing_decision_explain_json', JSON.stringify((hApex || {}).routing_decision_explain_json || {})], - - // [PROPOSAL47_B4] STALE_BLOCK enforcement: cv_verdict=BLOCK 시 생성 차단 마커 - ['harness_generation_status', (function() { - var verdict = (hApex || {}).cv_verdict || ''; - var cvReport = (hApex || {}).consistency_report_json || {}; - var failedList = cvReport.failed || []; - var staleBlock = failedList.some(function(f) { - return f && typeof f.reason === 'string' && f.reason.indexOf('STALE_BLOCK') >= 0; - }); - if (verdict === 'BLOCK' && staleBlock) return 'BLOCKED_STALE_DATA'; - if (verdict === 'BLOCK') return 'BLOCKED_CV_FAIL'; - return 'OK'; - })()] - ]; -} - - -/** - * F3: buildHarnessRows_ 출력 완전성 자체검증 - * 19_harness_contract.yaml required_harness_context_keys 기준 필수 키 누락 체크. - * 누락 키가 있으면 Logger.log 경고 — 운영 배포 전 조기 감지. - */ -function assertHarnessRowsComplete_(rows) { - var REQUIRED_KEYS = [ - // H1 포트폴리오 가드 - 'harness_version', 'captured_at', 'request_route', 'route_reason_code', - 'bundle_selected', 'prompt_entrypoint', 'json_validation_status', 'capture_required', - 'cash_ledger_basis', 'intraday_lock', 'snapshot_execution_gate', 'snapshot_execution_reason', - 'immediate_cash_krw', 'settlement_cash_d2_krw', - 'open_order_amount_krw', 'buy_power_krw', 'cash_floor_status', 'total_heat_pct', - 'heat_gate_status', 'heat_gate_threshold_pct', 'sell_priority_lock', 'quantities_lock', 'prices_lock', - 'decision_lock', 'blueprint_row_count', 'blueprint_checksum', 'blueprint_hash_algo', - 'source_manifest_checksum', 'decision_trace_checksum', 'checksum_hash_algo', - // Collections - 'source_manifest_json', 'allowed_actions', 'blocked_actions', - 'account_snapshot_freshness_json', - 'sell_candidates_json', 'sell_quantities_json', 'buy_qty_inputs_json', - 'prices_json', 'decisions_json', 'decision_trace_json', - 'order_blueprint_json', 'p4_intraday_allowed_actions', - 'proposal_reference_json', 'proposal_reference_lock', - 'regime_trim_guidance_json', 'secular_leader_gate_json', - 'backdata_feature_bank_json', - // G1 현금 부족액 잠금 (CASH_SHORTFALL_V1) - 'cash_current_pct_d2', 'cash_target_pct', 'cash_shortfall_min_krw', 'cash_shortfall_target_krw', - // G2 현금 회복 TRIM 계획 (TRIM_PLAN_MIN_CASH_V1) - 'trim_plan_to_min_cash_json', - // APEX V1 판단자료 생성 시점 로직 하네스 - 'alpha_lead_json', 'alpha_lead_lock', 'backdata_feature_bank_json', 'backdata_learning_lock', - 'follow_through_json', 'follow_through_lock', - 'distribution_risk_json', 'distribution_lock', 'profit_preservation_json', 'profit_preservation_lock', - 'cash_raise_plan_json', 'rebound_sell_trigger_json', 'smart_sell_quantities_json', 'smart_cash_raise_lock', - 'execution_quality_json', 'execution_quality_lock', 'buy_permission_json', 'limit_price_policy_json', - 'regime_adjusted_sell_priority_json', // K3: 국면·섹터 연계 H2 동적 우선순위 - 'benchmark_relative_timeseries_json', - 'index_relative_health_json', - 'saqg_json', - 'cash_creation_purpose_lock_json', - 'alpha_feedback_json', - 'alpha_evaluation_window_json', - 'entry_freshness_json', - 'sell_value_preservation_json', - 'sector_rotation_momentum_json', // L1: 섹터 로테이션 모멘텀 추적 - // M1-M5 신규 - 'drawdown_guard_state', 'drawdown_buy_scale', - 'portfolio_beta_gate', 'portfolio_beta_gate_json', - 'tp_quantity_ladder_json', 'event_risk_json', - 'sector_concentration_gate', 'sector_concentration_json', - // N1-N5 신규 - 'regime_size_scale', - 'stop_adequacy_json', - 'holding_stale_json', - 'regime_cash_uplift_min_pct', - // O1-O5 신규 - 'single_position_weight_gate', - 'semiconductor_cluster_gate', - 'portfolio_drawdown_gate', - 'win_loss_streak_state', 'win_loss_streak_buy_scale', - 'position_count_gate', 'position_count', - // O-group collections - 'single_position_weight_json', - 'semiconductor_cluster_json', - // P1-P5 실시간 경보 & 건전성 - 'stop_breach_gate', 'stop_breach_alert_json', - 'tp_trigger_gate', - 'heat_concentration_gate', - 'regime_transition_type', - 'portfolio_health_label', 'portfolio_health_score', - 'portfolio_health_blocked_json', - // M4 목표 자산 추적 - 'goal_asset_krw', 'goal_current_asset_krw', 'goal_achievement_pct', - 'goal_remaining_krw', 'goal_eta_label', 'goal_status', - // ── [2026-05-20_HARNESS_V5] H6/H7/H8 신규 게이트 - 'breakout_quality_gate_json', 'breakout_quality_gate_lock', - 'anti_whipsaw_gate_json', 'anti_whipsaw_gate_lock', - 'smart_cash_raise_json', 'smart_cash_raise_route', - 'follow_through_confirm_json', 'follow_through_confirm_lock', - // ── [2026-05-20_HARNESS_V5] 4종 결정론적 체크섬 - 'input_snapshot_checksum', 'rendered_output_checksum', 'rendered_report_checksum', 'non_deterministic_flag', - // ── [2026-05-21_CLA_HARNESS_V1] SFG - 'satellite_failure_gate_json', 'sapg_json', 'sfg_v1_lock', - // ── [SPRINT2_REGIME_CLA_V1] CLA 게이트 + RAG + RS_VERDICT V2 FUSION - 'regime_cla_json', 'cla_exit_status', 'rag_v1', 'rag_reason', - 'rs_verdict_source', 'rs_verdict_v1_raw', - // ── [SPRINT3_L4] PRE_DISTRIBUTION_EARLY_WARNING_V1 - 'pre_distribution_warning', - // ── [SPRINT4] SFG 스칼라 / F2 / PCG - 'sfg_v1', 'sfg_broken_count', 'sfg_failure_rate', - 'pattern_blacklist_json', - 'portfolio_correlation_gate_json', 'correlation_gate_status', - // ── [3RD_HARNESS_V1] 커버리지 완성 30개 필드 - 'data_freshness_status', 'intraday_scope', - 'profit_lock_stage', 'auto_trailing_stop', - 'auto_trailing_stop_v2', 'ratchet_stage_v2', - 'flow_acceleration_status', - 'distribution_sell_detector_status', 'signals_count', - 'breakout_quality_score', - 'anti_chasing_verdict', 'anti_chasing_velocity_status', - 'pullback_entry_verdict', 'pullback_entry_trigger_price', - 'cash_recovery_plan_json', 'waterfall_plan_json', - 'sell_timing_verdict', 'sell_execution_window', - 'preservation_verdict', 'tick_normalized_price', - 'sell_price_sanity_status', - 'brt_verdict', 'brt_rs_slope', 'rs_verdict', - 'saqg_verdict', 'sapg_verdict', - 'serving_constraint_check', 'routing_execution_log', - 'trade_quality_json', 'pattern_blacklist_status', - 'tick_normalized_prices_json', 'ratchet_v2_per_ticker_json', - // SPRINT 1 신규 필드 (Direction O1/O2/O5/P1/P3/P5/A2/B1/B3/K2/C1/D1) - 'semiconductor_cluster_json', - 'single_position_weight_json', - 'position_count', 'position_count_max', 'position_count_gate', - 'stop_breach_alert_json', - 'relative_stop_gate', 'relative_stop_signal_json', - 'heat_concentration_json', - 'portfolio_health_blocked_json', - 'anti_chasing_velocity_json', - 'distribution_sell_detector_json', - 'k2_staged_rebound_sell_json', - 'cash_recovery_plan_json', - // [PROPOSAL50] 신규 필수 필드 (P0-P2) - 'export_gate_json', 'json_validation_status', 'hts_entry_allowed', - 'routing_trace_json', 'watch_ledger_json', 'ejce_json', 'scrs_v2_json', 'serving_lock_json', - 'mrag_v2_json', 'mandatory_reduction_json', - // [PROPOSAL50-P0-GAP] H10/D2 신규 필드 - 'shadow_ledger_json', 'llm_serving_constraint_json', - // [PROPOSAL51] P0-D / P1-B / P1-C 신규 필드 - 'price_hierarchy_json', 'data_quality_gate_v2_json', 'cash_recovery_display_json', - // [PROPOSAL53] - 'fundamental_quality_json', 'horizon_allocation_json', - 'smart_money_liquidity_json', 'routing_serving_trace_v2_json' - ,'fundamental_multifactor_json','earnings_growth_quality_json','market_share_proxy_json', - 'cashflow_stability_json','routing_decision_explain_json' - ]; - var keySet = {}; - for (var i = 0; i < rows.length; i++) { - if (Array.isArray(rows[i]) && rows[i].length >= 1) { - keySet[rows[i][0]] = true; - } - } - var missing = REQUIRED_KEYS.filter(function(k) { return !keySet[k]; }); - if (missing.length > 0) { - Logger.log('[HARNESS_CONTRACT_FAIL] buildHarnessRows_ missing required keys: ' + missing.join(', ')); - } else { - Logger.log('[HARNESS_CONTRACT_OK] All ' + REQUIRED_KEYS.length + ' required keys present.'); - } - return missing; -} - -/** - * YAML_FORMULA_BINDING_REGISTRY_V1 - * spec 공식 ID와 GS 구현/연계 지점 연결 레지스트리 (커버리지 계량용) - */ -var YAML_FORMULA_BINDING_REGISTRY_V1 = { - BUY_TIMING_SUITABILITY_V1: "core_satellite timing gate binding", - CASH_RATIOS_V1: "cash ledger binding", - ECP_RISK_SCALE_V1: "risk scale binding", - EXECUTION_QUALITY_SCORE_V1: "execution quality binding", - EXPECTED_EDGE_V1: "expected edge binding", - FINANCIAL_HEALTH_SCORE_V1: "financial health binding", - OVERSOLD_DELAY_V1: "oversold delay binding", - PEG_SCORE_V1: "valuation peg binding", - PORTFOLIO_BAND_STATUS_V1: "portfolio band binding", - PORTFOLIO_BETA_V1: "factor beta binding", - RS_MOMENTUM_V1: "rs momentum binding", - SEA_TIMING_V1: "sell timing binding", - SELL_CONFLICT_AWARE_RECOMMENDATION_V1: "sell conflict binding", - STOP_PROPOSAL_LADDER_V1: "proposal stop ladder binding", - T1_FORCED_SELL_RISK_V1: "t+1 forced sell risk binding" -}; diff --git a/gas_lib.gs b/gas_lib.gs deleted file mode 100644 index 56898e3..0000000 --- a/gas_lib.gs +++ /dev/null @@ -1,2965 +0,0 @@ -// gas_lib.gs - Common utilities & static features -// Last Updated: 2026-06-13 18:48:40 KST -// Math/KRX utils, sheet I/O, sector flow, Web API, static runners -// GAS global scope: functions in gas_data_feed.gs / gas_data_collect.gs callable directly -// -// Bridge markers for Python-backed formulas that are intentionally mirrored in tools/* -// so YAML->GS direct coverage can be audited without changing runtime semantics. -// ALPHA_FEEDBACK_LOOP_V2 -// ALPHA_LEAD_THRESHOLD_OPTIMIZER_V1 -// ANTI_WHIPSAW_GATE_V1 -// BREAKEVEN_RATCHET_V1 -// CANONICAL_METRICS_V1 -// CAPITAL_STYLE_ALLOCATION_V1 -// CAPITAL_STYLE_TIME_STOP_V1 -// CASH_FLOOR_V1 -// CROSS_SECTION_CONSISTENCY_V1 -// DYNAMIC_VALUE_PRESERVATION_SELL_V6 -// EJCE_DIVERGENCE_AUDIT_V1 -// EXECUTION_INTEGRITY_GATE_V1 -// FINAL_JUDGMENT_GATE_V1 -// IMPUTED_DATA_EXPOSURE_GATE_V1 -// INVESTMENT_QUALITY_HEADLINE_V1 -// LLM_NARRATIVE_TEMPLATE_LOCK_V1 -// MACRO_EVENT_TICKER_IMPACT_V1 -// PREDICTION_ACCURACY_HARNESS_V2 -// PREDICTIVE_ALPHA_DIALECTIC_ENGINE_V2 -// PREDICTIVE_ALPHA_REPORT_LOCK_V2 -// REGIME_TRIM_GUIDANCE_V1 -// SELL_WATERFALL_ENGINE_V2 -// TRADE_QUALITY_FROM_T5_V1 -// VERDICT_CONSISTENCY_LOCK_V1 -function calcValSurgeStatus(valSurge) { - if (!Number.isFinite(valSurge)) return "DATA_MISSING"; - if (valSurge < THRESHOLDS.VAL_SURGE_WATCH) return "OK"; - if (valSurge < THRESHOLDS.VAL_SURGE_HOT) return "WATCH"; - if (valSurge < THRESHOLDS.VAL_SURGE_EXHAUSTED) return "HOT"; - return "EXHAUSTED"; -} - -function calcLiquidityStatus(avgTradingValue5D) { - if (!Number.isFinite(avgTradingValue5D)) return "DATA_MISSING"; - if (avgTradingValue5D >= THRESHOLDS.LIQUIDITY_PREFERRED_M) return "PREFERRED"; - if (avgTradingValue5D >= THRESHOLDS.LIQUIDITY_OK_M) return "OK"; - return "LOW"; -} - -function calcSpreadStatus(spreadPct) { - if (!Number.isFinite(spreadPct)) return "QUOTE_NO_MATCH"; - if (spreadPct <= THRESHOLDS.SPREAD_OK_PCT) return "OK"; - if (spreadPct <= THRESHOLDS.SPREAD_WARN_PCT) return "WATCH"; - return "BLOCK"; -} - -function tradingValueM(row) { - if (!row || !Number.isFinite(row.close) || !Number.isFinite(row.volume)) return null; - return (row.close * row.volume) / 1000000; -} - -function avgTradingValueM(rows, n) { - if (!Array.isArray(rows) || rows.length < n) return null; - const slice = rows.slice(-n); - const vals = slice.map(tradingValueM).filter(v => Number.isFinite(v)); - if (vals.length < n) return null; - return vals.reduce((s, v) => s + v, 0) / n; -} - -function avgNumber_(vals) { - const nums = vals.filter(v => Number.isFinite(v)); - if (nums.length !== vals.length || nums.length === 0) return null; - return nums.reduce((s, v) => s + v, 0) / nums.length; -} - -function pctReturn_(latestClose, priorClose) { - if (!Number.isFinite(latestClose) || !Number.isFinite(priorClose) || priorClose === 0) return null; - return ((latestClose / priorClose) - 1) * 100; -} - -// 한국 숫자 문자열 파싱 — 쉼표 제거 후 parseFloat. null 반환(NaN/무한대). -function parseKrNum_(s) { - const v = parseFloat(String(s ?? "").replace(/,/g, "")); - return Number.isFinite(v) ? v : null; -} - -// ── 데이터 신선도 검증 헬퍼 ────────────────────────────────────────────────── -// KRX 기준 영업일 차이 계산 (공휴일 미반영 — 토/일만 제외) -// dateStr: "YYYY-MM-DD" 또는 "YYYY.MM.DD" -// 반환: 0=당일, 1=전영업일, 2이상=스테일, 음수=미래 -function calcKrxBizDaysDiff_(dateStr) { - if (!dateStr) return 999; - const norm = String(dateStr).replace(/\./g, "-"); - if (!/^\d{4}-\d{2}-\d{2}$/.test(norm)) return 999; - - // 오늘 KST 기준 날짜 (UTC+9) - const now = new Date(); - const kstMs = now.getTime() + 9 * 3600 * 1000; - const kstNow = new Date(kstMs); - const todayStr = kstNow.toISOString().slice(0, 10); - - let d = new Date(norm + "T00:00:00Z"); - const end = new Date(todayStr + "T00:00:00Z"); - if (d > end) return -1; // 미래 날짜 — 이상치 - if (d.toISOString().slice(0,10) === todayStr) return 0; - - let count = 0; - const cur = new Date(d); - while (cur < end) { - cur.setDate(cur.getDate() + 1); - const dow = cur.getDay(); - if (dow !== 0 && dow !== 6) count++; // 월~금만 카운트 - } - return count; -} - -// OHLC·Flow 날짜가 스테일인지 판단 -// bizDaysThreshold: 이 값 초과 시 stale (기본 1 — 전영업일까지 허용) -function isStalePriceDate_(dateStr, bizDaysThreshold = 1) { - const diff = calcKrxBizDaysDiff_(dateStr); - return diff > bizDaysThreshold; -} - -function calcAtr20(rows) { - if (!Array.isArray(rows) || rows.length < 21) return null; - const trs = []; - for (let i = 1; i < rows.length; i++) { - const cur = rows[i]; - const prev = rows[i - 1]; - const tr = Math.max( - cur.high - cur.low, - Math.abs(cur.high - prev.close), - Math.abs(cur.low - prev.close) - ); - if (Number.isFinite(tr)) trs.push(tr); - } - const recent = trs.slice(-20); - if (recent.length < 20) return null; - return recent.reduce((s, v) => s + v, 0) / 20; -} - -// ── Google Sheets 출력 ──────────────────────────────────────────────────── -// TEXT_COLS: 앞자리 0이 있는 코드 컬럼을 문자열로 강제 저장 -const TEXT_COLS = new Set([ - "Ticker","ETF_Code","Symbol","Proxy_Ticker","Base_Ticker","Constituent_Code","ETF_Ticker", - "Record_Date","Trade_ID","Signal_Date","Name","Account","Entry_Stage","Source_Origin", - "Setup_Decision","Exit_Reason" -]); -const NUM_COLS = new Set([ - "Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_Rows", - "Frg_5D_SUM","Inst_5D_SUM","Indiv_5D_SUM","Frg_20D_SUM","Inst_20D_SUM", - "Rotation_Score","Rotation_Rank","Prev_Rotation_Rank","Prev_Rotation_Rank_W2", - "Coverage_Weight","Sector_Ret5D","Sector_Ret20D","Sector_RS_20D", - "SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW", - "SmartMoney_5D_Norm","Flow_Breadth_5D","Flow_Rows_Min","Stale_Count", - "ETF_Liquidity_Score","Sector_Score","Sector_Rank", - "NAV","iNAV","Premium_Discount_Pct","Tracking_Error","AUM","Bid","Ask","Spread_Pct", - "ETF_Frg_5D_KRW","ETF_Inst_5D_KRW", - "RS_Rank_20D","RS_Pct_20D","ChunkIdx", - "Timing_Score_Entry","Timing_Score_Exit","T1_Forced_Sell_Risk_Score","Sell_Conflict_Score", - "Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price", - "Rule_Sell_Qty","Rebalance_Target_Cash_Pct","Rebalance_Need_KRW","Override_Sell_Qty", - "Account_Holding_Qty","Account_Avg_Cost","Account_Market_Value", - "Action_Priority","Priority_Score","Final_Rank", - "Sell_Priority_Score" -]); - -// GAS 실행 컨텍스트 내 Spreadsheet 객체 캐시 (openById 중복 호출 방지) -let _ssCache = null; -function getSpreadsheet_() { - if (!_ssCache) { - let ssId = ""; - try { - // 1. Script Properties에서 SPREADSHEET_ID 로드 시도 - ssId = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID'); - } catch(e) {} - - // 만약 Properties에 없으면 하드코딩된 사용자 스프레드시트 ID 지정 (전역 변수 중복 에러 회피용) - if (!ssId) { - ssId = '1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM'; - } - - if (ssId) { - try { - _ssCache = SpreadsheetApp.openById(ssId); - } catch(e) { - Logger.log('[WARN] openById(' + ssId + ') 실패: ' + e.message); - } - } - - // 2. 캐시가 없고 Bound Sheet로 열 수 있다면 로드 후 Properties에 자동 영구 저장 - if (!_ssCache) { - try { - _ssCache = SpreadsheetApp.getActiveSpreadsheet(); - if (_ssCache) { - const activeId = _ssCache.getId(); - if (activeId) { - PropertiesService.getScriptProperties().setProperty('SPREADSHEET_ID', activeId); - Logger.log('[INFO] SPREADSHEET_ID 자동 등록 완료: ' + activeId); - } - } - } catch(e) { - Logger.log('[ERROR] getActiveSpreadsheet() 실패: ' + e.message); - } - } - - // 3. 글로벌 변수로 SPREADSHEET_ID가 명시되어 있는 경우 최종 fallback - if (!_ssCache) { - try { - if (typeof SPREADSHEET_ID !== 'undefined' && SPREADSHEET_ID) { - _ssCache = SpreadsheetApp.openById(SPREADSHEET_ID); - } - } catch(e) {} - } - } - return _ssCache; -} - -// runDataFeed 루프가 계산한 버킷 할당 스냅샷 — runMacro에서 BUCKET_STATUS 행으로 기록 -let _bucketSnapshot_ = null; - -// F4: 루프 내 trailing stop 갱신 대기열 — 루프 완료 후 account_snapshot에 일괄 기록 -let _trailingStopUpdates_ = []; - -function writeToSheet(sheetName, headers, rows) { - const ss = getSpreadsheet_(); - let sheet = ss.getSheetByName(sheetName); - if (!sheet) sheet = ss.insertSheet(sheetName); - sheet.clearContents(); - sheet.clearFormats(); - - // 코드 컬럼을 텍스트 형식으로 먼저 지정 — setValues 전에 해야 효과 있음 - // 포맷 범위를 실제 데이터행+2로 제한. 3000행 예약 시 빈 행이 xlsx에 포함되어 - // 파일 크기 ~7MB → ~200KB로 부풀어오르는 현상 방지 (95%+ 감축). - const fmtRows = Math.max(rows.length + 2, 3); - headers.forEach((h, i) => { - if (TEXT_COLS.has(h)) { - sheet.getRange(1, i+1, fmtRows, 1).setNumberFormat("@"); - } - if (NUM_COLS.has(h)) { - sheet.getRange(1, i+1, fmtRows, 1).setNumberFormat("0"); - } - }); - - const now = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); - sheet.getRange(1, 1).setValue(`updated: ${now} KST`); - const safeHeaders = sanitizeSheetRow_(headers); - sheet.getRange(2, 1, 1, headers.length).setValues([safeHeaders]); - if (rows.length > 0) { - const safeRows = rows.map(sanitizeSheetRow_); - sheet.getRange(3, 1, rows.length, headers.length).setValues(safeRows); - } -} - -function sanitizeSheetCell_(value) { - if (typeof value !== "string") return value; - if (!value) return value; - // Formula injection guard for spreadsheets. - const first = value[0]; - if (first === "=" || first === "+" || first === "-" || first === "@") { - return "'" + value; - } - return value; -} - -function sanitizeSheetRow_(row) { - return (row || []).map(sanitizeSheetCell_); -} - -// 누적형 시트용 업서트: row1 timestamp, row2 headers 유지, row3+ 데이터는 key 기준 병합 -function upsertToSheetByKey(sheetName, headers, rows, keyHeader) { - const ss = getSpreadsheet_(); - let sheet = ss.getSheetByName(sheetName); - if (!sheet) sheet = ss.insertSheet(sheetName); - - const keyIdx = headers.indexOf(keyHeader); - if (keyIdx < 0) throw new Error(`upsertToSheetByKey: missing key header: ${keyHeader}`); - - // 헤더 보정 (행2) - sheet.getRange(2, 1, 1, headers.length).setValues([headers]); - - // 기존 행 로드 - const existingRowsCount = Math.max(0, sheet.getLastRow() - 2); - const existingRows = existingRowsCount > 0 - ? sheet.getRange(3, 1, existingRowsCount, headers.length).getValues() - : []; - - const mergedByKey = {}; - existingRows.forEach(function(r) { - const k = String(r[keyIdx] || "").trim(); - if (!k) return; - mergedByKey[k] = r; - }); - (rows || []).forEach(function(r) { - const k = String((r || [])[keyIdx] || "").trim(); - if (!k) return; - mergedByKey[k] = r; - }); - - const merged = Object.keys(mergedByKey).map(function(k) { return mergedByKey[k]; }); - - // Record_Date desc, then Trade_ID asc - const recordDateIdx = headers.indexOf("Record_Date"); - merged.sort(function(a, b) { - const ad = String((recordDateIdx >= 0 ? a[recordDateIdx] : "") || ""); - const bd = String((recordDateIdx >= 0 ? b[recordDateIdx] : "") || ""); - if (ad !== bd) return ad < bd ? 1 : -1; - const ak = String(a[keyIdx] || ""); - const bk = String(b[keyIdx] || ""); - return ak.localeCompare(bk); - }); - - // 기존 데이터 영역만 지우고 재기록 (시트 전체 clear 금지) - if (existingRowsCount > 0) { - sheet.getRange(3, 1, existingRowsCount, headers.length).clearContent(); - } - if (merged.length > 0) { - sheet.getRange(3, 1, merged.length, headers.length).setValues(merged); - } - - // 포맷 보정 - const fmtRows = Math.max(merged.length + 2, 3); - headers.forEach((h, i) => { - if (TEXT_COLS.has(h)) sheet.getRange(1, i + 1, fmtRows, 1).setNumberFormat("@"); - if (NUM_COLS.has(h)) sheet.getRange(1, i + 1, fmtRows, 1).setNumberFormat("0"); - }); - - const now = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); - sheet.getRange(1, 1).setValue(`updated: ${now} KST`); - return merged.length; -} - -function parseIsoDateYmd_(value) { - if (!value) return null; - if (value instanceof Date && !isNaN(value.getTime())) { - return Utilities.formatDate(value, "Asia/Seoul", "yyyy-MM-dd"); - } - const text = String(value).trim(); - if (!text) return null; - return text.substring(0, 10); -} - -function daysBetweenIso_(startIso, endIso) { - try { - if (!startIso || !endIso) return null; - const s = String(startIso).substring(0, 10).split("-").map(Number); - const e = String(endIso).substring(0, 10).split("-").map(Number); - if (s.length !== 3 || e.length !== 3 || s.some(n => !Number.isFinite(n)) || e.some(n => !Number.isFinite(n))) return null; - const sMs = Date.UTC(s[0], s[1] - 1, s[2]); - const eMs = Date.UTC(e[0], e[1] - 1, e[2]); - return Math.round((eMs - sMs) / (1000 * 60 * 60 * 24)); - } catch (e) { - return null; - } -} - -// ── monthly_history 공유 헬퍼 ──────────────────────────────────────────────── -// orbit(runOrbitGap)과 snapshot(runMonthlySnapshot) 두 호출처가 각자 컬럼만 갱신. -// 나머지 컬럼은 기존 값 보존. Google Sheets가 "yyyy-MM" 셀을 Date로 변환해도 매칭. -const MONTHLY_HDR_ = [ - "Month", - "Total_Asset", "Start_Asset", "Target_Asset", - "Core_Pct", "Satellite_Pct", "Cash_Pct", - "Target_Return_Pct", "Actual_Return_Pct", - "MoM_Return_Pct", "YTD_Return_Pct", - "Orbit_Gap_Pct", "Orbit_State", - "Slot_Adj", "Cash_Floor_Adj", - "Sat_T20_Pass_N", "Sat_T20_Fail_N", "Sat_T60_Pass_N", "Sat_Avg_T20_Alpha_Pct", - "Updated" -]; - -const ALPHA_HISTORY_HDR_ = [ - "Ticker", "Entry_Date", - "SAQG_Grade_At_Entry", "BRT_Verdict_At_Entry", "Market_Regime_At_Entry", - "T20_Check_Date", "T20_Vs_Core_Pctp", "T20_Alpha_Gate", - "T60_Check_Date", "T60_Vs_Core_Pctp", "T60_Alpha_Gate", - "Updated" -]; - -function upsertMonthlyRow_(monthKey, fields) { - const ss = getSpreadsheet_(); - let sheet = ss.getSheetByName("monthly_history"); - if (!sheet) { - sheet = ss.insertSheet("monthly_history"); - sheet.getRange(1, 1, 1, MONTHLY_HDR_.length).setValues([MONTHLY_HDR_]); - sheet.getRange(1, 1, 120, 1).setNumberFormat("@"); - sheet.setFrozenRows(1); - } - const data = sheet.getDataRange().getValues(); - const hdrMap = Object.fromEntries(MONTHLY_HDR_.map((h, i) => [h, i])); - const normM = v => v instanceof Date && !isNaN(v.getTime()) - ? Utilities.formatDate(v, "Asia/Seoul", "yyyy-MM") - : String(v ?? "").trim().substring(0, 7); - - let rowIdx = -1; - let existing = new Array(MONTHLY_HDR_.length).fill(""); - for (let i = 1; i < data.length; i++) { - if (normM(data[i][0]) === monthKey) { - rowIdx = i + 1; - existing = data[i].map(v => v ?? ""); - // 중복 행 제거 (역순) - for (let j = data.length - 1; j > i; j--) { - if (normM(data[j][0]) === monthKey) sheet.deleteRow(j + 1); - } - break; - } - } - - existing[hdrMap["Month"]] = monthKey; - for (const [key, val] of Object.entries(fields)) { - const idx = hdrMap[key]; - if (idx !== undefined && val !== undefined && val !== null && val !== "") existing[idx] = val; - } - existing[hdrMap["Updated"]] = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - - if (rowIdx > 0) { - sheet.getRange(rowIdx, 1, 1, MONTHLY_HDR_.length).setValues([existing]); - } else { - sheet.appendRow(existing); - } - return sheet; -} - -// ── [2026-05-21_AFL_V1] ALPHA_FEEDBACK_LOOP_V1 -- alpha history upsert ──────────── -function appendAlphaHistory_(ss, aewRows, holdings, dfMap, marketRegime) { - if (!aewRows || !aewRows.length) return; - var sheet = ss.getSheetByName("alpha_history"); - if (!sheet) { - sheet = ss.insertSheet("alpha_history"); - sheet.getRange(1, 1, 1, ALPHA_HISTORY_HDR_.length).setValues([ALPHA_HISTORY_HDR_]); - sheet.setFrozenRows(1); - } - var data = sheet.getDataRange().getValues(); - var today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - var hdrMap = Object.fromEntries(ALPHA_HISTORY_HDR_.map(function(h, i) { return [h, i]; })); - - aewRows.forEach(function(r) { - if (r.t20_alpha_gate === 'NOT_YET' && r.t60_alpha_gate === 'NOT_YET') return; - var ticker = r.ticker; - var df = dfMap[ticker] || {}; - var rowIdx = -1; - for (var i = 1; i < data.length; i++) { - if (String(data[i][0]) === ticker && String(data[i][1]) === String(r.entry_date || '')) { - rowIdx = i + 1; - break; - } - } - var row = rowIdx > 0 - ? data[rowIdx - 1].map(function(v) { return v != null ? v : ''; }) - : new Array(ALPHA_HISTORY_HDR_.length).fill(''); - - row[hdrMap['Ticker']] = ticker; - row[hdrMap['Entry_Date']] = r.entry_date || ''; - row[hdrMap['SAQG_Grade_At_Entry']] = df.saqg_v1 || ''; - row[hdrMap['BRT_Verdict_At_Entry']] = df.brt_verdict || ''; - row[hdrMap['Market_Regime_At_Entry']] = marketRegime || ''; - - if (r.t20_alpha_gate && r.t20_alpha_gate !== 'NOT_YET' && !row[hdrMap['T20_Check_Date']]) { - row[hdrMap['T20_Check_Date']] = today; - row[hdrMap['T20_Vs_Core_Pctp']] = (r.t20_vs_core_pctp !== undefined && r.t20_vs_core_pctp !== null) - ? r.t20_vs_core_pctp : ''; - row[hdrMap['T20_Alpha_Gate']] = r.t20_alpha_gate; - } - if (r.t60_alpha_gate && r.t60_alpha_gate !== 'NOT_YET' && !row[hdrMap['T60_Check_Date']]) { - row[hdrMap['T60_Check_Date']] = today; - row[hdrMap['T60_Vs_Core_Pctp']] = (r.t60_vs_core_pctp !== undefined && r.t60_vs_core_pctp !== null) - ? r.t60_vs_core_pctp : ''; - row[hdrMap['T60_Alpha_Gate']] = r.t60_alpha_gate; - } - row[hdrMap['Updated']] = today; - - if (rowIdx > 0) { - sheet.getRange(rowIdx, 1, 1, ALPHA_HISTORY_HDR_.length).setValues([row]); - } else { - sheet.appendRow(row); - } - }); -} - -function getAlphaFeedbackJson_() { - var defaultPayload = { - formula_id: 'ALPHA_FEEDBACK_LOOP_V1', - as_of: '', - analysis_period: '', - status: 'DATA_MISSING', - cases_analyzed: 0, - grade_count: 0, - eligible_t20_fail_rate: null, - eligible_t60_fail_rate: null, - recommended_filter_adjustments: [], - grade_summary: [] - }; - try { - var settings = readSettingsTab_(); - var raw = settings['afl_v1_last_result']; - if (!raw) return defaultPayload; - var payload = typeof raw === 'string' ? JSON.parse(raw) : raw; - return payload && typeof payload === 'object' ? payload : defaultPayload; - } catch (e) { - Logger.log('[AFL] getAlphaFeedbackJson_ error: ' + e.message); - return defaultPayload; - } -} - -// ── settings 탭 읽기 → 사용자 입력 파라미터 (total_asset 등) ──────────────── -// settings 탭: row2=헤더(key|value|note), row3+=데이터 -// 없으면 빈 객체 반환 (각 호출처에서 null 처리) -function readSettingsTab_() { - const result = {}; - try { - const ss = getSpreadsheet_(); - const sheet = ss.getSheetByName("settings"); - if (!sheet) { Logger.log("readSettingsTab_: settings 탭 없음"); return result; } - const data = sheet.getDataRange().getValues(); - // 헤더·메타 행 자동 스킵 — "key", "updated", "date" 등 예약어 및 빈 셀 무시 - const SKIP_KEYS = new Set(["key", "updated", "date", "항목", "파라미터"]); - for (let i = 0; i < data.length; i++) { - const rawKey = String(data[i][0] ?? "").trim(); - if (!rawKey || SKIP_KEYS.has(rawKey.toLowerCase())) continue; - const val = data[i][1]; - if (val !== "" && val != null) result[rawKey] = val; - } - try { - var verbose = String(PropertiesService.getScriptProperties().getProperty('HARNESS_VERBOSE_LOG') || '').toLowerCase() === 'true'; - if (verbose) Logger.log("readSettingsTab_ 로드됨: " + Object.keys(result).join(", ")); - } catch (e) {} - } catch(e) { handleFetchError_("readSettingsTab_", e, "CRITICAL"); } - return result; -} - -// ── performance 탭 읽기 → Bayesian multiplier 계산 ────────────────────────── -// spec/17_performance_contract.yaml 구현. -// performance 탭이 없거나 청산 완료 거래 5건 미만이면 medium_confidence(0.5×) 반환. -function readPerformanceSheet_() { - const DEFAULT = { bayesian_multiplier: 0.5, bayesian_label: "medium_confidence", trades_used: 0, - win_rate_30: null, net_expectancy_30: null, consecutive_losses: 0, - bayesian_data_source: "default" }; - try { - const ss = getSpreadsheet_(); - const sheet = ss.getSheetByName("performance"); - if (!sheet) return DEFAULT; - const data = sheet.getDataRange().getValues(); - if (data.length < 3) return DEFAULT; - const hdr = data[1].map(h => String(h).trim()); - const pnlIdx = hdr.indexOf("pnl_pct"); - const exitIdx = hdr.indexOf("exit_date"); - const exitDateIdx = hdr.indexOf("exit_date"); - if (pnlIdx < 0 || exitIdx < 0) return DEFAULT; - - // 청산 완료 거래만 (exit_date 있음) — 최신 30건 - const closed = []; - for (let i = 2; i < data.length; i++) { - const exitVal = data[i][exitIdx]; - if (!exitVal || String(exitVal).trim() === "") continue; - const pnl = parseFloat(data[i][pnlIdx]); - if (!Number.isFinite(pnl)) continue; - const exitRaw = exitDateIdx >= 0 ? data[i][exitDateIdx] : exitVal; - const exitMs = exitRaw instanceof Date && !isNaN(exitRaw.getTime()) - ? exitRaw.getTime() - : new Date(exitRaw).getTime(); - closed.push({ pnl, exitMs: Number.isFinite(exitMs) ? exitMs : 0 }); - } - if (closed.length === 0) return DEFAULT; - closed.sort((a, b) => b.exitMs - a.exitMs); - const recent = closed.slice(0, 30).map(r => r.pnl); - const n = recent.length; - if (n < 5) return DEFAULT; - - const wins = recent.filter(p => p > 0); - const losses = recent.filter(p => p <= 0); - const winRate = wins.length / n; - const avgWin = wins.length > 0 ? wins.reduce((a,b)=>a+b,0)/wins.length : 0; - const avgLoss = losses.length > 0 ? losses.reduce((a,b)=>a+Math.abs(b),0)/losses.length : 0; - const netExp = winRate * avgWin - (1 - winRate) * avgLoss; - - // 연속 손절 체크 - let consLoss = 0; - for (const p of recent) { - if (p <= 0) consLoss++; - else break; - } - - let multiplier, label; - if (consLoss >= 5) { - multiplier = 0.0; label = "no_bet"; - } else if (winRate >= 0.60 && netExp >= 3.0) { - multiplier = 1.0; label = "high_bet"; - } else if (winRate >= 0.45 && netExp >= 0) { - multiplier = 0.5; label = "medium_bet"; - } else { - multiplier = 0.25; label = "low_bet"; - } - - return { - bayesian_multiplier: multiplier, - bayesian_label: label, - trades_used: n, - win_rate_30: parseFloat(winRate.toFixed(3)), - net_expectancy_30: parseFloat(netExp.toFixed(2)), - consecutive_losses: consLoss, - bayesian_data_source: "actual", - }; - } catch(e) { - handleFetchError_("readPerformanceSheet_", e, "WARN"); - return DEFAULT; - } -} - -// ── 섹터 자금 흐름 ──────────────────────────────────────────────────────── -const DEFAULT_SECTOR_UNIVERSE_V2 = [ - { sector: "반도체", proxyTicker: "091160", proxyName: "KODEX 반도체", proxyType: "ETF", baseTicker: "069500", constituents: [ - { code: "005930", name: "삼성전자", weight: 0.50 }, - { code: "000660", name: "SK하이닉스", weight: 0.35 }, - { code: "042700", name: "한미반도체", weight: 0.10 }, - { code: "091160", name: "KODEX 반도체", weight: 0.05, isEtf: true }, - ]}, - { sector: "AI전력", proxyTicker: "0117V0", proxyName: "TIGER 코리아AI전력기기TOP3플러스", proxyType: "ETF", baseTicker: "069500", constituents: [ - { code: "010120", name: "LS ELECTRIC", weight: 0.30 }, - { code: "267260", name: "HD현대일렉트릭", weight: 0.30 }, - { code: "006260", name: "LS", weight: 0.20 }, - { code: "062040", name: "산일전기", weight: 0.10 }, - { code: "298040", name: "효성중공업", weight: 0.10 }, - ]}, - { sector: "방산", proxyTicker: "012450", proxyName: "한화에어로스페이스", proxyType: "대표주", baseTicker: "069500", constituents: [ - { code: "012450", name: "한화에어로스페이스", weight: 0.45 }, - { code: "079550", name: "LIG넥스원", weight: 0.25 }, - { code: "047810", name: "한국항공우주", weight: 0.15 }, - { code: "064350", name: "현대로템", weight: 0.15 }, - ]}, - { sector: "조선", proxyTicker: "494670", proxyName: "TIGER 조선TOP10", proxyType: "ETF", baseTicker: "069500", constituents: [ - { code: "329180", name: "HD현대중공업", weight: 0.35 }, - { code: "042660", name: "한화오션", weight: 0.30 }, - { code: "009540", name: "HD한국조선해양", weight: 0.20 }, - { code: "494670", name: "TIGER 조선TOP10", weight: 0.15, isEtf: true }, - ]}, - { sector: "건설/EPC", proxyTicker: "028050", proxyName: "삼성E&A", proxyType: "대표주", baseTicker: "069500", constituents: [ - { code: "028050", name: "삼성E&A", weight: 0.40 }, - { code: "000720", name: "현대건설", weight: 0.30 }, - { code: "006360", name: "GS건설", weight: 0.20 }, - { code: "047040", name: "대우건설", weight: 0.10 }, - ]}, - { sector: "자동차", proxyTicker: "091180", proxyName: "TIGER 자동차", proxyType: "ETF", baseTicker: "069500", constituents: [ - { code: "005380", name: "현대차", weight: 0.45 }, - { code: "000270", name: "기아", weight: 0.40 }, - { code: "012330", name: "현대모비스", weight: 0.15 }, - ]}, - { sector: "금융/은행", proxyTicker: "091170", proxyName: "KODEX 은행", proxyType: "ETF", baseTicker: "069500", constituents: [ - { code: "105560", name: "KB금융", weight: 0.30 }, - { code: "055550", name: "신한지주", weight: 0.30 }, - { code: "086790", name: "하나금융지주", weight: 0.20 }, - { code: "316140", name: "우리금융지주", weight: 0.10 }, - { code: "003540", name: "대신증권", weight: 0.10 }, - ]}, - { sector: "2차전지", proxyTicker: "305720", proxyName: "KODEX 2차전지산업", proxyType: "ETF", baseTicker: "069500", constituents: [ - { code: "373220", name: "LG에너지솔루션", weight: 0.40 }, - { code: "006400", name: "삼성SDI", weight: 0.30 }, - { code: "051910", name: "LG화학", weight: 0.20 }, - { code: "096770", name: "SK이노베이션", weight: 0.10 }, - ]}, - { sector: "바이오", proxyTicker: "266410", proxyName: "KODEX 헬스케어", proxyType: "ETF", baseTicker: "069500", constituents: [ - { code: "207940", name: "삼성바이오로직스", weight: 0.45 }, - { code: "068270", name: "셀트리온", weight: 0.30 }, - { code: "128940", name: "한미약품", weight: 0.15 }, - { code: "000100", name: "유한양행", weight: 0.10 }, - ]}, - { sector: "원전", proxyTicker: "099440", proxyName: "두산에너빌리티", proxyType: "대표주", baseTicker: "069500", constituents: [ - { code: "099440", name: "두산에너빌리티", weight: 0.45 }, - { code: "023450", name: "한전기술", weight: 0.25 }, - { code: "015760", name: "한국전력", weight: 0.20 }, - { code: "071320", name: "지역난방공사", weight: 0.10 }, - ]}, - { sector: "소비재", proxyTicker: "139220", proxyName: "TIGER 생활소비재", proxyType: "ETF", baseTicker: "069500", constituents: [ - { code: "028260", name: "삼성물산", weight: 0.35 }, - { code: "097950", name: "CJ제일제당", weight: 0.25 }, - { code: "004370", name: "농심", weight: 0.20 }, - { code: "051900", name: "LG생활건강", weight: 0.20 }, - ]}, -]; - -function runSectorFlow() { - const rows = runSectorFlowV3(); - writeLegacySectorFlowFromStage2_(rows); - - // 연쇄 실행: 매크로 지표 - runMacro(); -} - -function normalizeSectorName_(sector) { - const s = String(sector ?? "").trim(); - if (s === "AI전력/전력기기") return "AI전력"; - if (s === "바이오/헬스케어") return "바이오"; - if (s === "원전/에너지") return "원전"; - if (s === "소비재/유통") return "소비재"; - return s; -} - -function boolFromSheet_(value, defaultValue) { - if (value === true || value === false) return value; - const s = String(value ?? "").trim().toUpperCase(); - if (["TRUE","Y","YES","1","사용","사용함"].includes(s)) return true; - if (["FALSE","N","NO","0","미사용","제외"].includes(s)) return false; - return defaultValue; -} - -function readSectorUniverse_() { - const ss = getSpreadsheet_(); - const sheet = ss.getSheetByName("sector_universe"); - if (!sheet) { - writeDefaultSectorUniverseSheet_(); - return DEFAULT_SECTOR_UNIVERSE_V2; - } - const data = sheet.getDataRange().getValues(); - if (data.length < 3) { - writeDefaultSectorUniverseSheet_(); - return DEFAULT_SECTOR_UNIVERSE_V2; - } - const hdr = data[1].map(h => String(h).trim()); - const idx = name => hdr.indexOf(name); - const required = ["Sector","Proxy_Ticker","Constituent_Code","Weight"]; - if (required.some(h => idx(h) < 0)) return DEFAULT_SECTOR_UNIVERSE_V2; - - const map = {}; - for (let i = 2; i < data.length; i++) { - const enabled = idx("Enabled") >= 0 ? boolFromSheet_(data[i][idx("Enabled")], true) : true; - if (!enabled) continue; - const sector = normalizeSectorName_(data[i][idx("Sector")]); - const code = normalizeTickerCode(data[i][idx("Constituent_Code")]); - const weight = parseFloat(data[i][idx("Weight")]); - if (!sector || !code || !Number.isFinite(weight) || weight <= 0) continue; - if (!map[sector]) { - map[sector] = { - sector, - proxyTicker: normalizeTickerCode(data[i][idx("Proxy_Ticker")]), - proxyName: idx("Proxy_Name") >= 0 ? String(data[i][idx("Proxy_Name")] ?? "").trim() : "", - proxyType: idx("Proxy_Type") >= 0 ? String(data[i][idx("Proxy_Type")] ?? "").trim() : "", - baseTicker: idx("Base_Ticker") >= 0 ? normalizeTickerCode(data[i][idx("Base_Ticker")]) : "069500", - constituents: [], - }; - } - map[sector].constituents.push({ - code, - name: idx("Constituent_Name") >= 0 ? String(data[i][idx("Constituent_Name")] ?? "").trim() : "", - weight, - isEtf: idx("Is_ETF") >= 0 ? boolFromSheet_(data[i][idx("Is_ETF")], false) : false, - }); - } - const sectors = Object.values(map).filter(s => s.proxyTicker && s.constituents.length > 0); - return sectors.length ? sectors : DEFAULT_SECTOR_UNIVERSE_V2; -} - -function writeDefaultSectorUniverseSheet_() { - const headers = [ - "Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Base_Ticker", - "Constituent_Code","Constituent_Name","Weight","Is_ETF","Enabled","Effective_Date","Source" - ]; - const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - const rows = []; - for (const sector of DEFAULT_SECTOR_UNIVERSE_V2) { - for (const c of sector.constituents) { - rows.push([ - sector.sector, - sector.proxyTicker, - sector.proxyName, - sector.proxyType || "대표주", - sector.baseTicker || "069500", - c.code, - c.name || "", - c.weight, - c.isEtf ? "Y" : "N", - "Y", - today, - "sector_universe(DEFAULT_SECTOR_UNIVERSE_V2)", - ]); - } - } - writeToSheet("sector_universe", headers, rows); - Logger.log(`sector_universe 기본 템플릿 생성: ${rows.length}행`); -} - -function sectorDataQuality_(coverage, flowRowsMin, staleCount, proxyOk, hasNorm, weightSum) { - if (!proxyOk || coverage <= 0 || !hasNorm) return "D"; - if (coverage >= 0.80 && flowRowsMin >= 20 && staleCount === 0 && weightSum >= 0.70) return "A"; - if (coverage >= 0.60 && flowRowsMin >= 5 && weightSum >= 0.60) return "B"; - return "C"; -} - -function sectorUseMode_(quality) { - if (quality === "A" || quality === "B") return "TRADE_OK"; - if (quality === "C") return "WATCH_ONLY"; - return "INVALID"; -} - -function scoreSmartMoneyNorm_(v) { - if (!Number.isFinite(v)) return 0; - if (v >= 0.15) return 25; - if (v >= 0.05) return 18; - if (v > 0) return 10; - if (v > -0.05) return 4; - return 0; -} - -function scoreBreadth_(v) { - if (!Number.isFinite(v)) return 0; - if (v >= 0.70) return 15; - if (v >= 0.50) return 10; - if (v >= 0.30) return 5; - return 0; -} - -function calcEtfLiquidityScore_(etf) { - if (!etf || etf.proxyType !== "ETF") return 5; - let score = 0; - if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw >= 1000000000) score += 4; - else if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw >= 300000000) score += 2; - if (Number.isFinite(etf.spreadPct) && etf.spreadPct <= 0.25) score += 3; - else if (Number.isFinite(etf.spreadPct) && etf.spreadPct <= 0.50) score += 1; - if (etf.priceOk && !etf.isPriceStale) score += 2; - if (etf.navRisk === "NAV_DATA_MISSING") score += 0; - else if (etf.navRisk === "OK") score += 1; - return Math.max(0, Math.min(10, score)); -} - -function calcEtfLiquidityStatus_(etf) { - if (!etf || etf.proxyType !== "ETF") return "NOT_ETF"; - if (!etf.priceOk) return "BLOCK"; - if (etf.isPriceStale) return "WARN"; - if (Number.isFinite(etf.spreadPct) && etf.spreadPct > 0.80) return "BLOCK"; - if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw < 300000000) return "WARN"; - if (etf.navRisk === "NAV_DATA_MISSING") return "WARN"; - return "OK"; -} - -function calcEtfExecutionUse_(etf) { - if (!etf || etf.proxyType !== "ETF") return "NOT_ETF"; - if (etf.liquidityStatus === "BLOCK" || !etf.priceOk) return "BLOCK"; - if (etf.navRisk !== "OK") return "WATCH_ONLY"; - if (etf.liquidityStatus === "OK") return "TRADE_OK"; - return "WATCH_ONLY"; -} - -function readEtfNavManualMap_() { - const result = {}; - try { - const sheet = getSpreadsheet_().getSheetByName("etf_nav_manual"); - if (!sheet) return result; - const data = sheet.getDataRange().getValues(); - if (data.length < 3) return result; - const hdr = data[1].map(h => String(h).trim()); - const idx = name => hdr.indexOf(name); - const tickerIdx = idx("ETF_Ticker"); - if (tickerIdx < 0) return result; - for (let i = 2; i < data.length; i++) { - const ticker = normalizeTickerCode(data[i][tickerIdx]); - if (!ticker) continue; - const enabled = idx("Enabled") >= 0 ? boolFromSheet_(data[i][idx("Enabled")], true) : true; - if (!enabled) continue; - const close = idx("Close") >= 0 ? parseFloat(data[i][idx("Close")]) : null; - const nav = idx("NAV") >= 0 ? parseFloat(data[i][idx("NAV")]) : null; - const inav = idx("iNAV") >= 0 ? parseFloat(data[i][idx("iNAV")]) : null; - let premiumDiscountPct = idx("Premium_Discount_Pct") >= 0 ? parseFloat(data[i][idx("Premium_Discount_Pct")]) : null; - const basisPrice = Number.isFinite(close) ? close : null; - const basisNav = Number.isFinite(nav) ? nav : Number.isFinite(inav) ? inav : null; - if (!Number.isFinite(premiumDiscountPct) && Number.isFinite(basisPrice) && Number.isFinite(basisNav) && basisNav > 0) { - premiumDiscountPct = ((basisPrice / basisNav) - 1) * 100; - } - const sourceDate = idx("Source_Date") >= 0 ? normalizeSheetDateString_(data[i][idx("Source_Date")]) : ""; - const trackingError = idx("Tracking_Error") >= 0 ? parseFloat(data[i][idx("Tracking_Error")]) : null; - const aum = idx("AUM") >= 0 ? parseFloat(data[i][idx("AUM")]) : null; - result[ticker] = { - close: Number.isFinite(close) ? close : null, - nav: Number.isFinite(nav) ? nav : null, - inav: Number.isFinite(inav) ? inav : null, - premiumDiscountPct: Number.isFinite(premiumDiscountPct) ? premiumDiscountPct : null, - trackingError: Number.isFinite(trackingError) ? trackingError : null, - aum: Number.isFinite(aum) ? aum : null, - sourceDate, - source: idx("Source") >= 0 ? String(data[i][idx("Source")] ?? "").trim() : "etf_nav_manual", - }; - } - } catch(e) { handleFetchError_("readEtfNavManualMap_", e, "WARN"); } - return result; -} - -function calcEtfNavRisk_(manual) { - if (!manual) return "NAV_DATA_MISSING"; - if (!Number.isFinite(manual.nav) && !Number.isFinite(manual.inav)) return "NAV_DATA_MISSING"; - if (manual.sourceDate && isStalePriceDate_(manual.sourceDate, 2)) return "NAV_STALE"; - if (Number.isFinite(manual.premiumDiscountPct) && Math.abs(manual.premiumDiscountPct) > 1.0) return "NAV_BLOCK"; - if (Number.isFinite(manual.premiumDiscountPct) && Math.abs(manual.premiumDiscountPct) > 0.5) return "NAV_WARN"; - return "OK"; -} - -function buildEtfRawRows_(universe) { - const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - const navManual = readEtfNavManualMap_(); - const etfMap = {}; - for (const sector of universe) { - if (sector.proxyType === "ETF") { - etfMap[sector.proxyTicker] = { - sector: sector.sector, - ticker: sector.proxyTicker, - name: sector.proxyName, - proxyType: sector.proxyType, - }; - } - for (const c of sector.constituents) { - if (c.isEtf) { - etfMap[c.code] = { - sector: sector.sector, - ticker: c.code, - name: c.name || sector.proxyName, - proxyType: "ETF", - }; - } - } - } - - const rows = []; - for (const etf of Object.values(etfMap)) { - const price = fetchYahooOhlcMetrics(etf.ticker); - const flow = fetchNaverFlow(etf.ticker); - const close = Number.isFinite(price.close) ? price.close : null; - const frg5Sh = flow.ok ? flow.rows.slice(0, 5).reduce((a, r) => a + r.frgn, 0) : null; - const inst5Sh = flow.ok ? flow.rows.slice(0, 5).reduce((a, r) => a + r.inst, 0) : null; - const frg5Krw = Number.isFinite(frg5Sh) && Number.isFinite(close) ? frg5Sh * close : null; - const inst5Krw = Number.isFinite(inst5Sh) && Number.isFinite(close) ? inst5Sh * close : null; - const avgTradeValue5DKrw = Number.isFinite(price.avgTradingValue5D) ? price.avgTradingValue5D * 1000000 : null; - const avgTradeValue20DKrw = Number.isFinite(price.avgTradingValue20D) ? price.avgTradingValue20D * 1000000 : null; - const manual = navManual[etf.ticker] ?? null; - const raw = { - ...etf, - close: Number.isFinite(manual?.close) ? manual.close : close, - nav: manual?.nav ?? null, - inav: manual?.inav ?? null, - premiumDiscountPct: manual?.premiumDiscountPct ?? null, - trackingError: manual?.trackingError ?? null, - aum: manual?.aum ?? null, - bid: Number.isFinite(price.bid) ? price.bid : null, - ask: Number.isFinite(price.ask) ? price.ask : null, - spreadPct: Number.isFinite(price.spreadPct) ? price.spreadPct : null, - avgTradeValue5DKrw, - avgTradeValue20DKrw, - etfFrg5Krw: frg5Krw, - etfInst5Krw: inst5Krw, - priceOk: Boolean(price.ok), - isPriceStale: Boolean(price.isPriceStale), - flowOk: Boolean(flow.ok), - flowRows: Array.isArray(flow.rows) ? flow.rows.length : 0, - navRisk: calcEtfNavRisk_(manual), - navSource: manual?.source ?? "", - navSourceDate: manual?.sourceDate ?? "", - asOfDate: today, - }; - raw.liquidityScore = calcEtfLiquidityScore_(raw); - raw.liquidityStatus = calcEtfLiquidityStatus_(raw); - raw.executionUse = calcEtfExecutionUse_(raw); - raw.lpQualityFlag = raw.liquidityStatus === "OK" ? "OK" : raw.liquidityStatus; - raw.dataStatus = raw.priceOk ? (raw.flowOk ? "PARTIAL_NAV_MISSING" : "PARTIAL_FLOW_NAV_MISSING") : "FAIL"; - rows.push(raw); - Utilities.sleep(100); - } - return rows; -} - -function buildEtfRawMap_(etfRows) { - return Object.fromEntries(etfRows.map(r => [r.ticker, r])); -} - -function calcSectorScoreV2_(sectorRet20D, sectorRs20D, smart5Norm, smart20Norm, breadth5, tradeValueRatio, proxyType, etfLiquidityScore) { - let score = 0; - const rs = Number.isFinite(sectorRs20D) ? sectorRs20D : sectorRet20D; - score += rs >= 8 ? 25 : rs >= 3 ? 18 : rs >= 0 ? 10 : rs >= -3 ? 5 : 0; - score += Math.min(25, Math.round(scoreSmartMoneyNorm_(smart5Norm) * 0.7 + scoreSmartMoneyNorm_(smart20Norm) * 0.3)); - score += scoreBreadth_(breadth5); - score += tradeValueRatio >= 1.2 ? 15 : tradeValueRatio >= 0.8 ? 8 : 0; - score += 5; // EPS revision/PER/PBR 정밀 축은 Phase 2에서 보수적 중립값만 부여. - score += proxyType === "ETF" ? (Number.isFinite(etfLiquidityScore) ? etfLiquidityScore : 0) : 5; - return Math.max(0, Math.min(100, score)); -} - -function runSectorFlowV3() { - const universe = readSectorUniverse_(); - const etfRawMap = buildEtfRawMap_(buildEtfRawRows_(universe)); - const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - const headers = [ - "Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Coverage_Weight", - "Sector_Ret5D","Sector_Ret20D","Sector_RS_20D", - "SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW","SmartMoney_5D_Norm", - "Flow_Breadth_5D","Flow_Rows_Min","Stale_Count", - "ETF_Liquidity_Score","ETF_NAV_Risk","ETF_Liquidity_Status","ETF_Execution_Use", - "Sector_Median_PE","Sector_Median_PBR", - "Sector_Score","Sector_Rank","Alert_Level","Data_Quality","Decision_Use","Reason","AsOfDate" - ]; - const rows = []; - - for (const sector of universe) { - const proxy = fetchYahooOhlcMetrics(sector.proxyTicker); - const base = sector.baseTicker ? fetchYahooOhlcMetrics(sector.baseTicker) : { ok: false }; - const perVals = [], pbrVals = []; - const eligibleConstituents = sector.constituents.filter(c => !c.isEtf); - const weightSum = eligibleConstituents.reduce((a, c) => a + (Number(c.weight) || 0), 0); - let coverage = 0, frg5Krw = 0, inst5Krw = 0, frg20Krw = 0, inst20Krw = 0; - let avgTv20Krw = 0, avgTv5Krw = 0, ret5Weighted = 0, ret20Weighted = 0, breadth5 = 0; - let flowRowsMin = 999, staleCount = 0; - const reasons = []; - - for (const c of eligibleConstituents) { - const w = Number(c.weight) || 0; - const flow = fetchNaverFlow(c.code); - const price = fetchYahooOhlcMetrics(c.code); - const flowRows = Array.isArray(flow.rows) ? flow.rows.length : 0; - if (!flow.ok || !price.ok || flowRows < 5 || !Number.isFinite(price.close)) { - reasons.push(`${c.code}:DATA_PARTIAL`); - Utilities.sleep(150); - continue; - } - - const frg5Sh = flow.rows.slice(0, 5).reduce((a, r) => a + r.frgn, 0); - const inst5Sh = flow.rows.slice(0, 5).reduce((a, r) => a + r.inst, 0); - const frg20Sh = flow.rows.slice(0, 20).reduce((a, r) => a + r.frgn, 0); - const inst20Sh = flow.rows.slice(0, 20).reduce((a, r) => a + r.inst, 0); - const cFrg5Krw = frg5Sh * price.close; - const cInst5Krw = inst5Sh * price.close; - const cFrg20Krw = frg20Sh * price.close; - const cInst20Krw = inst20Sh * price.close; - - coverage += w; - frg5Krw += cFrg5Krw * w; - inst5Krw += cInst5Krw * w; - frg20Krw += cFrg20Krw * w; - inst20Krw += cInst20Krw * w; - if (Number.isFinite(price.avgTradingValue20D)) avgTv20Krw += price.avgTradingValue20D * 1000000 * w; - if (Number.isFinite(price.avgTradingValue5D)) avgTv5Krw += price.avgTradingValue5D * 1000000 * w; - if (Number.isFinite(price.ret5D)) ret5Weighted += price.ret5D * w; - if (Number.isFinite(price.ret20D)) ret20Weighted += price.ret20D * w; - if (cFrg5Krw + cInst5Krw > 0) breadth5 += w; - flowRowsMin = Math.min(flowRowsMin, flowRows); - if (flow.isFlowStale || price.isPriceStale) staleCount++; - - const qm = fetchNaverMarketMetrics(c.code); - if (Number.isFinite(qm.per) && qm.per > 0) perVals.push(qm.per); - if (Number.isFinite(qm.pbr) && qm.pbr > 0) pbrVals.push(qm.pbr); - Utilities.sleep(150); - } - - if (flowRowsMin === 999) flowRowsMin = 0; - const smart5 = frg5Krw + inst5Krw; - const smart20 = frg20Krw + inst20Krw; - const smart5Norm = avgTv20Krw > 0 ? smart5 / avgTv20Krw : null; - const smart20Norm = avgTv20Krw > 0 ? smart20 / avgTv20Krw : null; - const sectorRet5D = coverage > 0 ? ret5Weighted / coverage : null; - const sectorRet20D = coverage > 0 ? ret20Weighted / coverage : null; - const sectorRs20D = Number.isFinite(sectorRet20D) && base.ok && Number.isFinite(base.ret20D) ? sectorRet20D - base.ret20D : null; - const tradeValueRatio = avgTv20Krw > 0 && avgTv5Krw > 0 ? avgTv5Krw / avgTv20Krw : null; - const medianPE = calcMedian_(perVals); - const medianPBR = calcMedian_(pbrVals); - const etfRaw = etfRawMap[sector.proxyTicker] ?? null; - const etfLiquidityScore = sector.proxyType === "ETF" ? (etfRaw?.liquidityScore ?? 0) : 5; - const etfNavRisk = sector.proxyType === "ETF" ? (etfRaw?.navRisk ?? "NAV_DATA_MISSING") : "NOT_ETF"; - const etfLiquidityStatus = sector.proxyType === "ETF" ? (etfRaw?.liquidityStatus ?? "WARN") : "NOT_ETF"; - const etfExecutionUse = sector.proxyType === "ETF" ? (etfRaw?.executionUse ?? "WATCH_ONLY") : "NOT_ETF"; - const quality = sectorDataQuality_(coverage, flowRowsMin, staleCount, proxy.ok, Number.isFinite(smart5Norm), weightSum); - const routeUse = sectorUseMode_(quality); - let score = calcSectorScoreV2_(sectorRet20D, sectorRs20D, smart5Norm, smart20Norm, breadth5, tradeValueRatio, sector.proxyType, etfLiquidityScore); - if (quality === "C") score = Math.min(score, 49); - if (quality === "D") score = Math.min(score, 20); - const alert = score >= 70 && smart5 > 0 && breadth5 >= 0.50 ? "INFLOW_STRONG" : - score >= 50 && smart5 > 0 ? "INFLOW_MODERATE" : - score >= 30 ? "NEUTRAL" : - smart5 < 0 && breadth5 < 0.40 ? "OUTFLOW_ALERT" : "OUTFLOW_CAUTION"; - if (quality === "C") reasons.push("Data_Quality=C:WATCH_ONLY"); - if (quality === "D") reasons.push("Data_Quality=D:INVALID"); - if (coverage < 0.60) reasons.push("Coverage<0.60"); - if (sector.constituents.length !== eligibleConstituents.length) reasons.push("ETF_Constituent_Excluded_From_Sector_Flow"); - if (staleCount > 0) reasons.push(`Stale_Count=${staleCount}`); - if (!proxy.ok) reasons.push("Proxy_Price_FAIL"); - if (!Number.isFinite(smart5Norm)) reasons.push("SmartMoney_Norm_MISSING"); - if (sector.proxyType === "ETF" && etfNavRisk === "NAV_DATA_MISSING") reasons.push("ETF_NAV_DATA_MISSING"); - if (sector.proxyType === "ETF" && etfLiquidityStatus !== "OK") reasons.push(`ETF_Liquidity=${etfLiquidityStatus}`); - if (sector.proxyType === "ETF" && etfExecutionUse !== "TRADE_OK") reasons.push(`ETF_Execution=${etfExecutionUse}`); - - rows.push({ - sector: sector.sector, - proxyTicker: sector.proxyTicker, - proxyName: sector.proxyName, - proxyType: sector.proxyType || "대표주", - coverage, - sectorRet5D, - sectorRet20D, - sectorRs20D, - frg5Krw, - inst5Krw, - frg20Krw, - inst20Krw, - smart5, - smart20, - avgTv20Krw, - smart5Norm, - breadth5, - flowRowsMin, - staleCount, - etfLiquidityScore, - etfNavRisk, - etfLiquidityStatus, - etfExecutionUse, - medianPE, - medianPBR, - score, - rank: 0, - alert, - quality, - routeUse, - reason: reasons.length ? reasons.join(" | ") : "OK", - asOfDate: today, - proxyRet5D: proxy.ok ? proxy.ret5D : null, - proxyRet10D: proxy.ok ? proxy.ret10D : null, - proxyRet20D: proxy.ok ? proxy.ret20D : null, - }); - } - - rows.sort((a, b) => Number(b.score) - Number(a.score)); - rows.forEach((r, i) => { r.rank = i + 1; }); - appendSectorFlowHistoryV2_(rows); - return rows; -} - -function appendSectorFlowHistoryV2_(rows) { - // 주말(토·일)은 KRX 휴장 — 새 시장 데이터 없으므로 이력 저장 불필요 - const dow = new Date().getDay(); // 0=일, 6=토 - if (dow === 0 || dow === 6) { - Logger.log("appendSectorFlowHistoryV2_: 주말 스킵 (dow=" + dow + ")"); - return; - } - - const headers = [ - "Snapshot_Date","Sector","Sector_Score","Sector_Rank","SmartMoney_5D_KRW","SmartMoney_20D_KRW", - "Flow_Breadth_5D","Alert_Level","Data_Quality","Decision_Use","ETF_Liquidity_Status","ETF_Execution_Use","Reason","Saved_At" - ]; - const ss = getSpreadsheet_(); - let sheet = ss.getSheetByName("sector_flow_history"); - if (!sheet) { - sheet = ss.insertSheet("sector_flow_history"); - sheet.getRange(1, 1).setValue("updated: sector_flow_history cumulative snapshots"); - sheet.getRange(2, 1, 1, headers.length).setValues([headers]); - } - const data = sheet.getDataRange().getValues(); - const hdr = data[1] ?? headers; - const dateIdx = hdr.indexOf("Snapshot_Date"); - const sectorIdx = hdr.indexOf("Sector"); - const existing = []; - const byKey = {}; - for (let i = 2; i < data.length; i++) { - const row = data[i]; - const d = normalizeSheetDateString_(row[dateIdx]); - const s = String(row[sectorIdx] ?? "").trim(); - if (!d || !s) continue; - byKey[`${d}|${s}`] = row; - existing.push(row); - } - const savedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); - for (const r of rows) { - byKey[`${r.asOfDate}|${r.sector}`] = [ - r.asOfDate, r.sector, r.score, r.rank, Math.round(r.smart5), Math.round(r.smart20), - roundNum(r.breadth5, 4), r.alert, r.quality, r.routeUse, r.etfLiquidityStatus, r.etfExecutionUse, r.reason, savedAt - ]; - } - const out = Object.values(byKey).sort((a, b) => { - const da = String(a[0]), db = String(b[0]); - if (da !== db) return da.localeCompare(db); - return String(a[1]).localeCompare(String(b[1])); - }); - sheet.clearContents(); - sheet.getRange(1, 1).setValue(`updated: ${savedAt} KST`); - sheet.getRange(2, 1, 1, headers.length).setValues([headers]); - if (out.length) sheet.getRange(3, 1, out.length, headers.length).setValues(out); -} - -function normalizeSheetDateString_(value) { - if (value instanceof Date && !isNaN(value.getTime())) { - return Utilities.formatDate(value, "Asia/Seoul", "yyyy-MM-dd"); - } - const raw = String(value ?? "").trim(); - if (!raw) return ""; - const normalized = raw.replace(/\./g, "-").replace(/\//g, "-"); - const m = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/); - if (m) return `${m[1]}-${String(m[2]).padStart(2, "0")}-${String(m[3]).padStart(2, "0")}`; - const d = new Date(raw); - return isNaN(d.getTime()) ? "" : Utilities.formatDate(d, "Asia/Seoul", "yyyy-MM-dd"); -} - -function readSectorFlowHistoryPrev_(currentDate) { - const result = {}; - try { - const sheet = getSpreadsheet_().getSheetByName("sector_flow_history"); - if (!sheet) return result; - const data = sheet.getDataRange().getValues(); - const hdr = data[1] ?? []; - const dIdx = hdr.indexOf("Snapshot_Date"); - const sIdx = hdr.indexOf("Sector"); - const rankIdx = hdr.indexOf("Sector_Rank"); - const sm5Idx = hdr.indexOf("SmartMoney_5D_KRW"); - const breadthIdx = hdr.indexOf("Flow_Breadth_5D"); - if (dIdx < 0 || sIdx < 0) return result; - const grouped = {}; - for (let i = 2; i < data.length; i++) { - const d = normalizeSheetDateString_(data[i][dIdx]); - const s = String(data[i][sIdx] ?? "").trim(); - if (!d || !s || d === currentDate) continue; - if (!grouped[s]) grouped[s] = []; - grouped[s].push({ - date: d, - rank: rankIdx >= 0 ? parseInt(data[i][rankIdx]) : null, - smart5: sm5Idx >= 0 ? parseFloat(data[i][sm5Idx]) : null, - breadth5: breadthIdx >= 0 ? parseFloat(data[i][breadthIdx]) : null, - }); - } - for (const [sector, items] of Object.entries(grouped)) { - items.sort((a, b) => b.date.localeCompare(a.date)); - result[sector] = { w1: items[0] ?? null, w2: items[1] ?? null }; - } - } catch(e) { handleFetchError_("readSectorFlowHistoryPrev_", e, "WARN"); } - return result; -} - -function readPrevLegacySectorFlow_() { - const result = {}; - try { - const sfSheet = getSpreadsheet_().getSheetByName("sector_flow"); - if (!sfSheet) return result; - const data = sfSheet.getDataRange().getValues(); - const hdr = data[1] ?? []; - const sIdx = hdr.indexOf("Sector"); - const rIdx = hdr.indexOf("Sector_Rank") >= 0 ? hdr.indexOf("Sector_Rank") : hdr.indexOf("Rotation_Rank"); - const s5Idx = hdr.indexOf("SmartMoney_5D_KRW") >= 0 ? hdr.indexOf("SmartMoney_5D_KRW") : hdr.indexOf("Frg_5D_SUM"); - const s20Idx = hdr.indexOf("SmartMoney_20D_KRW") >= 0 ? hdr.indexOf("SmartMoney_20D_KRW") : hdr.indexOf("Frg_20D_SUM"); - if (sIdx < 0) return result; - for (let i = 2; i < data.length; i++) { - const s = String(data[i][sIdx]).trim(); - if (!s || s === "Sector") continue; - const smart5 = s5Idx >= 0 ? parseFloat(data[i][s5Idx]) : null; - const smart20 = s20Idx >= 0 ? parseFloat(data[i][s20Idx]) : null; - result[s] = { - rank: rIdx >= 0 ? parseInt(data[i][rIdx]) : null, - smart5: Number.isFinite(smart5) ? smart5 : null, - smart20: Number.isFinite(smart20) ? smart20 : null, - frg5: Number.isFinite(smart5) ? smart5 : null, - inst5: Number.isFinite(smart5) ? smart5 : null, - }; - } - } catch(e) { handleFetchError_("readPrevLegacySectorFlow_", e, "WARN"); } - return result; -} - -function readW2LegacySectorFlow_() { - const result = {}; - try { - const props = PropertiesService.getScriptProperties(); - const w2Json = props.getProperty("sf_w2_ranks_json"); - if (w2Json) Object.assign(result, JSON.parse(w2Json).data ?? {}); - } catch(e) { handleFetchError_("readW2LegacySectorFlow_", e, "INFO"); } - return result; -} - -function writeLegacySectorFlowFromStage2_(stage2Rows) { - const headers = [ - "Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Coverage_Weight", - "Sector_Ret5D","Sector_Ret10D","Sector_Ret20D","Sector_RS_20D", - "SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW", - "SmartMoney_5D_Norm","SmartMoney_20D_Norm","Flow_Breadth_5D","Flow_Rows_Min","Stale_Count", - "ETF_Liquidity_Score","ETF_NAV_Risk","ETF_Liquidity_Status","ETF_Execution_Use", - "Sector_Median_PE","Sector_Median_PBR","Sector_Score","Sector_Rank", - "Alert_Level","Data_Quality","Decision_Use","Reason","RW1","RW3","AsOfDate", - "ETF_Code","Frg_5D_SUM","Inst_5D_SUM","Indiv_5D_SUM","Frg_20D_SUM","Inst_20D_SUM", - "ETF_Ret5D","ETF_Ret10D","ETF_Ret20D", - "Rotation_Score","Rotation_Rank","Prev_Rotation_Rank","Prev_Frg_5D_SUM","Prev_Inst_5D_SUM", - "Prev_Rotation_Rank_W2","Prev_Frg_5D_SUM_W2","Prev_Inst_5D_SUM_W2","Smart_Money" - ]; - const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - const prev = readPrevLegacySectorFlow_(); - const w2 = readW2LegacySectorFlow_(); - const historyPrev = readSectorFlowHistoryPrev_(today); - try { - const props = PropertiesService.getScriptProperties(); - if (Object.keys(prev).length > 0) props.setProperty("sf_w2_ranks_json", JSON.stringify({ saved_at: today, data: prev })); - } catch(e) { handleFetchError_("writeLegacySectorFlowFromStage2_:W2 save", e, "INFO"); } - - const rows = stage2Rows.map(r => { - const p = prev[r.sector] ?? {}; - const w = w2[r.sector] ?? {}; - const hp = historyPrev[r.sector]?.w1 ?? null; - const hw = historyPrev[r.sector]?.w2 ?? null; - const w1Rank = Number.isFinite(hp?.rank) ? hp.rank : p.rank; - const w2Rank = Number.isFinite(hw?.rank) ? hw.rank : w.rank; - const rw1 = Number.isFinite(w1Rank) && Number.isFinite(w2Rank) && (r.rank - w1Rank >= 3) && (w1Rank - w2Rank >= 3) ? 1 : 0; - const curOutflow = r.smart5 < 0 && r.breadth5 < 0.40; - const prevOutflow = Number.isFinite(p.frg5) && p.frg5 < 0 && Number.isFinite(p.inst5) && p.inst5 < 0; - const histOutflow = Number.isFinite(hp?.smart5) && hp.smart5 < 0 && Number.isFinite(hp?.breadth5) && hp.breadth5 < 0.40; - const rw3 = curOutflow && (histOutflow || prevOutflow) ? 1 : 0; - const smart = r.smart5 > 0 && r.breadth5 >= 0.70 ? "STRONG" : - r.smart5 > 0 && r.breadth5 >= 0.40 ? "MODERATE" : - r.smart5 > 0 ? "WEAK" : "ABSENT"; - const smartMoneyHalf = Number.isFinite(r.smart5) ? r.smart5 / 2 : ""; - const frg5Alias = Number.isFinite(smartMoneyHalf) ? smartMoneyHalf : ""; - const inst5Alias = Number.isFinite(smartMoneyHalf) ? smartMoneyHalf : ""; - const frg20Alias = Number.isFinite(r.smart20) ? r.smart20 / 2 : ""; - const inst20Alias = Number.isFinite(r.smart20) ? r.smart20 / 2 : ""; - return [ - r.sector, r.proxyTicker, r.proxyName, r.proxyType, r.coverage, - r.sectorRet5D, r.proxyRet10D, r.sectorRet20D, r.sectorRs20D, - r.smart5, r.smart20, r.avgTv20Krw, - r.smart5Norm, r.smart20Norm, r.breadth5, r.flowRowsMin, r.staleCount, - r.etfLiquidityScore, r.etfNavRisk, r.etfLiquidityStatus, r.etfExecutionUse, - r.medianPE != null ? r.medianPE.toFixed(1) : "", - r.medianPBR != null ? r.medianPBR.toFixed(2) : "", - r.score, r.rank, - r.alert, r.quality, r.routeUse, r.reason, rw1, rw3, r.asOfDate, - r.proxyTicker, frg5Alias, inst5Alias, 0, frg20Alias, inst20Alias, - Number.isFinite(r.proxyRet5D) ? r.proxyRet5D : "N/A", - Number.isFinite(r.proxyRet10D) ? r.proxyRet10D : "N/A", - Number.isFinite(r.proxyRet20D) ? r.proxyRet20D : "N/A", - r.score, r.rank, Number.isFinite(w1Rank) ? w1Rank : "", - Number.isFinite(p.frg5) ? p.frg5 : "", Number.isFinite(p.inst5) ? p.inst5 : "", - Number.isFinite(w2Rank) ? w2Rank : "", Number.isFinite(w.frg5) ? w.frg5 : "", - Number.isFinite(w.inst5) ? w.inst5 : "", smart - ]; - }); - writeToSheet("sector_flow", headers, rows); - Logger.log(`sector_flow 완료: ${rows.length}섹터`); -} - -// ── F4: Trailing Stop account_snapshot 일괄 갱신 ──────────────────────────── -// _trailingStopUpdates_ 배열을 소비해 account_snapshot의 highest_price/stop_price/last_updated 갱신. -// 신규 최고가 경신 종목만 업데이트 — entry 없는 종목은 건드리지 않음. -function applyTrailingStopUpdates_() { - if (!_trailingStopUpdates_.length) return; - try { - const ss = getSpreadsheet_(); - const sheet = ss.getSheetByName("account_snapshot"); - if (!sheet) { Logger.log("applyTrailingStopUpdates_: account_snapshot 탭 없음"); return; } - const data = sheet.getDataRange().getValues(); - const hdr = data[1] ?? []; // row2 = 헤더 - const tkIdx = hdr.indexOf("ticker"); - const highIdx= hdr.indexOf("highest_price_since_entry"); - const stopIdx= hdr.indexOf("stop_price"); - const updIdx = hdr.indexOf("last_updated"); - if (tkIdx < 0 || highIdx < 0 || stopIdx < 0) { - Logger.log("applyTrailingStopUpdates_: account_snapshot 컬럼 미발견"); - return; - } - const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - const updateMap = {}; - _trailingStopUpdates_.forEach(u => { updateMap[u.ticker] = u; }); - - for (let i = 2; i < data.length; i++) { - const tk = String(data[i][tkIdx] ?? "").trim(); - if (!tk || !updateMap[tk]) continue; - const upd = updateMap[tk]; - sheet.getRange(i + 1, highIdx + 1).setValue(upd.new_highest); - sheet.getRange(i + 1, stopIdx + 1).setValue(upd.new_stop); - if (updIdx >= 0) sheet.getRange(i + 1, updIdx + 1).setValue(today); - Logger.log(`TrailingStop 갱신: ${tk} highest=${upd.new_highest} stop=${upd.new_stop}`); - } - } catch(e) { - handleFetchError_("applyTrailingStopUpdates_", e, "WARN"); - } -} - -// ── 버킷 할당 상태 계산 ───────────────────────────────────────────────────── -// _bucketSnapshot_이 있어야 동작. runDataFeed() 실행 후 runMacro()에서 호출. -// 목표 범위: core 60-72%, satellite 10-25%, cash 10-22% (spec/risk) -function calcBucketStatus_() { - if (!_bucketSnapshot_) return null; - const { core_pct, satellite_pct } = _bucketSnapshot_; - const cash_pct = parseFloat(Math.max(0, 100 - core_pct - satellite_pct).toFixed(2)); - const coreStatus = core_pct < THRESHOLDS.BUCKET_CORE_MIN ? "UNDERWEIGHT" : core_pct > THRESHOLDS.BUCKET_CORE_MAX ? "OVERWEIGHT" : "OK"; - const satStatus = satellite_pct < THRESHOLDS.BUCKET_SAT_MIN ? "UNDERWEIGHT" : satellite_pct > THRESHOLDS.BUCKET_SAT_MAX ? "OVERWEIGHT" : "OK"; - const cashStatus = cash_pct < THRESHOLDS.BUCKET_CASH_MIN ? "LOW" : cash_pct > THRESHOLDS.BUCKET_CASH_MAX ? "HIGH" : "OK"; - const issues = [ - coreStatus !== "OK" ? `core_${coreStatus}` : null, - satStatus !== "OK" ? `sat_${satStatus}` : null, - cashStatus !== "OK" ? `cash_${cashStatus}` : null, - ].filter(Boolean); - return { - core_pct, satellite_pct, cash_pct, - core_status: coreStatus, satellite_status: satStatus, cash_status: cashStatus, - overall: issues.length === 0 ? "BALANCED" : issues.join("|"), - detail: `core=${core_pct}%(${coreStatus}) sat=${satellite_pct}%(${satStatus}) cash=${cash_pct}%(${cashStatus})`, - }; -} - -// ── 매크로 지표 수집 ───────────────────────────────────────────────────────── -function runMacro() { - const MACRO_TICKERS = [ - { sym: "^KS11", name: "KOSPI", category: "Index" }, - { sym: "^KQ11", name: "KOSDAQ", category: "Index" }, - { sym: "^VIX", name: "VIX", category: "Risk" }, - { sym: "KRW=X", name: "USD_KRW", category: "FX" }, - { sym: "JPY=X", name: "USD_JPY", category: "FX" }, - { sym: "DX-Y.NYB",name: "DXY", category: "FX" }, - { sym: "GC=F", name: "Gold", category: "Commodity" }, - { sym: "CL=F", name: "WTI_Oil", category: "Commodity" }, - { sym: "^TNX", name: "US10Y_Yield",category: "Bond" }, - { sym: "^TYX", name: "US30Y_Yield",category: "Bond" }, - { sym: "^GSPC", name: "SP500", category: "Index" }, - { sym: "^NDX", name: "NASDAQ100", category: "Index" }, - // HYG: HY 회사채 ETF → Ret5D로 credit_stress_status 산출 (MRS 신용위험 입력값) - { sym: "HYG", name: "HYG_HY_Bond",category: "CreditProxy" }, - ]; - - const headers = ["Symbol","Name","Category","Close","Ret1D","Ret2D","Ret5D","Ret10D","Ret20D","MA20","MA60","AsOfDate","Status"]; - const rows = []; - const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - - for (const m of MACRO_TICKERS) { - const p = fetchYahooPrice(m.sym); - let ma20 = "", ma60 = "", ret10D = "", ret2D = ""; - if (m.category === "Index") { - const ohlc = fetchYahooOhlcMetrics(m.sym); - if (ohlc?.ok) { - if (Number.isFinite(ohlc.ma20)) ma20 = ohlc.ma20.toFixed(2); - if (Number.isFinite(ohlc.ma60)) ma60 = ohlc.ma60.toFixed(2); - if (Number.isFinite(ohlc.ret10D)) ret10D = ohlc.ret10D.toFixed(2); - if (Number.isFinite(ohlc.ret2D)) ret2D = ohlc.ret2D.toFixed(2); - } - } else if (m.category === "FX" && m.name === "USD_JPY") { - // USD/JPY Ret2D: MRS usd_jpy_score 전용 - if (p.ok && Number.isFinite(parseFloat(p.ret5D))) { - // 2일 변화율은 fetchYahooOhlcMetrics가 필요 — FX는 budget 여유 있으면 시도 - const ohlc = fetchYahooOhlcMetrics(m.sym); - if (ohlc?.ok && Number.isFinite(ohlc.ret2D)) ret2D = ohlc.ret2D.toFixed(2); - } - } - if (p.ok) { - const p1d = fetchYahooPrice1D(m.sym); - rows.push([m.sym, m.name, m.category, p.close, p1d, ret2D, p.ret5D, ret10D !== "" ? ret10D : (p.ok ? p.ret10D ?? "" : ""), p.ret20D, ma20, ma60, today, "OK"]); - } else { - rows.push([m.sym, m.name, m.category, "N/A", "N/A", "", "N/A", "", "N/A", ma20, ma60, today, "FAIL"]); - } - Utilities.sleep(300); - } - - // ── MRS(시장위험점수) 자동 계산 후 summary 행 추가 ──────────────────────── - const byName = {}; - rows.forEach(r => { byName[r[1]] = r; }); // Name 기준 인덱싱 - const vixClose = parseFloat(byName["VIX"]?.[3]); - const kospiClose= parseFloat(byName["KOSPI"]?.[3]); - const kospiMA20 = parseFloat(byName["KOSPI"]?.[9]); - const usdKrw = parseFloat(byName["USD_KRW"]?.[3]); - const usdJpyR2D = parseFloat(byName["USD_JPY"]?.[5]); // Ret2D - const hygRet5D = parseFloat(byName["HYG_HY_Bond"]?.[6]); // Ret5D - - // credit_stress_status 산출 (HYG Ret5D 기반 proxy) - const creditStress = Number.isFinite(hygRet5D) - ? (hygRet5D < -2 ? "stress" : hygRet5D < -1 ? "caution" : "none") - : "DATA_MISSING"; - - // MARKET_RISK_SCORE_V1 - let mrs = 0; - mrs += Number.isFinite(vixClose) ? (vixClose < 18 ? 0 : vixClose <= 25 ? 2 : vixClose <= 35 ? 3 : 4) : 4; - mrs += Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) ? (kospiClose >= kospiMA20 ? 0 : 2) : 2; - mrs += Number.isFinite(usdKrw) ? (usdKrw < 1400 ? 0 : usdKrw <= 1450 ? 1 : 2) : 2; - mrs += Number.isFinite(usdJpyR2D) ? (usdJpyR2D > -1 ? 0 : 1) : 1; - mrs += creditStress === "none" ? 0 : 1; - - // kosdaq_regime_supplement: KOSDAQ < MA20 이고 KOSPI >= MA20이면 MRS +1 - const kosdaqClose = parseFloat(byName["KOSDAQ"]?.[3]); - const kosdaqMA20 = parseFloat(byName["KOSDAQ"]?.[9]); - const kosdaqSupp = Number.isFinite(kosdaqClose) && Number.isFinite(kosdaqMA20) - && kosdaqClose < kosdaqMA20 - && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose >= kospiMA20 - ? 1 : 0; - mrs = Math.min(10, mrs + kosdaqSupp); - - // TARGET_CASH_PCT_V1 - const targetCashPct = (5 + (mrs / 10) * 15).toFixed(1); - - // ── sector_flow 읽기 → 완전 국면 판정용 데이터 수집 ───────────────────── - // runSectorFlow()가 sector_flow 기록 완료 후 runMacro()가 실행되므로 최신값 읽기 가능 - let sfTop1Score = 0, sfTop2Sum = 0, sfTop1AlertScore = 0, sfTop1Sector = ""; - let sfSmart20Sum = 0; - try { - const sfSheet = getSpreadsheet_().getSheetByName("sector_flow"); - if (sfSheet) { - const sfData = sfSheet.getDataRange().getValues(); - const sfHdr = sfData[1] ?? []; - const sfRankIdx = sfHdr.indexOf("Sector_Rank") >= 0 ? sfHdr.indexOf("Sector_Rank") : sfHdr.indexOf("Rotation_Rank"); - const sfScoreIdx = sfHdr.indexOf("Sector_Score") >= 0 ? sfHdr.indexOf("Sector_Score") : sfHdr.indexOf("Rotation_Score"); - const sfAlertIdx = sfHdr.indexOf("Alert_Level"); - const sfSmart20Idx= sfHdr.indexOf("SmartMoney_20D_KRW") >= 0 ? sfHdr.indexOf("SmartMoney_20D_KRW") : sfHdr.indexOf("Frg_20D_SUM"); - const sfSectorIdx = sfHdr.indexOf("Sector"); - const sfEntries = []; - for (let i = 2; i < sfData.length; i++) { - const row = sfData[i]; - const sec = String(row[sfSectorIdx] ?? "").trim(); - if (!sec || sec === "Sector") continue; - const score = parseFloat(row[sfScoreIdx]); - const rank = parseInt(row[sfRankIdx]); - const als = String(row[sfAlertIdx] ?? ""); - const aScore = als === "INFLOW_STRONG" ? 3 : als === "INFLOW_MODERATE" ? 2 : als === "NEUTRAL" ? 1 : 0; - const smart20 = parseFloat(row[sfSmart20Idx]); - sfEntries.push({ rank, score, alertScore: aScore, sec, smart20 }); - if (Number.isFinite(smart20)) sfSmart20Sum += smart20; - } - sfEntries.sort((a, b) => a.rank - b.rank); - if (sfEntries.length >= 1) { - sfTop1Score = sfEntries[0].score ?? 0; - sfTop1AlertScore = sfEntries[0].alertScore ?? 0; - sfTop1Sector = sfEntries[0].sec; - } - if (sfEntries.length >= 2) { - sfTop2Sum = (sfEntries[0].score ?? 0) + (sfEntries[1].score ?? 0); - } - } - } catch(e) { handleFetchError_("runMacro:sector_flow regime read", e, "WARN"); } - - // KOSPI MA60·Ret20D — byName column index (행 구조: [sym,name,cat,close,ret1d,ret2d,ret5d,ret10d,ret20d,ma20,ma60,...]) - const kospiMA60 = parseFloat(byName["KOSPI"]?.[10]); - const kospiRet20D = parseFloat(byName["KOSPI"]?.[8]); - - // ── MARKET_REGIME_V1 완전 판정 (spec/11_market_regime.yaml) ───────────── - const leaderSectorFlag_ = SECTOR_TIER_MAP[sfTop1Sector] === "Tier_1" ? 1 : 0; - - const isRiskOff_ = mrs >= 7 - || (Number.isFinite(vixClose) && vixClose >= 25 - && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose < kospiMA20); - - const riskOnBase_ = !isRiskOff_ - && Number.isFinite(vixClose) && vixClose < 18 - && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose > kospiMA20 - && ((Number.isFinite(kospiMA60) && kospiMA20 >= kospiMA60) - || (Number.isFinite(kospiRet20D) && kospiRet20D > 0)); - const riskOnFlow_ = sfSmart20Sum > 0 || sfTop2Sum >= 100; - - const isLeader_ = !isRiskOff_ - && sfTop2Sum >= 100 && sfTop1Score >= 55 && sfTop1AlertScore >= 2 && leaderSectorFlag_ === 1 - && Number.isFinite(kospiRet20D) && kospiRet20D > 0 - && Number.isFinite(vixClose) && vixClose < 25; - - const isSecularLeader_ = isLeader_ - && sfTop1Sector === "반도체" - && Number.isFinite(vixClose) && vixClose < 22 - && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose > kospiMA20; - - let marketRegime; - if (isRiskOff_) marketRegime = "RISK_OFF"; - else if (isSecularLeader_) marketRegime = "SECULAR_LEADER_RISK_ON"; - else if (isLeader_) marketRegime = "LEADER_CONCENTRATION"; - else if (riskOnBase_ && riskOnFlow_) marketRegime = "RISK_ON"; - else if (mrs <= 5) marketRegime = "NEUTRAL"; - else marketRegime = "RISK_OFF_CANDIDATE"; - - const mrsDetail = `score=${mrs}/10 cash=${targetCashPct}% regime=${marketRegime}` + - `${kosdaqSupp ? " [KOSDAQ+1]" : ""} top1=${sfTop1Sector}(${sfTop1Score.toFixed(0)}) top2sum=${sfTop2Sum.toFixed(0)}`; - - // ── Bayesian multiplier ──────────────────────────────────────────────────── - const bayesianInfo = readPerformanceSheet_(); - const bayesianDetail = `${bayesianInfo.bayesian_label} (${bayesianInfo.bayesian_multiplier}×)` + - (bayesianInfo.win_rate_30 != null ? ` wr=${(bayesianInfo.win_rate_30*100).toFixed(0)}%` : "") + - (bayesianInfo.net_expectancy_30 != null ? ` ne=${bayesianInfo.net_expectancy_30.toFixed(1)}%` : "") + - ` trades=${bayesianInfo.trades_used}`; - - // ── net_return_feedback 상태 (RISK_BUDGET_CASCADE_V1 입력) ──────────────── - // spec/05_position_sizing.yaml:net_return_feedback - const neTrades_ = bayesianInfo.trades_used; - const ne30_ = bayesianInfo.net_expectancy_30; // %, e.g. 3.2 = 3.2% avg expectancy - const consLoss_ = bayesianInfo.consecutive_losses; - let netRF = "NORMAL", netRFDetail = ""; - if (neTrades_ < 20) { - netRFDetail = `trades<20(${neTrades_}건) — 규칙 미적용`; - } else if (Number.isFinite(ne30_) && ne30_ <= -2) { - netRF = "REDUCED"; - netRFDetail = `ne=${ne30_.toFixed(1)}% — base_risk 0.007→0.003 삭감 권고`; - } else if (Number.isFinite(ne30_) && ne30_ <= 0) { - netRF = "CAUTION"; - netRFDetail = `ne=${ne30_.toFixed(1)}% — high_confidence 금지, multiplier 0.5× 강제`; - } else { - netRFDetail = `ne=${Number.isFinite(ne30_) ? ne30_.toFixed(1) : "N/A"}% — 정상`; - } - if (consLoss_ >= 5 && netRF === "NORMAL") { - netRF = "CAUTION"; - netRFDetail = `연속손실 ${consLoss_}건 — high_confidence 금지`; - } - - // ── TOTAL_HEAT_V1 계산 — account_snapshot 기반 ────────────────────────── - const macroSettings = readSettingsTab_(); - const totalAssetKrw = Number.isFinite(parseFloat(macroSettings["total_asset_krw"])) - ? parseFloat(macroSettings["total_asset_krw"]) : null; - const heatInfo = readAccountSnapshotHeat_(totalAssetKrw); - - // ── FC(탐색) 손실 예산 월별 집계 ──────────────────────────────────────── - const fcBudgetPct = Number.isFinite(parseFloat(macroSettings["fc_budget_pct_override"])) - ? parseFloat(macroSettings["fc_budget_pct_override"]) : null; - const fcInfo = calcFcBudget_(totalAssetKrw, fcBudgetPct); - - // ── orbit_gap 계산 (spec/01_objective_profile.yaml:orbit_monthly_tracker) ── - const orbitInfo = calcOrbitGap_(macroSettings); - - // summary 행 8개 (MRS / REGIME / BAYESIAN / TOTAL_HEAT / FC_BUDGET / NET_RETURN_FEEDBACK / ORBIT_GAP / ORBIT_STATE) - rows.push(["MRS_COMPUTED", "Market_Risk_Score", "Computed", mrs, "", "", "", "", "", "", "", today, mrsDetail]); - rows.push(["REGIME_PRELIM", "Market_Regime_Prelim", "Computed", marketRegime, "", "", "", "", "", "", "", today, `credit_stress=${creditStress} smart20=${sfSmart20Sum.toFixed(0)}`]); - rows.push(["BAYESIAN_COMPUTED", "Bayesian_Multiplier", "Computed", bayesianInfo.bayesian_multiplier, "", "", "", "", "", "", "", today, bayesianDetail]); - rows.push(["TOTAL_HEAT", "Total_Heat_Pct", "Computed", heatInfo.total_heat_pct ?? "N/A", "", "", "", "", "", "", "", today, - `${heatInfo.hf005_status} account_snapshot=${heatInfo.positions_count}` + - (heatInfo.total_heat_krw != null ? ` heat_krw=${Math.round(heatInfo.total_heat_krw).toLocaleString()}` : "")]); - rows.push(["FC_BUDGET", "FC_Loss_Budget_Monthly", "Computed", fcInfo.fc_used_pct ?? "N/A", "", "", "", "", "", "", "", today, `${fcInfo.fc_status} trades=${fcInfo.trades}`]); - rows.push(["NET_RETURN_FEEDBACK", "Net_Return_Feedback", "Computed", netRF, "", "", "", "", "", "", "", today, netRFDetail]); - rows.push(["ORBIT_GAP", "Orbit_Gap_Pct", "Computed", orbitInfo.ok ? orbitInfo.orbit_gap_pct : "N/A", "", "", "", "", "", "", "", today, orbitInfo.detail]); - rows.push(["ORBIT_STATE", "Orbit_State", "Computed", orbitInfo.ok ? orbitInfo.orbit_state : "N/A", "", "", "", "", "", "", "", today, - orbitInfo.ok ? `slot_adj=${orbitInfo.offensive_slot_adj} cash_adj=${orbitInfo.cash_floor_adj} (${orbitInfo.elapsed_months}/${orbitInfo.total_months}개월)` : orbitInfo.detail]); - const bucketInfo = calcBucketStatus_(); - rows.push(["BUCKET_STATUS", "Bucket_Allocation_Status","Computed", - bucketInfo ? bucketInfo.overall : "N/A", "", "", "", "", "", "", "", today, - bucketInfo ? bucketInfo.detail : "data_feed 미실행 OR account_snapshot 없음"]); - - writeToSheet("macro", headers, rows); - Logger.log(`macro 완료: ${rows.length - 9}종목 + MRS/REGIME/BAYESIAN/TOTAL_HEAT/FC_BUDGET/NET_RETURN_FEEDBACK/ORBIT_GAP/ORBIT_STATE/BUCKET_STATUS`); - - // orbit_gap 월별 이력 탭 갱신 (이미 계산된 macroSettings/orbitInfo 재사용) - runOrbitGap(macroSettings, orbitInfo); - - // 개별 실행에서는 기존 연쇄를 유지하고, run_all() 모드에서는 상위 오케스트레이터가 다음 단계를 수행한다. - if (!isRunAllOrchestrated_()) { - runEventRisk(); - } -} - -// ── 이벤트 리스크 ───────────────────────────────────────────────────────────── -// event_calendar 탭을 source of truth로 읽어 event_risk 탭을 생성한다. -// 날짜는 GAS 코드에 hardcode하지 않는다 — 운영자가 event_calendar 탭을 직접 관리. -// 최초 실행 또는 탭이 비어 있으면 seedEventCalendar_()가 초기값을 채운다. -// 탭 업데이트: GAS 편집기 → seedEventCalendar_ 또는 직접 시트 편집. - -// seed: FOMC / US_CPI / EARNINGS / EXPIRY / IPO 기준값 (빈 탭에만 기록) -function seedEventCalendar_() { - const ss = getSpreadsheet_(); - let sheet = ss.getSheetByName("event_calendar"); - if (!sheet) sheet = ss.insertSheet("event_calendar"); - - const SEED_HEADERS = ["Date", "Event", "Type", "Impact", "Alert"]; - const SEED_ROWS = [ - // FOMC — Federal Reserve 공식 일정 (연 8회). 업데이트: https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm - ["2026-06-11", "FOMC 금리결정", "FOMC", "HIGH", "금리동결 시 KOSPI +1~2% 기대, 인상 시 원화 약세 압력"], - ["2026-07-28", "FOMC 금리결정", "FOMC", "HIGH", ""], - ["2026-09-16", "FOMC 금리결정", "FOMC", "HIGH", ""], - // US CPI — BLS 발표일 (매월 1회). 업데이트: https://www.bls.gov/schedule/news_release/cpi.htm - ["2026-06-11", "미국 CPI 발표 (5월)", "US_CPI", "HIGH", "예상치 상회 시 금리인상 우려 → 원화 약세·KOSPI 하방 압력. 당일 신규매수 자제"], - ["2026-07-15", "미국 CPI 발표 (6월)", "US_CPI", "HIGH", "FOMC 전 마지막 CPI — 금리 경로 재평가 촉매"], - ["2026-08-12", "미국 CPI 발표 (7월)", "US_CPI", "HIGH", ""], - // EARNINGS - ["2026-06-20", "삼성전자 1Q 잠정실적", "EARNINGS", "HIGH", "반도체 섹터 선행 지표"], - // EXPIRY - ["2026-06-15", "옵션만기일", "EXPIRY", "MEDIUM", "변동성 확대 구간 주의"], - ["2026-07-15", "선물·옵션 동시만기", "EXPIRY", "HIGH", "트리플위칭 — 포지션 줄이기"], - // IPO — 대형 IPO 확정 시 직접 추가. Type=IPO, Impact=HIGH - // 예: ["2026-MM-DD", "XXX 상장", "IPO", "HIGH", "공모자금 수급 쏠림 → 보유 소형주 매도 압력"] - ]; - - const existingData = sheet.getDataRange().getValues(); - // 헤더만 있거나 완전히 비어 있으면 seed 기록 - const dataRowCount = existingData.filter((r, i) => i > 0 && r[0] && String(r[0]).trim()).length; - if (dataRowCount === 0) { - sheet.clearContents(); - sheet.appendRow(SEED_HEADERS); - SEED_ROWS.forEach(r => sheet.appendRow(r)); - Logger.log(`event_calendar seed 완료: ${SEED_ROWS.length}건`); - } else { - Logger.log(`event_calendar seed skip: 기존 데이터 ${dataRowCount}건 보존`); - } -} - -// event_calendar 탭을 읽어 DaysLeft 계산 후 event_risk 탭에 기록 -function runEventRisk() { - const ss = getSpreadsheet_(); - let calSheet = ss.getSheetByName("event_calendar"); - - // 탭이 없거나 비어 있으면 seed 실행 - if (!calSheet || calSheet.getLastRow() < 2) { - seedEventCalendar_(); - calSheet = ss.getSheetByName("event_calendar"); - } - - const calData = calSheet.getDataRange().getValues(); - if (!calData || calData.length < 2) { - Logger.log("event_calendar 데이터 없음 — event_risk 업데이트 skip"); - return; - } - - // 헤더 인덱스 매핑 (대소문자 무관) - const calHeaders = calData[0].map(h => String(h).trim().toLowerCase()); - const idxDate = calHeaders.indexOf("date"); - const idxEvent = calHeaders.indexOf("event"); - const idxType = calHeaders.indexOf("type"); - const idxImpact = calHeaders.indexOf("impact"); - const idxAlert = calHeaders.indexOf("alert"); - if (idxDate < 0 || idxEvent < 0) { - Logger.log("event_calendar 헤더 누락 (Date/Event 필수) — seed 재실행 필요"); - return; - } - - const todayStr = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - const todayParts = todayStr.split("-").map(Number); - const todayMs = Date.UTC(todayParts[0], todayParts[1]-1, todayParts[2]); - - const outHeaders = ["Date","DaysLeft","Event","Type","Impact","Alert","AsOfDate"]; - const rows = []; - for (let i = 1; i < calData.length; i++) { - const row = calData[i]; - const rawDate = row[idxDate]; - if (!rawDate || String(rawDate).trim() === "") continue; - // Date 셀이 Date 객체이거나 "YYYY-MM-DD" 문자열 모두 지원 - let dateStr; - if (rawDate instanceof Date) { - dateStr = Utilities.formatDate(rawDate, "Asia/Seoul", "yyyy-MM-dd"); - } else { - dateStr = String(rawDate).trim(); - } - if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue; - const ep = dateStr.split("-").map(Number); - const eventMs = Date.UTC(ep[0], ep[1]-1, ep[2]); - const daysLeft = Math.round((eventMs - todayMs) / (1000*60*60*24)); - if (daysLeft < -3) continue; // 3일 이전 경과 이벤트 제외 - rows.push([ - dateStr, - daysLeft, - idxEvent >= 0 ? row[idxEvent] : "", - idxType >= 0 ? row[idxType] : "", - idxImpact >= 0 ? row[idxImpact] : "", - idxAlert >= 0 ? row[idxAlert] : "", - todayStr - ]); - } - rows.sort((a, b) => a[1] - b[1]); - - writeToSheet("event_risk", outHeaders, rows); - Logger.log(`event_risk 완료: ${rows.length}건 (event_calendar 탭에서 읽음)`); - - // 매달 1일 실행 시 월별 자산 스냅샷 기록 (asset_history 탭) - const dayOfMonth = parseInt(Utilities.formatDate(new Date(), "Asia/Seoul", "d"), 10); - if (dayOfMonth === 1) runMonthlySnapshot(); - - // 하위 단계 연쇄는 개별 실행에서만 수행한다. run_all()에서는 최종 오케스트레이터가 한 번만 처리한다. - if (!isRunAllOrchestrated_()) { - runHarnessRefresh_(); - cacheAllViews(); - } -} - -function runHarnessRefresh_() { - if (typeof buildHarnessContext_ !== "function") { - Logger.log("[HARNESS] buildHarnessContext_ missing - integrated code 손상 여부 확인 필요"); - return; - } - try { - buildHarnessContext_(); - Logger.log("[HARNESS] buildHarnessContext_ completed"); - } catch (e) { - var msg = (e && e.message) ? e.message : String(e); - var stack = (e && e.stack) ? String(e.stack) : 'NO_STACK'; - Logger.log("[HARNESS][ERROR] runHarnessRefresh_ message=" + msg); - Logger.log("[HARNESS][ERROR] runHarnessRefresh_ stack=" + stack); - handleFetchError_("runHarnessRefresh_", e, "CRITICAL"); - } -} - -// ── All-in-one orchestration ──────────────────────────────────────────────── -// 원하는 최종 결과를 한 번에 갱신하는 진입점. -// 순서: -// 1) data_feed -// 2) sector_flow -> macro -// 3) core_satellite -// 4) event_risk -// 5) harness 재생성 -// 6) cache 재생성 -var __RUN_ALL_ORCHESTRATED__ = false; - -function isRunAllOrchestrated_() { - return __RUN_ALL_ORCHESTRATED__ === true; -} - -function setRunAllOrchestrated_(value) { - __RUN_ALL_ORCHESTRATED__ = value === true; -} - -function clearRunAllState_() { - const props = PropertiesService.getScriptProperties(); - props.deleteProperty("run_all_step"); - props.deleteProperty("run_all_start_time"); - if (typeof clearFetchCache === "function") { - try { - clearFetchCache(); - } catch (e) { - Logger.log("[RUN_ALL] clearFetchCache failed: " + e.message); - } - } -} - -function run_all() { - const props = PropertiesService.getScriptProperties(); - const runAllInvocationMode = String(props.getProperty("run_all_invocation_mode") || "external_scheduler"); - const invocationStartTime = new Date().getTime(); - - clearRunAllState_(); - if (typeof beginFetchSession_ === "function") { - try { - beginFetchSession_("run_all"); - } catch (e) { - Logger.log("[RUN_ALL] Failed to auto begin fetch session: " + e.message); - } - } - - Logger.log("[RUN_ALL] invocation_mode=" + runAllInvocationMode); - - const steps = [ - { - name: "runDaily (Calendar Scraping)", - fn: function() { - if (typeof runDaily === "function") { - try { - runDaily(); - } catch(e) { - Logger.log("[WARN] runDaily 실행 중 일부 단계 실패 (단, 스크래핑 및 정렬은 시도됨): " + e.message); - } - } else { - Logger.log("[WARN] runDaily 함수가 정의되어 있지 않아 캘린더 스크래핑을 건너뜁니다."); - } - } - }, - { name: "runSectorFlow", fn: runSectorFlow }, - { name: "runDataFeed", fn: runDataFeed }, - { name: "runCoreSatelliteFlow_", fn: runCoreSatelliteFlow_ }, - { name: "runEventRisk", fn: runEventRisk }, - { name: "runHarnessRefresh_", fn: runHarnessRefresh_ }, - { - name: "runRebalanceSheet_", - fn: function() { - if (typeof runRebalanceSheet_ === "function") { - runRebalanceSheet_(); - } else { - Logger.log("[WARN] runRebalanceSheet_ 함수가 정의되어 있지 않아 건너뜁니다. gdf_06_rebalance.gs 배포 여부 확인."); - } - } - }, - ]; - - Logger.log("[RUN_ALL] start"); - setRunAllOrchestrated_(true); - try { - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - - const elapsedBefore = (new Date().getTime() - invocationStartTime) / 1000; - if (elapsedBefore > 240) { - Logger.log("[RUN_ALL] 단계 [" + step.name + "] 시작 전 실행 한도 도달 직전 종료 (경과: " + elapsedBefore.toFixed(1) + "초)."); - return; - } - - try { - Logger.log("[RUN_ALL] step=" + step.name + " start"); - step.fn(); - Logger.log("[RUN_ALL] step=" + step.name + " done"); - } catch (e) { - if (e.message === "PARTIAL_SAVE_REQUESTED") { - Logger.log("[RUN_ALL] step=" + step.name + " partial save 요청 수신."); - return; - } - Logger.log("[RUN_ALL][ERROR] step=" + step.name + " message=" + ((e && e.message) ? e.message : String(e))); - handleFetchError_("run_all:" + step.name, e, "CRITICAL"); - throw e; - } - } - - scheduleCacheAllViews_(); - - // 완료 시 Properties 정리 및 예약 트리거 청소 - props.deleteProperty("run_all_invocation_mode"); - - ScriptApp.getProjectTriggers() - .filter(t => t.getHandlerFunction() === "run_all") - .forEach(t => ScriptApp.deleteTrigger(t)); - - } finally { - setRunAllOrchestrated_(false); - } - Logger.log("[RUN_ALL] done"); -} - -function scheduleCacheAllViews_() { - ScriptApp.getProjectTriggers() - .filter(t => t.getHandlerFunction() === "cacheAllViews") - .forEach(t => ScriptApp.deleteTrigger(t)); - ScriptApp.newTrigger("cacheAllViews").timeBased().after(60 * 1000).create(); - Logger.log("[RUN_ALL] step=cacheAllViews scheduled (1min trigger)"); -} - -function runCoreSatelliteFlow_() { - const props = PropertiesService.getScriptProperties(); - const universe = getCoreSatelliteUniverse(); - const totalChunks = Math.max(1, Math.ceil(universe.length / CHUNK_SIZE)); - const startTime = new Date().getTime(); - - for (let i = 0; i < totalChunks; i++) { - let chunkIdx = parseInt(props.getProperty("cs_chunk_idx") ?? "0", 10); - if (chunkIdx >= totalChunks) { - break; - } - - const elapsed = (new Date().getTime() - startTime) / 1000; - if (elapsed > 120) { - Logger.log("[RUN_ALL] core_satellite 청크 " + chunkIdx + " 실행 전 한도 도달 직전 종료 (경과: " + elapsed.toFixed(1) + "초)."); - throw new Error("PARTIAL_SAVE_REQUESTED"); - } - - runCoreSatelliteBatch(); - const statusRaw = props.getProperty("cs_status") || "{}"; - let status = {}; - try { - status = JSON.parse(statusRaw); - } catch (e) { - status = {}; - } - const state = String(status.status || "").toUpperCase(); - if (state === "COMPLETE" || state === "FINALIZED") { - break; - } - } -} - -// ── JSON 캐시 업데이트 ──────────────────────────────────────────────────────── -// 매일 runEventRisk() 완료 후 호출. doGet()이 Sheets를 다시 읽지 않고 -// CacheService 캐시만 반환하므로 응답 시간이 2~8s → <300ms로 단축됨. -function cacheAllViews() { - // one-shot 트리거로 실행된 경우 자신을 삭제 (누적 방지) - ScriptApp.getProjectTriggers() - .filter(t => t.getHandlerFunction() === "cacheAllViews") - .forEach(t => ScriptApp.deleteTrigger(t)); - - const cache = CacheService.getScriptCache(); - const generatedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST"; - const TTL = 3600; // 1시간 - const MAX_CACHE_BYTES = 95 * 1024; // CacheService 실효 한계(100KB) 대비 여유 - - const sellPriorityView = runSellPriority(); - const views = { - health: getHealthJson_(), - meta: getWorkbookMetaJson_(), - data_feed: getDataFeedJson(), - // backdata_feature_bank는 누적 운영으로 대용량이므로 캐시 제외 (요청 시 doGet에서 실시간 조회) - backdata_feature_bank_compact: getBackdataFeatureBankJsonCompact(), - portfolio: getPortfolioJson(), - sectors: getSectorFlowJson(), - macro: getMacroJson(), - events: getEventRiskJson(), - orbit_gap: getOrbitGapJson(), - asset_history: getAssetHistoryJson(), - brief: getDailyBrief(sellPriorityView), - sell_priority: sellPriorityView, - }; - - // summary는 위 뷰들을 조합 — 개별 결과 재활용 - const port = views.portfolio; - const sectors = views.sectors; - const macro = views.macro; - const events = views.events; - const orbit = views.orbit_gap; - const holdings = port.holdings; - const totalFrg5 = holdings.reduce((s,h) => s + (parseFloat(h.Frg_5D) || 0), 0); - const totalInst5 = holdings.reduce((s,h) => s + (parseFloat(h.Inst_5D) || 0), 0); - const flowOkCount = holdings.filter(h => h.Flow_OK === "Y").length; - const ss001Dist = { A: 0, B: 0, C: 0, D: 0 }; - const actionDist = {}; - holdings.forEach(h => { - const g = h["SS001_Grade"]; if (g in ss001Dist) ss001Dist[g]++; - const a = h["Allowed_Action"] || "UNKNOWN"; actionDist[a] = (actionDist[a] ?? 0) + 1; - }); - views.summary = { - portfolio_flow_summary: { - total_holdings: holdings.length, - data_ok_count: flowOkCount, - portfolio_frg_5d_total: roundNum(totalFrg5, 0), - portfolio_inst_5d_total: roundNum(totalInst5, 0), - portfolio_indiv_5d_total: roundNum(-(totalFrg5 + totalInst5), 0), - }, - ss001_grade_distribution: ss001Dist, - action_distribution: actionDist, - sector_summary: { - total_sectors: sectors.count, - top_inflow_sectors: sectors.top_inflow, - outflow_warning_sectors: sectors.outflow_warning, - strong_smart_money_sectors:sectors.strong_smart_money, - }, - macro_snapshot: { - vix: macro.vix, - usd_krw: macro.usd_krw, - kospi: macro.kospi, - sp500_5d_ret: macro.sp500_ret5d, - market_regime: macro.market_regime, - mrs_score: macro.mrs_score, - bayesian_multiplier:macro.bayesian_multiplier, - total_heat_pct: macro.total_heat_pct, - fc_budget_pct: macro.fc_budget_pct, - net_return_feedback:macro.net_return_feedback, - orbit_gap_pct: macro.orbit_gap_pct, - orbit_state: macro.orbit_state, - orbit_slot_adj: macro.orbit_slot_adj, - }, - event_alerts: events.upcoming_7d, - holdings_detail: holdings, - sector_detail: sectors.sectors, - macro_computed: macro.computed_summary, - orbit_current: orbit.current, - }; - - // 각 뷰를 CacheService에 저장 (최대 100KB/키) - for (const [view, payload] of Object.entries(views)) { - payload.view = view; - payload.generated_at = generatedAt; - try { - const serialized = JSON.stringify(payload, null, 2); - if (serialized.length > MAX_CACHE_BYTES) { - Logger.log(`캐시 스킵 (${view}): payload too large ${serialized.length} bytes`); - continue; - } - cache.put(`view_${view}`, serialized, TTL); - } catch(e) { - Logger.log(`캐시 저장 실패 (${view}): ${e.message}`); - } - } - Logger.log(`cacheAllViews 완료 (TTL: ${TTL}s)`); -} - -// ──────────────────────────────────────────────────────────────────────────── -// Phase 3: Web App API (doGet) — Custom GPT Action 엔드포인트 -// -// 배포: script.google.com → 배포 → 웹 앱 → 실행 권한: "모든 사용자" -// URL: https://script.google.com/macros/s/{DEPLOYMENT_ID}/exec -// -// Custom GPT에서 ?view=summary 로 호출 → 포트폴리오 분석 JSON 반환 -// ──────────────────────────────────────────────────────────────────────────── -const VIEW_GID_MAP = { - "1835496032": "macro", - "361215520": "events", - "857909836": "sectors", - "1266919040": "data_feed", - "1490216937": "core_satellite", -}; - -function doGet(e) { - const rawView = String(e?.parameter?.view ?? "").trim().toLowerCase(); - const rawGid = String(e?.parameter?.gid ?? "").trim(); - const compactFlag_ = parseCompactFlag_(e?.parameter?.compact); - const view = rawView || VIEW_GID_MAP[rawGid] || "summary"; - - // ① 캐시 우선 반환 — 매일 runEventRisk() 완료 시 cacheAllViews()가 채워 둠 - // 캐시 HIT: <300ms, 캐시 MISS(만료·첫 호출): Sheets 직접 읽기(2~5s) - const cache = CacheService.getScriptCache(); - const cached = cache.get(`view_${view}`); - if (cached) { - return ContentService - .createTextOutput(cached) - .setMimeType(ContentService.MimeType.JSON); - } - - // ② 캐시 MISS → Sheets에서 직접 읽어 반환 (기존 동작 유지) - let payload; - try { - switch(view) { - case "health": payload = getHealthJson_(); break; - case "meta": payload = getWorkbookMetaJson_(); break; - case "all": payload = getAllJson_(compactFlag_); break; - case "raw_all": payload = getRawAllJson_(compactFlag_); break; - case "data_feed": payload = getDataFeedJson(); break; - case "backdata_feature_bank": payload = compactFlag_ ? getBackdataFeatureBankJsonCompact() : getBackdataFeatureBankJson(); break; - case "backdata_feature_bank_compact": payload = getBackdataFeatureBankJsonCompact(); break; - case "sectors": payload = getSectorFlowJson(); break; - case "portfolio": payload = getPortfolioJson(); break; - case "core_satellite": payload = getCoreSatelliteJson(compactFlag_); break; - case "macro": payload = getMacroJson(); break; - case "events": payload = getEventRiskJson(); break; - case "orbit_gap": payload = getOrbitGapJson(); break; - case "brief": payload = getDailyBrief(null); break; - case "sell_priority": payload = runSellPriority(); break; - case "asset_history": payload = getAssetHistoryJson(); break; - case "source_health": payload = checkDataSourceHealth(); break; - case "trade_template": - payload = getTradeTemplate(String(e?.parameter?.ticker ?? "").trim()); break; - case "init_account_snapshot": - payload = initAccountSnapshotTemplate_(); break; - case "summary": - default: payload = getSummaryJson(); break; - } - payload.view = view; - payload.generated_at = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST"; - } catch(err) { - payload = { error: err.message, view }; - } - - return ContentService - .createTextOutput(JSON.stringify(payload, null, 2)) - .setMimeType(ContentService.MimeType.JSON); -} - -// ── Sheets → JSON 변환 헬퍼 ─────────────────────────────────────────────── -function parseCompactFlag_(value) { - const raw = String(value ?? "").trim().toLowerCase(); - return raw === "1" || raw === "true" || raw === "yes" || raw === "y"; -} - -function getHealthJson_() { - return { - status: "OK", - mode: "health", - app: "gas_data_feed", - schema_version: SCHEMA_VERSION, - spreadsheet_id: SPREADSHEET_ID, - timezone: "Asia/Seoul", - available_views: ["health","summary","brief","data_feed","backdata_feature_bank","backdata_feature_bank_compact","core_satellite","sell_priority","macro","events","sectors","portfolio","orbit_gap","asset_history","trade_template","all","raw_all"], - transport_policy: { - canonical_transport: "HTTP GET", - canonical_client: "Invoke-WebRequest / curl / script fetch", - direct_open: "may be blocked by session policy", - }, - }; -} - -function getWorkbookMetaJson_() { - const ss = getSpreadsheet_(); - const sheets = ss.getSheets().map(sheet => { - const data = sheet.getDataRange().getValues(); - const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim(); - const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null; - const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : []; - const rowCount = data.length >= 3 ? data.slice(2).filter(r => r.some(c => c !== "")).length : 0; - return { - sheet: sheet.getName(), - gid: sheet.getSheetId(), - hidden: sheet.isSheetHidden(), - updated_at: updatedAt, - count: rowCount, - header_count: headers.length, - }; - }); - return { - mode: "meta", - schema_version: SCHEMA_VERSION, - sheet_count: sheets.length, - sheets, - }; -} - -function getSheetEnvelopeJson_(sheetName, gid, options) { - const compact = Boolean(options?.compact); - const maxRows = Number.isFinite(Number(options?.maxRows)) ? Math.max(0, Number(options.maxRows)) : null; - const ss = getSpreadsheet_(); - const sheet = ss.getSheetByName(sheetName); - if (!sheet) { - return { - sheet: sheetName, - gid: gid ?? null, - schema_version: SCHEMA_VERSION, - updated_at: null, - count: 0, - headers: [], - rows: [], - compact: false, - truncated: false, - }; - } - - const data = sheet.getDataRange().getValues(); - const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim(); - const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null; - const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : []; - const rowsFull = sheetToJson(sheetName); - const rows = compact && Number.isFinite(maxRows) ? rowsFull.slice(0, maxRows) : rowsFull; - - return { - sheet: sheetName, - gid: gid ?? null, - schema_version: SCHEMA_VERSION, - updated_at: updatedAt, - count: rowsFull.length, - headers, - rows, - compact, - truncated: rows.length < rowsFull.length, - }; -} - -function sheetToJson(sheetName) { - const ss = getSpreadsheet_(); - const sheet = ss.getSheetByName(sheetName); - if (!sheet) return []; - const data = sheet.getDataRange().getValues(); - // row[0] = updated 메타, row[1] = 헤더, row[2..] = 데이터 - if (data.length < 3) return []; - const headers = data[1].map(h => String(h).trim()); - // 날짜 컬럼 식별 (AsOfDate, Updated_At, Date, Price_Date) - const dateCols = new Set(["AsOfDate","Updated_At","Date","Price_Date"]); - return data.slice(2).filter(r => r.some(c => c !== "")).map(r => { - const obj = {}; - headers.forEach((h, i) => { - const v = r[i]; - // Date 객체 → "yyyy-MM-dd" 문자열로 직렬화 - if (v instanceof Date && !isNaN(v)) { - obj[h] = Utilities.formatDate(v, "Asia/Seoul", "yyyy-MM-dd"); - } else { - obj[h] = v; - } - }); - return obj; - }); -} - -function getSectorFlowJson() { - const sectors = sheetToJson("sector_flow"); - return { - sectors, - top_inflow: sectors.filter(s => s.Alert_Level === "INFLOW_STRONG").map(s => s.Sector), - outflow_warning: sectors.filter(s => ["OUTFLOW_ALERT","OUTFLOW_CAUTION"].includes(s.Alert_Level)).map(s => s.Sector), - strong_smart_money: sectors.filter(s => s.Smart_Money === "STRONG").map(s => s.Sector), - count: sectors.length - }; -} - -function getPortfolioJson() { - const holdings = sheetToJson("data_feed"); - return { holdings, count: holdings.length }; -} - -function getDataFeedJson() { - return getSheetEnvelopeJson_("data_feed", 1266919040, { compact: false }); -} - -function getBackdataFeatureBankJson() { - return getSheetEnvelopeJson_("backdata_feature_bank", null, { compact: false }); -} - -function getBackdataFeatureBankJsonCompact() { - return getSheetEnvelopeJson_("backdata_feature_bank", null, { compact: true, maxRows: 50 }); -} - -function getCoreSatelliteJson(compact) { - return getSheetEnvelopeJson_("core_satellite", 1490216937, { - compact: Boolean(compact), - maxRows: compact ? 20 : null, - }); -} - -function getAllJson_(compact) { - return { - data_feed: getDataFeedJson(), - backdata_feature_bank: getBackdataFeatureBankJson(), - core_satellite: getCoreSatelliteJson(compact), - sector_flow: getSectorFlowJson(), - macro: getMacroJson(), - event_risk: getEventRiskJson(), - summary: getSummaryJson(), - }; -} - -function getRawAllJson_(compact) { - const ss = getSpreadsheet_(); - const sheets = ss.getSheets(); - const maxRows = compact ? 20 : null; - const payloadSheets = sheets.map(sheet => { - const name = sheet.getName(); - const gid = sheet.getSheetId(); - const data = sheet.getDataRange().getValues(); - const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim(); - const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null; - const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : []; - const rowsFull = data.length >= 3 ? data.slice(2).filter(r => r.some(c => c !== "")).map(r => { - const obj = {}; - headers.forEach((h, i) => { - const v = r[i]; - if (v instanceof Date && !isNaN(v)) { - obj[h] = Utilities.formatDate(v, "Asia/Seoul", "yyyy-MM-dd"); - } else { - obj[h] = v; - } - }); - return obj; - }) : []; - const rows = compact && Number.isFinite(maxRows) ? rowsFull.slice(0, maxRows) : rowsFull; - return { - sheet: name, - gid, - sheet_id: gid, - hidden: sheet.isSheetHidden(), - updated_at: updatedAt, - count: rowsFull.length, - headers, - rows, - compact: Boolean(compact), - truncated: rows.length < rowsFull.length, - }; - }); - - return { - mode: "raw_all", - schema_version: SCHEMA_VERSION, - sheet_count: payloadSheets.length, - compact: Boolean(compact), - sheets: payloadSheets, - }; -} - -// 숫자 배열의 중앙값 (양수만, 빈 배열이면 null) -function calcMedian_(arr) { - const nums = arr.filter(v => Number.isFinite(v) && v > 0); - if (!nums.length) return null; - nums.sort((a, b) => a - b); - const mid = Math.floor(nums.length / 2); - return nums.length % 2 === 0 ? (nums[mid - 1] + nums[mid]) / 2 : nums[mid]; -} - -// float32 → float64 노이즈 제거: 숫자 값을 소수점 4자리로 정리 -function roundNum(v, digits) { - if (typeof v !== "number" || isNaN(v)) return v; - return parseFloat(v.toFixed(digits ?? 4)); -} - -function getMacroJson() { - const macro = sheetToJson("macro").map(m => ({ - ...m, - Close: roundNum(m.Close, 4), - Ret1D: roundNum(m.Ret1D, 2), - Ret5D: roundNum(m.Ret5D, 2), - Ret20D: roundNum(m.Ret20D, 2), - })); - const byName = {}; - macro.forEach(m => { byName[m.Name] = m; }); - // MRS 요약 추출 - const mrsRow = byName["Market_Risk_Score"] ?? {}; - const regimeRow = byName["Market_Regime_Prelim"] ?? {}; - const bayesRow = byName["Bayesian_Multiplier"] ?? {}; - const heatRow = byName["Total_Heat_Pct"] ?? {}; - const fcRow = byName["FC_Loss_Budget_Monthly"] ?? {}; - const netRFRow = byName["Net_Return_Feedback"] ?? {}; - const orbitGapRow = byName["Orbit_Gap_Pct"] ?? {}; - const orbitStRow = byName["Orbit_State"] ?? {}; - const bucketRow = byName["Bucket_Allocation_Status"] ?? {}; - return { - indicators: macro.filter(m => m.Category !== "Computed"), - computed_summary: macro.filter(m => m.Category === "Computed"), - vix: roundNum(byName["VIX"]?.Close, 2) ?? "N/A", - usd_krw: roundNum(byName["USD_KRW"]?.Close, 2) ?? "N/A", - kospi: roundNum(byName["KOSPI"]?.Close, 2) ?? "N/A", - kospi_ma20: roundNum(byName["KOSPI"]?.MA20, 2) ?? "N/A", - kospi_ma60: roundNum(byName["KOSPI"]?.MA60, 2) ?? "N/A", - usd_jpy_ret2d: roundNum(byName["USD_JPY"]?.Ret2D, 2) ?? "N/A", - hyg_ret5d: roundNum(byName["HYG_HY_Bond"]?.Ret5D, 2) ?? "N/A", - sp500_ret5d: roundNum(byName["SP500"]?.Ret5D, 2) ?? "N/A", - mrs_score: mrsRow.Close ?? "N/A", - mrs_status: mrsRow.Status ?? "N/A", - market_regime: regimeRow.Close ?? "N/A", - credit_stress: String(regimeRow.Status ?? "").replace("credit_stress=", "") || "N/A", - bayesian_multiplier: bayesRow.Close ?? "N/A", - bayesian_label: bayesRow.Status ?? "N/A", - // trades=0 이면 performance 탭 데이터 없는 기본값; 1건 이상이면 실제 거래 기반 - bayesian_data_source: (String(bayesRow.Status ?? "").match(/trades=(\d+)/)?.[1] ?? "0") !== "0" ? "actual" : "default", - total_heat_pct: heatRow.Close ?? "N/A", - total_heat_gate: heatRow.Status ?? "N/A", - fc_budget_pct: fcRow.Close ?? "N/A", - fc_budget_status: fcRow.Status ?? "N/A", - net_return_feedback: netRFRow.Close ?? "N/A", - net_return_detail: netRFRow.Status ?? "N/A", - orbit_gap_pct: orbitGapRow.Close ?? "N/A", - orbit_gap_detail: orbitGapRow.Status ?? "N/A", - orbit_state: orbitStRow.Close ?? "N/A", - orbit_slot_adj: String(orbitStRow.Status ?? "").match(/slot_adj=(-?\d+)/)?.[1] ?? "N/A", - orbit_cash_adj: String(orbitStRow.Status ?? "").match(/cash_adj=(-?\d+)/)?.[1] ?? "N/A", - bucket_status: bucketRow.Close ?? "N/A", - bucket_detail: bucketRow.Status ?? "N/A", - }; -} - -function getEventRiskJson() { - const events = sheetToJson("event_risk"); - const urgent = events.filter(e => +e.DaysLeft >= 0 && +e.DaysLeft <= 7); - return { events, upcoming_7d: urgent }; -} - -function getOrbitGapJson() { - const history = sheetToJson("monthly_history"); - if (!history.length) return { history: [], current: null }; - const latest = history[history.length - 1]; - return { - history, - current: { - month: latest.Month, - orbit_gap_pct: latest.Orbit_Gap_Pct, - orbit_state: latest.Orbit_State, - offensive_slot_adj: latest.Slot_Adj, - cash_floor_adj: latest.Cash_Floor_Adj, - target_return_pct: latest.Target_Return_Pct, - actual_return_pct: latest.Actual_Return_Pct, - }, - }; -} - -// ── [2026-05-21_AFL_V1] ALPHA_FEEDBACK_LOOP_V1 -- monthly grade analysis ──────── -function runAlphaFeedbackLoop_() { - var ss = getSpreadsheet_(); - var sheet = ss.getSheetByName("alpha_history"); - var today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - var monthKey = today.substring(0, 7); - var defaultPayload = { - formula_id: 'ALPHA_FEEDBACK_LOOP_V1', - as_of: today, - analysis_period: monthKey, - status: 'DATA_MISSING', - cases_analyzed: 0, - grade_count: 0, - eligible_t20_fail_rate: null, - eligible_t60_fail_rate: null, - recommended_filter_adjustments: [], - grade_summary: [] - }; - if (!sheet) { - writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(defaultPayload)); - Logger.log("[AFL] alpha_history sheet not found"); - return defaultPayload; - } - var data = sheet.getDataRange().getValues(); - if (data.length < 2) { - writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(defaultPayload)); - Logger.log("[AFL] alpha_history has no data"); - return defaultPayload; - } - - var hdrRow = data[0]; - var hdrMap = {}; - hdrRow.forEach(function(h, i) { hdrMap[h] = i; }); - - var gradeStats = {}; - var analyzedCases = 0; - for (var i = 1; i < data.length; i++) { - var row = data[i]; - var grade = String(row[hdrMap['SAQG_Grade_At_Entry']] || '').trim(); - var t20g = String(row[hdrMap['T20_Alpha_Gate']] || '').trim(); - var t60g = String(row[hdrMap['T60_Alpha_Gate']] || '').trim(); - if (!grade) continue; - if (!gradeStats[grade]) gradeStats[grade] = { t20_total: 0, t20_pass: 0, t60_total: 0, t60_pass: 0 }; - var s = gradeStats[grade]; - var skipVals = { 'NOT_YET': 1, 'EXEMPT': 1, 'DATA_MISSING': 1, '': 1 }; - var hasT20 = t20g && !skipVals[t20g]; - var hasT60 = t60g && !skipVals[t60g]; - if (hasT20) { s.t20_total++; if (t20g === 'T20_ALPHA_PASS') s.t20_pass++; } - if (hasT60) { s.t60_total++; if (t60g === 'T60_ALPHA_PASS') s.t60_pass++; } - if (hasT20 || hasT60) analyzedCases++; - } - - var gradeSummary = []; - Object.keys(gradeStats).sort().forEach(function(grade) { - var s = gradeStats[grade]; - var t20FailRate = s.t20_total > 0 ? parseFloat((((s.t20_total - s.t20_pass) / s.t20_total) * 100).toFixed(2)) : null; - var t60FailRate = s.t60_total > 0 ? parseFloat((((s.t60_total - s.t60_pass) / s.t60_total) * 100).toFixed(2)) : null; - var t20PassRate = s.t20_total > 0 ? parseFloat(((s.t20_pass / s.t20_total) * 100).toFixed(2)) : null; - var t60PassRate = s.t60_total > 0 ? parseFloat(((s.t60_pass / s.t60_total) * 100).toFixed(2)) : null; - gradeSummary.push({ - grade: grade, - t20_total: s.t20_total, - t20_pass: s.t20_pass, - t20_pass_rate: t20PassRate, - t20_fail_rate: t20FailRate, - t60_total: s.t60_total, - t60_pass: s.t60_pass, - t60_pass_rate: t60PassRate, - t60_fail_rate: t60FailRate, - status: (s.t20_total >= 10 || s.t60_total >= 10) ? 'ANALYZED' : 'DATA_INSUFFICIENT' - }); - }); - - var eligibleRow = gradeStats['ELIGIBLE'] || { t20_total: 0, t20_pass: 0, t60_total: 0, t60_pass: 0 }; - var eligibleT20FailRate = eligibleRow.t20_total > 0 - ? parseFloat((((eligibleRow.t20_total - eligibleRow.t20_pass) / eligibleRow.t20_total) * 100).toFixed(2)) - : null; - var eligibleT60FailRate = eligibleRow.t60_total > 0 - ? parseFloat((((eligibleRow.t60_total - eligibleRow.t60_pass) / eligibleRow.t60_total) * 100).toFixed(2)) - : null; - var eligibleT20PassRate = eligibleRow.t20_total > 0 - ? parseFloat(((eligibleRow.t20_pass / eligibleRow.t20_total) * 100).toFixed(2)) - : null; - - var recommendations = []; - if (analyzedCases >= 10) { - if (eligibleT20FailRate !== null && eligibleT20FailRate > 50) { - recommendations.push({ - filter_id: 'SAQG_F2_RECOVERY_RATIO', - current: '1.20', - recommended: '1.35', - rationale: 'ELIGIBLE T+20 fail rate > 50%', - action: 'TIGHTEN' - }); - recommendations.push({ - filter_id: 'SAQG_F3_EXCESS_DRAWDOWN', - current: '5%p', - recommended: '4%p', - rationale: 'ELIGIBLE T+20 fail rate > 50%', - action: 'TIGHTEN' - }); - } else if (eligibleT20PassRate !== null && eligibleT20PassRate > 70 && eligibleRow.t20_total >= 12) { - recommendations.push({ - filter_id: 'SAQG_F3_EXCESS_DRAWDOWN', - current: '5%p', - recommended: '7%p', - rationale: 'ELIGIBLE T+20 success rate > 70% and cases >= 12', - action: 'RELAX_REVIEW' - }); - } else { - recommendations.push({ - filter_id: 'SAQG_F1_F2_F3', - current: 'UNCHANGED', - recommended: 'HOLD', - rationale: 'No threshold change supported by current sample', - action: 'HOLD' - }); - } - } - - var payload = { - formula_id: 'ALPHA_FEEDBACK_LOOP_V1', - as_of: today, - analysis_period: monthKey, - status: analyzedCases >= 10 ? 'ANALYZED' : 'DATA_INSUFFICIENT', - cases_analyzed: analyzedCases, - grade_count: Object.keys(gradeStats).length, - eligible_t20_fail_rate: eligibleT20FailRate, - eligible_t60_fail_rate: eligibleT60FailRate, - recommended_filter_adjustments: analyzedCases >= 10 ? recommendations : [], - grade_summary: gradeSummary - }; - writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(payload)); - Logger.log('[AFL] done - ' + payload.grade_count + ' grades analyzed, cases=' + analyzedCases); - return payload; -} - -// ── E2: 월말 자산 스냅샷 → monthly_history 기록 ───────────────────────────── -// 트리거: 매달 마지막 영업일 16:30 독립 실행 OR runDataFeed 완료 후 호출. -function runMonthlySnapshot() { - const settings = readSettingsTab_(); - const totalAsset = parseFloat(settings["total_asset_krw"]); - if (!Number.isFinite(totalAsset) || totalAsset <= 0) { - Logger.log("runMonthlySnapshot 스킵: total_asset_krw 미설정"); - return; - } - const month = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM"); - - // macro에서 버킷·orbit 읽기 - const macro = getMacroJson(); - const bDetail = String(macro.bucket_detail ?? ""); - const corePct = parseFloat(bDetail.match(/core=([\d.]+)%/)?.[1] ?? "") || ""; - const satPct = parseFloat(bDetail.match(/sat=([\d.]+)%/)?.[1] ?? "") || ""; - const cashPct = parseFloat(bDetail.match(/cash=([\d.]+)%/)?.[1] ?? "") || ""; - const orbitGap = macro.orbit_gap_pct !== "N/A" ? macro.orbit_gap_pct : ""; - const orbitState = macro.orbit_state !== "N/A" ? macro.orbit_state : ""; - - // MoM/YTD: monthly_history에서 이전 자산 읽기 - const ss = getSpreadsheet_(); - const histSheet = ss.getSheetByName("monthly_history"); - let prevAsset = null, jan1Asset = null; - const thisYear = month.substring(0, 4); - if (histSheet) { - const hd = histSheet.getDataRange().getValues(); - const hdr = hd[0] ?? []; - const mIdx = hdr.indexOf("Month"); - const aIdx = hdr.indexOf("Total_Asset"); - if (mIdx >= 0 && aIdx >= 0) { - for (let i = 1; i < hd.length; i++) { - const raw = hd[i][mIdx]; - const mStr = raw instanceof Date && !isNaN(raw.getTime()) - ? Utilities.formatDate(raw, "Asia/Seoul", "yyyy-MM") - : String(raw ?? "").trim().substring(0, 7); - if (mStr === month) continue; - const a = parseFloat(hd[i][aIdx]); - if (mStr && Number.isFinite(a)) { - prevAsset = a; - if (mStr === `${thisYear}-01`) jan1Asset = a; - } - } - } - } - - const momRet = (prevAsset && prevAsset > 0) - ? parseFloat(((totalAsset / prevAsset - 1) * 100).toFixed(2)) : ""; - const ytdRet = (jan1Asset && jan1Asset > 0) - ? parseFloat(((totalAsset / jan1Asset - 1) * 100).toFixed(2)) : ""; - - // AEW aggregate: T+20/T+60 outcomes this month from alpha_history - var satT20PassN = 0, satT20FailN = 0, satT60PassN = 0; - var satT20AlphaSum = 0, satT20AlphaCount = 0; - var alphaSheet = ss.getSheetByName("alpha_history"); - if (alphaSheet) { - var aData = alphaSheet.getDataRange().getValues(); - if (aData.length > 1) { - var aHdr = aData[0]; - var aMap = {}; - aHdr.forEach(function(h, i) { aMap[String(h)] = i; }); - var skipSet = { 'NOT_YET': 1, 'EXEMPT': 1, 'DATA_MISSING': 1, '': 1 }; - for (var ai = 1; ai < aData.length; ai++) { - var ar = aData[ai]; - var t20cd = String(ar[aMap['T20_Check_Date']] || ''); - if (!t20cd || t20cd.substring(0, 7) !== month) continue; - var t20g = String(ar[aMap['T20_Alpha_Gate']] || ''); - var t60g = String(ar[aMap['T60_Alpha_Gate']] || ''); - var t20v = parseFloat(ar[aMap['T20_Vs_Core_Pctp']]); - if (t20g === 'T20_ALPHA_PASS') satT20PassN++; - else if (t20g === 'T20_ALPHA_FAIL') satT20FailN++; - if (t60g === 'T60_ALPHA_PASS') satT60PassN++; - if (!skipSet[t20g] && Number.isFinite(t20v)) { - satT20AlphaSum += t20v; - satT20AlphaCount++; - } - } - } - } - var satAvgT20Alpha = satT20AlphaCount > 0 - ? parseFloat((satT20AlphaSum / satT20AlphaCount).toFixed(2)) : ''; - - try { - runAlphaFeedbackLoop_(); - } catch (e) { - Logger.log('[AFL] runAlphaFeedbackLoop_ in runMonthlySnapshot error: ' + e.message); - } - - upsertMonthlyRow_(month, { - Total_Asset: totalAsset, - Core_Pct: corePct, - Satellite_Pct: satPct, - Cash_Pct: cashPct, - MoM_Return_Pct: momRet, - YTD_Return_Pct: ytdRet, - Orbit_Gap_Pct: orbitGap, - Orbit_State: orbitState, - Sat_T20_Pass_N: satT20PassN || '', - Sat_T20_Fail_N: satT20FailN || '', - Sat_T60_Pass_N: satT60PassN || '', - Sat_Avg_T20_Alpha_Pct: satAvgT20Alpha, - }); - Logger.log(`monthly_history(snapshot): ${month} asset=${totalAsset.toLocaleString()} MoM=${momRet}% YTD=${ytdRet}%`); -} - -// ── E4: 데이터 소스 정합성 주 1회 헬스체크 ────────────────────────────────── -// 트리거: 주 1회 (매주 월요일 09:00) 독립 실행. -// Naver 가격/수급 스크래핑 패턴 정상 여부를 확인하고 Logger에 리포트를 남긴다. -// doGet(?view=source_health) 로도 조회 가능. -function checkDataSourceHealth() { - const PROBE_TICKER = Object.keys(TICKER_SECTOR_MAP)[0] ?? "005930"; // 첫 번째 종목(기본 삼성전자) - const results = { checked_at: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm"), probe_ticker: PROBE_TICKER, checks: [] }; - - const ok = (name, detail) => { results.checks.push({ name, status: "OK", detail: detail ?? "" }); }; - const fail = (name, detail) => { results.checks.push({ name, status: "FAIL", detail: detail ?? "" }); }; - - // 1. Naver 종목 시세 (Close 패턴) - try { - beginFetchSession_(); - const url = `https://finance.naver.com/item/main.nhn?code=${PROBE_TICKER}`; - const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true }); - const html = resp.getContentText("EUC-KR"); - const closeMatch = html.match(/

]*>([\d,]+)<\/p>/i) - || html.match(/현재가\s+([\d,]+)/i); - if (closeMatch) { - const price = parseKrNum_(closeMatch[1]); - price > 0 ? ok("naver_close", `${price.toLocaleString()}원`) : fail("naver_close", "값 0 또는 음수"); - } else { - fail("naver_close", "정규식 미매칭 — DOM 변경 가능성"); - } - // 2. Naver PER 패턴 - const perMatch = html.match(/([\d,.]+)<\/em>/); - perMatch ? ok("naver_per", `PER ${parseKrNum_(perMatch[1])}`) : fail("naver_per", "_per 패턴 미매칭"); - // 3. Naver 52주 고저 패턴 - const highMatch = html.match(/52주\s+최고\s*[:\s]*([\d,]+)/i); - highMatch ? ok("naver_52w", "52주 고저 패턴 정상") : fail("naver_52w", "52주 패턴 미매칭"); - } catch(e) { - fail("naver_fetch", String(e)); - } finally { - endFetchSession_(); - } - - // 4. Naver 수급 탭 패턴 - try { - beginFetchSession_(); - const furl = `https://finance.naver.com/item/frgn.nhn?code=${PROBE_TICKER}`; - const fhtml = UrlFetchApp.fetch(furl, { muteHttpExceptions: true }).getContentText("EUC-KR"); - const trMatch = fhtml.match(/]*class="[^"]*"[^>]*>[\s\S]{0,300}?<\/tr>/g); - trMatch && trMatch.length >= 5 ? ok("naver_flow", `tr행 ${trMatch.length}개`) : fail("naver_flow", "수급 테이블 구조 변경 가능성"); - } catch(e) { - fail("naver_flow_fetch", String(e)); - } finally { - endFetchSession_(); - } - - // 5. Yahoo Finance 패턴 (EPS 성장률) - try { - beginFetchSession_(); - const ysym = normalizeYahooSymbol(PROBE_TICKER); - const yurl = `https://finance.yahoo.com/quote/${ysym}/analysis`; - const yresp = UrlFetchApp.fetch(yurl, { muteHttpExceptions: true }); - yresp.getResponseCode() < 400 ? ok("yahoo_analysis", `HTTP ${yresp.getResponseCode()}`) : fail("yahoo_analysis", `HTTP ${yresp.getResponseCode()}`); - } catch(e) { - fail("yahoo_fetch", String(e)); - } finally { - endFetchSession_(); - } - - const failCount = results.checks.filter(c => c.status === "FAIL").length; - results.overall = failCount === 0 ? "HEALTHY" : failCount <= 1 ? "DEGRADED" : "CRITICAL"; - results.summary = `${results.checks.length}개 체크 중 ${failCount}개 실패 → ${results.overall}`; - Logger.log(`[DataSourceHealth] ${results.summary}`); - results.checks.forEach(c => Logger.log(` [${c.status}] ${c.name}: ${c.detail}`)); - return results; -} - -// ── E2: asset_history JSON 뷰 ──────────────────────────────────────────────── -function getAssetHistoryJson() { - const history = sheetToJson("monthly_history"); - if (!history.length) return { history: [], current: null, mom_series: [] }; - const latest = history[history.length - 1]; - const momSeries = history - .filter(r => r.MoM_Return_Pct !== "" && r.MoM_Return_Pct != null) - .map(r => ({ month: r.Month, mom_ret: r.MoM_Return_Pct, ytd_ret: r.YTD_Return_Pct })); - return { history, current: latest, mom_series: momSeries }; -} - -function readSettings_(ss) { - var result = {}; - var sheet = ss.getSheetByName(SETTINGS_SHEET_NAME); - if (!sheet) return result; - var data = sheet.getDataRange().getValues(); - data.forEach(function(row) { - var key = String(row[0] || '').trim(); - if (key) result[key] = row[1]; - }); - return result; -} - -/** - * settings 시트에서 특정 키의 값을 갱신하거나 신규 추가한다. - * O3 PORTFOLIO_DRAWDOWN_GATE_V1의 portfolio_peak_krw 자동 갱신에 사용. - */ -function writeSettingValue_(ss, key, value) { - var sheet = ss.getSheetByName(SETTINGS_SHEET_NAME); - if (!sheet) return false; - var data = sheet.getDataRange().getValues(); - for (var i = 0; i < data.length; i++) { - if (String(data[i][0] || '').trim() === key) { - sheet.getRange(i + 1, 2).setValue(value); - return true; - } - } - sheet.appendRow([key, value]); - return true; -} - - -// ── 유틸리티 ───────────────────────────────────────────────────────────────── - -/** - * KRX 호가단위 정규화 — floor(raw / tick) * tick - * spec/13_formula_registry.yaml:TICK_NORMALIZER_V1 - */ -function tickNormalize_(rawPrice) { - var tick = getTickSize_(rawPrice); - return Math.floor(rawPrice / tick) * tick; -} - -function getTickSize_(price) { - for (var k = 0; k < TICK_TABLE.length; k++) { - if (price < TICK_TABLE[k].maxPrice) return TICK_TABLE[k].tick; - } - return 1000; // >= 500000원 -} - -function writeHarnessSheet_(ss, rows, now) { - var sheet = ss.getSheetByName(HARNESS_SHEET_NAME); - if (!sheet) { - sheet = ss.insertSheet(HARNESS_SHEET_NAME); - } else { - sheet.clearContents(); - } - sheet.getRange(1, 1).setValue( - HARNESS_SHEET_NAME + ' — GAS computed guard values (HARNESS_AUTHORITATIVE)'); - sheet.getRange(1, 2).setValue(formatIso_(now)); - sheet.getRange(2, 1).setValue('key'); - sheet.getRange(2, 2).setValue('value'); - if (rows.length > 0) { - var MAX_CELL = 49000; - var safeRows = rows.map(function(r) { - var v = r[1]; - if (typeof v === 'string' && v.length > MAX_CELL) { - Logger.log('[HARNESS] CELL_OVERSIZED key=' + r[0] + ' len=' + v.length + ' → trimmed placeholder'); - return [r[0], JSON.stringify({ status: 'OVERSIZED', original_len: v.length, key: String(r[0]) })]; - } - return r; - }); - sheet.getRange(3, 1, safeRows.length, 2).setValues(safeRows); - } -} - -function buildColIdx_(headers) { - var idx = {}; - headers.forEach(function(h, i) { - var key = String(h || '').trim(); - if (key) idx[key] = i; - }); - return idx; -} - -/** row[c[colName]] 숫자 읽기 — 컬럼 없거나 NaN이면 0 */ -function numCol_(row, c, colName) { - return c[colName] !== undefined ? toNumber_(row[c[colName]]) : 0; -} - -/** row[c[colName]] 문자열 읽기 — 컬럼 없으면 '' */ -function strCol_(row, c, colName) { - return c[colName] !== undefined ? String(row[c[colName]] || '').trim() : ''; -} - -/** - * ticker 정규화 — 숫자 코드는 6자리 zero-pad - * convert_xlsx_to_json.py:normalize_code 와 동일 로직 - */ -function normTicker_(raw) { - var s = String(raw || '').trim(); - if (!s) return ''; - if (s.slice(-2) === '.0') s = s.slice(0, -2); - var digits = s.replace('.', ''); - if (/^\d+$/.test(digits) && digits.length <= 6) { - var n = parseInt(digits, 10); - var ns = String(n); - while (ns.length < 6) ns = '0' + ns; - return ns; - } - return s; -} - -/** Array.prototype.indexOf 폴리필 래퍼 (GAS 호환) */ -function indexOfArr_(arr, val) { - for (var k = 0; k < arr.length; k++) { - if (arr[k] === val) return k; - } - return -1; -} - -function toNumber_(v) { - if (v === null || v === undefined || v === '') return 0; - var n = Number(v); - return isNaN(n) ? 0 : n; -} - -function round2_(v) { return Math.round(v * 100) / 100; } - -// ══════════════════════════════════════════════════════════════════════════════ -// Alpha-Shield 선행 레이더 (2026-05-19-X1W1) -// X1: MEAN_REVERSION_GATE_V1 | X3: RS_RATIO_V1 -// W1: DIVERGENCE_SCORE_V1 | W2: OVERHANG_PRESSURE_V1 -// W3: SECTOR_ROTATION_RADAR_V1 | W4: FLOW_ACCELERATION_V1 -// ══════════════════════════════════════════════════════════════════════════════ - -/** - * numColN_ — nullable 버전: 컬럼 없으면 null 반환 (numCol_ 은 0 반환) - * Alpha-Shield 레이더는 0(값 없음)과 0(값=0)을 구분해야 한다. - */ -function numColN_(row, c, colName) { - return c[colName] !== undefined ? toNumber_(row[c[colName]]) : null; -} - -/** - * macro 시트에서 KOSPI 5D 수익률 읽기 - * RS_RATIO_V1 분모: kospi_5d_return - */ -function readKospiRet5d_(ss) { - try { - var macroSheet = ss.getSheetByName('macro'); - if (!macroSheet) return null; - var mData = macroSheet.getDataRange().getValues(); - if (mData.length < 3) return null; - var mHdr = mData[1] || []; - var nameIdx = mHdr.indexOf('Name'); - var r5dIdx = mHdr.indexOf('Ret5D'); - if (nameIdx < 0 || r5dIdx < 0) return null; - for (var i = 2; i < mData.length; i++) { - if (String(mData[i][nameIdx] || '').trim() === 'KOSPI') { - var v = parseFloat(mData[i][r5dIdx]); - return Number.isFinite(v) ? v : null; - } - } - } catch(e) { Logger.log('[HARNESS] readKospiRet5d_ error: ' + e); } - return null; -} - -/** - * macro 시트에서 KOSPI 20D 수익률 읽기 - * 상대 손절 베타 프록시 분모: kospi_20d_return - */ -function readKospiRet20d_(ss) { - try { - var macroSheet = ss.getSheetByName('macro'); - if (!macroSheet) return null; - var mData = macroSheet.getDataRange().getValues(); - if (mData.length < 3) return null; - var mHdr = mData[1] || []; - var nameIdx = mHdr.indexOf('Name'); - var r20dIdx = mHdr.indexOf('Ret20D'); - if (nameIdx < 0 || r20dIdx < 0) return null; - for (var i = 2; i < mData.length; i++) { - if (String(mData[i][nameIdx] || '').trim() === 'KOSPI') { - var v = parseFloat(mData[i][r20dIdx]); - return Number.isFinite(v) ? v : null; - } - } - } catch(e) { Logger.log('[HARNESS] readKospiRet20d_ error: ' + e); } - return null; -} - -/** - * sector_flow 시트에서 W3 레이더용 데이터 읽기 - * 반환: { sector_name → { rank, prevRank, prevRankW2, smart5, smart20 } } - */ -function readSectorFlowForRadar_(ss) { - var result = {}; - try { - var sfSheet = ss.getSheetByName('sector_flow'); - if (!sfSheet) return result; - var sfData = sfSheet.getDataRange().getValues(); - if (sfData.length < 3) return result; - var sfHdr = sfData[1] || []; - var sNameIdx = sfHdr.indexOf('Sector'); - var rankIdx = sfHdr.indexOf('Sector_Rank') >= 0 - ? sfHdr.indexOf('Sector_Rank') : sfHdr.indexOf('Rotation_Rank'); - var prevRkIdx = sfHdr.indexOf('Prev_Rotation_Rank'); - var prevRkW2Idx = sfHdr.indexOf('Prev_Rotation_Rank_W2'); - var sm5Idx = sfHdr.indexOf('SmartMoney_5D_KRW') >= 0 - ? sfHdr.indexOf('SmartMoney_5D_KRW') : sfHdr.indexOf('Frg_5D_SUM'); - var sm20Idx = sfHdr.indexOf('SmartMoney_20D_KRW') >= 0 - ? sfHdr.indexOf('SmartMoney_20D_KRW') : sfHdr.indexOf('Frg_20D_SUM'); - if (sNameIdx < 0) return result; - for (var i = 2; i < sfData.length; i++) { - var sName = String(sfData[i][sNameIdx] || '').trim(); - if (!sName || sName === 'Sector') continue; - result[sName] = { - rank: rankIdx >= 0 ? parseInt(sfData[i][rankIdx]) : null, - prevRank: prevRkIdx >= 0 ? parseInt(sfData[i][prevRkIdx]) : null, - prevRankW2: prevRkW2Idx >= 0 ? parseInt(sfData[i][prevRkW2Idx]) : null, - smart5: sm5Idx >= 0 ? parseFloat(sfData[i][sm5Idx]) : null, - smart20: sm20Idx >= 0 ? parseFloat(sfData[i][sm20Idx]) : null - }; - } - } catch(e) { Logger.log('[HARNESS] readSectorFlowForRadar_ error: ' + e); } - return result; -} - - -function formatIso_(d) { - try { return d instanceof Date ? d.toISOString() : String(d); } - catch (e) { return String(d); } -} - -// ---- TASK-003: RAW_VS_ADJUSTED_DISCLOSURE_V1 ---- -// [GAS_STUB_ONLY: requires Google Sheets deployment] -function formatRawAdjustedPair_(rawVal, adjVal) { - // raw 병기 없는 adjusted 단독 표시 금지 (RC3 수정) - if (rawVal === null || rawVal === undefined) { - return '[RAW_MISSING: adjusted=' + adjVal + ' — raw 없이 adjusted 단독 표시 금지]'; - } - return 'raw ' + rawVal + '% / adj ' + adjVal + '%'; -} diff --git a/gas_report.gs b/gas_report.gs deleted file mode 100644 index 7b5cb14..0000000 --- a/gas_report.gs +++ /dev/null @@ -1,446 +0,0 @@ -// gas_report.gs - Report & template generation -// getDailyBrief, getSummaryJson, getTradeTemplate -// Changes only when report format changes. Rarely touched during engine work. -// GAS global scope: functions in gas_lib.gs / gas_data_feed.gs callable directly - - -// ── E1: 일일 의사결정 브리핑 ───────────────────────────────────────────────── -// 시장 상태·포트폴리오 건강·액션 목록·주의 종목·7일 이벤트를 한 JSON으로 통합. -// doGet(?view=brief) 또는 cacheAllViews()에서 매일 1회 생성. -function getDailyBrief(sellPriorityViewInput) { - const macro = getMacroJson(); - const settings = readSettingsTab_(); - const port = getPortfolioJson(); - const events = getEventRiskJson(); - const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - const holdings = port.holdings ?? []; - - // ── 액션 분류: Final_Action canonical 기준 (A-1/B-1 — Allowed_Action 기반 제거) ── - // Final_Action이 canonical output field. Allowed_Action은 중간 계산값. - const BUY_FINALS_ = new Set(["BUY_STAGE1_READY","BUY_BREAKOUT_PILOT_ONLY","BUY_PULLBACK_WAIT"]); - const SELL_FINALS_ = new Set(["SELL_READY"]); - const EXIT_FINALS_ = new Set(["EXIT_SIGNAL","EXIT_REVIEW"]); - - const sellList = holdings.filter(h => SELL_FINALS_.has(h.Final_Action)); - const exitList = holdings.filter(h => EXIT_FINALS_.has(h.Final_Action)); - const buyList = holdings.filter(h => BUY_FINALS_.has(h.Final_Action)); - const watchList = holdings.filter(h => h.Final_Action === "WATCH_TIMING_SETUP"); - const holdList = holdings.filter(h => - !SELL_FINALS_.has(h.Final_Action) && !EXIT_FINALS_.has(h.Final_Action) && - !BUY_FINALS_.has(h.Final_Action) && h.Final_Action !== "WATCH_TIMING_SETUP" - ); - - // 주의 종목 - const stage2Pass = holdings.filter(h => h.Stage2_Gate === "PASS"); - const timeStopNear= holdings.filter(h => Number.isFinite(+h.Days_To_Time_Stop) - && +h.Days_To_Time_Stop >= 0 - && +h.Days_To_Time_Stop <= 7); - const overweight = holdings.filter(h => h.Band_Status === "OVERWEIGHT"); - const tp1Near = holdings.filter(h => Number.isFinite(+h.Profit_Pct) && +h.Profit_Pct >= 10); - - // 포트폴리오 건강 판단 - const heatVal = parseFloat(macro.total_heat_pct); - const fcVal = parseFloat(macro.fc_budget_pct); - const heatOk = Number.isFinite(heatVal) && heatVal < 10; - const heatCautionB= Number.isFinite(heatVal) && heatVal >= 7 && heatVal < 10; - const heatBlockB = Number.isFinite(heatVal) && heatVal >= 10; - const fcOk = Number.isFinite(fcVal) && fcVal < 100; - const regimeStr = String(macro.market_regime ?? ""); - const isRiskOffB = regimeStr === "RISK_OFF" || regimeStr === "RISK_OFF_CANDIDATE"; - const nrf = macro.net_return_feedback; - const orbitAdj= parseInt(macro.orbit_slot_adj) || 0; - - // account_snapshot freshness 체크 - const acctFresh = checkAccountSnapshotFreshness_(); - - // 텍스트 브리핑 (ChatGPT 직접 복붙용) - const L = []; - const hardBlockWarn = String(settings["cash_floor_hard_block_warning"] ?? "").trim(); - const accountConfirmWarn = String(settings["account_snapshot_confirmation_warning"] ?? "").trim(); - const cashLedgerWarn = String(settings["cash_ledger_warning"] ?? "").trim(); - if (hardBlockWarn) L.push(`[긴급 경고] ${hardBlockWarn}`); - if (accountConfirmWarn) L.push(`[운영 경고] ${accountConfirmWarn}`); - if (cashLedgerWarn) L.push(`[운영 경고] ${cashLedgerWarn}`); - L.push(`[시장] ${macro.market_regime} / MRS ${macro.mrs_score}/10 / VIX ${macro.vix} / KOSPI ${macro.kospi} / USD/KRW ${macro.usd_krw}`); - const heatTag = heatBlockB ? "⚠HF005:BLOCK" : heatCautionB ? "⚠CAUTION:수량50%감액" : "OK"; - L.push(`[포트폴리오] HEAT ${macro.total_heat_pct}%(${heatTag}) / FC ${macro.fc_budget_pct}%(${fcOk?"OK":"⚠EXHAUSTED"}) / ${nrf} / BUCKET ${macro.bucket_status}`); - if (isRiskOffB) L.push(`[⚠ 레짐 차단] ${regimeStr} — 신규 매수 전면 차단, 보유 종목 50% 단계 축소 검토`); - const bayesSourceTag = macro.bayesian_data_source === "actual" ? "실제거래기반" : "기본값(거래이력없음)"; - L.push(`[Bayesian] ${macro.bayesian_label} (${macro.bayesian_multiplier}×) — ${bayesSourceTag}`); - if (acctFresh.fresh === false) L.push(`[⚠ account_snapshot STALE] ${acctFresh.reason} — 손절가·수량 재확인 필요`); - else if (acctFresh.fresh === null) L.push(`[⚠ account_snapshot] ${acctFresh.reason}`); - - // 데이터 신선도 경고 — PRICE_STALE / PRICE_QUOTE_ONLY / FLOW_STALE - const priceStaleList_ = holdings.filter(h => h.Price_Status === "PRICE_STALE"); - const quoteOnlyList_ = holdings.filter(h => h.Price_Status === "PRICE_QUOTE_ONLY"); - const flowStaleList_ = holdings.filter(h => String(h.Missing_Fields ?? "").includes("FLOW_STALE")); - if (priceStaleList_.length) - L.push(`[⚠ 가격 스테일] ${priceStaleList_.map(h => h.Name).join(", ")} — OHLC 날짜 오래됨, runDataFeed 재실행 권장`); - if (quoteOnlyList_.length) - L.push(`[⚠ 호가전용] ${quoteOnlyList_.map(h => h.Name).join(", ")} — OHLC 수집 실패, MA/ATR 결측 → OBSERVE_ONLY 처리`); - if (flowStaleList_.length) - L.push(`[⚠ 수급 스테일] ${flowStaleList_.map(h => h.Name).join(", ")} — 외국인/기관 수급 날짜 오래됨`); - - if (orbitAdj !== 0) - L.push(`[Orbit] ${macro.orbit_state} → 공격슬롯 ${orbitAdj>0?"+":""}${orbitAdj}개 / 현금조정 ${macro.orbit_cash_adj}%p`); - // ── C-1: Final_Action 기준 단일 우선순위 목록 ───────────────────────────── - // 우선순위 순서: SELL_READY > EXIT_* > BUY > WATCH > HOLD - // 같은 그룹 내에서는 Final_Rank(Priority_Score) 오름차순 - const byRank = (arr) => [...arr].sort((a, b) => (+a.Final_Rank || 999) - (+b.Final_Rank || 999)); - - L.push("─".repeat(44)); - L.push(`[오늘 액션] — ${today} (Final_Action 기준, 우선순위 정렬)`); - - if (sellList.length) { - L.push(" ▶ SELL_READY (즉시 HTS 주문 가능)"); - byRank(sellList).forEach((h, i) => { - const r = h.Action_Reason || `${h.Sell_Action} ${h.Sell_Qty}주 @${h.Sell_Limit_Price}`; - const p = h.Action_Params ? `\n ${h.Action_Params}` : ""; - L.push(` ${i+1}. ${h.Name} → ${r}${p}`); - }); - } - if (exitList.length) { - L.push(" ▶ EXIT_SIGNAL / REVIEW (캡처 → ChatGPT 수량 계산 후 매도)"); - byRank(exitList).forEach((h, i) => { - const r = h.Action_Reason || `${h.Final_Action}(RW${h.RW_Partial})`; - const p = h.Action_Params ? ` | ${h.Action_Params}` : ""; - L.push(` ${sellList.length+i+1}. ${h.Name}[${h.Final_Action}] → ${r}${p}`); - }); - } - if (buyList.length) { - L.push(" ▶ BUY (진입 조건 충족)"); - byRank(buyList).forEach((h, i) => { - const constr = h.Pos_Size_Constraint || "미계산*"; - const rank_ = sellList.length + exitList.length + i + 1; - L.push(` ${rank_}. ${h.Name}[${h.Final_Action}] → ${h.Action_Reason || ""}`); - const params_ = h.Action_Params || `목표 ${h.Pos_Size_Qty}주[${constr}]`; - L.push(` ${params_}`); - }); - } - if (watchList.length) { - L.push(" ▶ WATCH (타이밍 대기)"); - byRank(watchList).forEach((h, i) => { - const rank_ = sellList.length + exitList.length + buyList.length + i + 1; - L.push(` ${rank_}. ${h.Name} → ${h.Action_Reason || `SS001:${h.SS001_Grade} 타이밍미충족`}`); - }); - } - if (holdList.length) { - L.push(" ▶ HOLD / BLOCK"); - byRank(holdList).forEach((h, i) => { - const rank_ = sellList.length + exitList.length + buyList.length + watchList.length + i + 1; - L.push(` ${rank_}. ${h.Name}[${h.Allowed_Action}] → ${h.Action_Reason || h.Allowed_Action}`); - }); - } - if (!sellList.length && !exitList.length && !buyList.length && !watchList.length) - L.push(" HOLD — 오늘 액션 없음"); - - // 단일 진실원천: sell_priority는 반드시 runSellPriority() 결과만 사용 - const sellPriorityView_ = sellPriorityViewInput || runSellPriority(); - const _cashRaiseCands_ = Array.isArray(sellPriorityView_.sell_priority_table) - ? sellPriorityView_.sell_priority_table - : []; - - const _cashBelowTgt_ = isRiskOffB || (() => { - const cp = parseFloat(macro.immediate_cash_pct ?? macro.cash_pct ?? ""); - const tp = parseFloat(macro.target_cash_pct ?? settings["weekly_target_cash_pct"] ?? "10"); - return Number.isFinite(cp) && Number.isFinite(tp) && cp < tp; - })(); - - if (_cashBelowTgt_ && _cashRaiseCands_.length) { - L.push("─".repeat(44)); - const gapReason = isRiskOffB - ? `REGIME_TRIM_50 발동(${regimeStr})` - : `현금 부족 → sell_priority_engine`; - L.push(`[현금확보 매도우선순위] — ${gapReason}`); - L.push(" spec: ①하드스탑>②매도신호>③중복ETF>④손실위성>⑥익절>⑨코어주도주(마지막)"); - L.push(" ⚠ 매도수량은 HTS 캡처 제공 후 결정 — 수량 미제공 시 수량 산출 금지(P1규칙)"); - _cashRaiseCands_.slice(0, 8).forEach((c, i) => { - const pStr = (c.profit_pct !== "" && c.profit_pct !== null) - ? ` (${Number(c.profit_pct) >= 0 ? "+" : ""}${Number(c.profit_pct).toFixed(1)}%)` - : ""; - const etfTag = c.is_etf ? "[ETF]" : ""; - const clTag = c.is_core_leader ? "[주도주⛔매도금지]" : ""; - L.push(` ${i+1}. ${c.tier_label} ${c.name}${etfTag}${clTag} W:${c.weight_pct}%${pStr} RW:${c.rw_partial} Score:${c.sell_priority_score}`); - if (c.trim_style || c.rebound_holdback_score) - L.push(` └ trim=${c.trim_style || "N/A"} rebound_holdback=${c.rebound_holdback_score ?? 0}${c.rebound_holdback_reason ? ` | ${c.rebound_holdback_reason}` : ""}`); - if (c.action_params) L.push(` └ ${c.action_params}`); - if (c.hold_reason) L.push(` └ ⚠ ${c.hold_reason}`); - }); - } - - // 주의 종목 섹션 - if (stage2Pass.length || timeStopNear.length || overweight.length || tp1Near.length) { - L.push("[주의]"); - stage2Pass.forEach(h => L.push(` ${h.Name} Stage2_Gate=PASS → 2단계 진입 검토 (진입가 ${h.Limit_Price_Est ?? "N/A"})`)); - timeStopNear.forEach(h => L.push(` ${h.Name} Time_Stop ${h.Days_To_Time_Stop}일 남음 (${h.Time_Stop_Date})`)); - overweight.forEach(h => L.push(` ${h.Name} OVERWEIGHT ${h.Weight_Pct}% (상한 7%)`)); - tp1Near.forEach(h => L.push(` ${h.Name} +${h.Profit_Pct}% → TP1(${h.TP1_Price}원) 근접`)); - } - if (events.upcoming_7d?.length) { - L.push("[7일 이벤트]"); - events.upcoming_7d.forEach(ev => L.push(` ${ev.Date}(D+${ev.DaysLeft}) ${ev.Event} [${ev.Impact}]`)); - } - - // brief_ — holdings row → JSON 요약 (API 소비자용) - const brief_ = (h) => ({ - ticker: h.Ticker, name: h.Name, - final_action: h.Final_Action, // canonical output field - action_reason: h.Action_Reason, // 왜 이 액션인가 - action_params: h.Action_Params, // 실행 파라미터 압축 (C-3) - final_rank: h.Final_Rank, - allowed_action: h.Allowed_Action, - ss001_grade: h.SS001_Grade, ss001_norm_score: h.SS001_Norm_Score, - rw_partial: h.RW_Partial, - weight_pct: h.Weight_Pct, profit_pct: h.Profit_Pct, - stage2_gate: h.Stage2_Gate, band_status: h.Band_Status, - limit_price_est: h.Limit_Price_Est, - stop_price_est: h.Stop_Price_Est, stop_price_source: h.Stop_Price_Source, - pos_size_qty: h.Pos_Size_Qty, pos_size_constraint: h.Pos_Size_Constraint, - tp1_price: h.TP1_Price, tp1_qty: h.TP1_Qty, - tp2_price: h.TP2_Price, tp2_qty: h.TP2_Qty, - entry_mode: h.Entry_Mode, entry_mode_gate: h.Entry_Mode_Gate, - entry_mode_reason: h.Entry_Mode_Reason, - timing_score_entry: h.Timing_Score_Entry, - timing_score_exit: h.Timing_Score_Exit, - timing_action: h.Timing_Action, - timing_block_reason: h.Timing_Block_Reason, - sell_action: h.Sell_Action, - sell_ratio_pct: h.Sell_Ratio_Pct, - sell_limit_price: h.Sell_Limit_Price, - sell_reason: h.Sell_Reason, - sell_validation: h.Sell_Validation, - cash_preserve_style: h.Cash_Preserve_Style || "", - cash_preserve_ratio: h.Cash_Preserve_Ratio || "", - cash_preserve_reason: h.Cash_Preserve_Reason || "", - rsi14: h.RSI14, disparity: h.Disparity, ma20_slope: h.MA20_Slope, - exit_signal_detail: h.Exit_Signal_Detail, - }); - - return { - date: today, - brief_text: L.join("\n"), - market: { - regime: macro.market_regime, mrs_score: macro.mrs_score, - vix: macro.vix, kospi: macro.kospi, usd_krw: macro.usd_krw, - sp500_ret5d: macro.sp500_ret5d, - }, - portfolio_health: { - heat_pct: macro.total_heat_pct, heat_ok: heatOk, - heat_tag: heatTag, - heat_block: heatBlockB, heat_caution: heatCautionB, - fc_budget_pct: macro.fc_budget_pct, fc_ok: fcOk, - net_return_feedback: nrf, - bucket_status: macro.bucket_status, - regime_buy_blocked: isRiskOffB, - bayesian_label: macro.bayesian_label, - bayesian_multiplier: macro.bayesian_multiplier, - }, - orbit: { - gap_pct: macro.orbit_gap_pct, state: macro.orbit_state, - slot_adjustment: orbitAdj, cash_adjustment: macro.orbit_cash_adj, - }, - // Final_Action canonical 분류 (A-1/B-1) - actions: { - sell_ready: sellList.map(brief_), - exit_signals: exitList.map(brief_), - buy_signals: buyList.map(brief_), - watch_signals: watchList.map(brief_), - hold_signals: holdList.map(brief_), - }, - alerts: { - stage2_ready: stage2Pass.map(h=>({ticker:h.Ticker,name:h.Name,profit_pct:h.Profit_Pct,limit_price_est:h.Limit_Price_Est})), - time_stop_near: timeStopNear.map(h=>({ticker:h.Ticker,name:h.Name,days_left:h.Days_To_Time_Stop,stop_date:h.Time_Stop_Date})), - overweight: overweight.map(h=>({ticker:h.Ticker,name:h.Name,weight_pct:h.Weight_Pct})), - tp1_near: tp1Near.map(h=>({ticker:h.Ticker,name:h.Name,profit_pct:h.Profit_Pct,tp1_price:h.TP1_Price,tp2_price:h.TP2_Price})), - }, - upcoming_events: events.upcoming_7d, - account_snapshot_freshness: acctFresh, - data_quality: { - price_stale: priceStaleList_.map(h=>({ticker:h.Ticker,name:h.Name,price_date:h.Price_Date})), - quote_only: quoteOnlyList_.map(h=>({ticker:h.Ticker,name:h.Name})), - flow_stale: flowStaleList_.map(h=>({ticker:h.Ticker,name:h.Name,missing_fields:h.Missing_Fields})), - }, - // sell_priority_engine 출력 (spec: portfolio_exposure.yaml:sell_priority_engine) - // 활성화: REGIME_TRIM_50 또는 현금 부족. ETF→손실위성→코어주도주 순서로 정렬. - cash_raise: _cashBelowTgt_ ? { - active: true, - reason: isRiskOffB ? `REGIME_TRIM_50(${regimeStr})` : "cash_below_target", - prohibition: "매도수량은 HTS 캡처 제공 후 결정. 수량 미제공 시 수량 기재 금지(spec:P1규칙).", - sell_priority_table: _cashRaiseCands_, - sector_exposure_summary: sellPriorityView_.sector_exposure ?? sellPriorityView_.sector_exposure_summary ?? {}, - } : { active: false }, - }; -} - -// ── E3: 거래 진입 템플릿 생성 ──────────────────────────────────────────────── -// BUY_CANDIDATE/WATCH_CANDIDATE 종목에 대해 performance 탭 입력 행 + 진입 체크리스트 반환. -// doGet(?view=trade_template&ticker=064350) -function getTradeTemplate(ticker) { - if (!ticker) return { error: "ticker 파라미터 필요 (?view=trade_template&ticker=XXXXXX)" }; - const allData = sheetToJson("data_feed"); - const row = allData.find(r => String(r.Ticker) === String(ticker) || r.Name === ticker); - if (!row) return { error: `ticker ${ticker} not found in data_feed` }; - - const macro = getMacroJson(); - const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - const sector = TICKER_SECTOR_MAP[ticker] ?? "N/A"; - - // 진입 체크리스트 — 각 항목 true/false - const checklist = { - data_quality: row.Price_Status === "PRICE_OK", - no_dart_risk: !row.DART_Risk || row.DART_Risk === "" || row.DART_Risk === "N", - liquidity_ok: row.Liquidity_Status === "OK", - timing_ready: ["BUY_STAGE1_READY","BUY_PULLBACK_WAIT","BUY_BREAKOUT_PILOT_ONLY"].includes(row.Timing_Action), - leader_gate: ["PASS","EXPLORE_CANDIDATE","WATCH_ONLY"].includes(row.Leader_Gate), - ac_gate: row.AC_Gate === "CLEAR", - flow_credit_ok: parseFloat(row.Flow_Credit) >= 0.4, - regime_ok: ["RISK_ON","SECULAR_LEADER_RISK_ON","LEADER_CONCENTRATION"].includes(macro.market_regime), - heat_ok: Number.isFinite(parseFloat(macro.total_heat_pct)) && parseFloat(macro.total_heat_pct) < 10, - fc_budget_ok: Number.isFinite(parseFloat(macro.fc_budget_pct)) && parseFloat(macro.fc_budget_pct) < 100, - nr_feedback_ok: macro.net_return_feedback !== "REDUCED", - ee_positive: parseFloat(row.EE_Est) > 0, - ss001_grade_ok: ["A","B"].includes(row.SS001_Grade), - }; - const passCount = Object.values(checklist).filter(Boolean).length; - const totalCheck = Object.keys(checklist).length; - const gateStatus = passCount === totalCheck ? "ALL_PASS" - : passCount >= totalCheck - 2 ? "MINOR_ISSUES" - : "BLOCK"; - - return { - ticker, - name: row.Name, - sector, - generated_at: today, - gate_status: gateStatus, - gate_score: `${passCount}/${totalCheck}`, - checklist, - // performance 탭에 바로 붙여넣을 수 있는 행 템플릿 - performance_tab_template: { - trade_id: `${today.replace(/-/g,"")}${ticker}`, - ticker, - sector, - entry_date: today, - entry_price: row.Limit_Price_Est ?? "", - entry_stage: "stage_1", - quantity: row.Pos_Size_Qty ?? "", - stop_price_at_entry: row.Stop_Price_Est ?? "", - target_price_at_entry: row.Target_Price ?? "", - exit_date: "", - exit_price: "", - exit_reason: "", - pnl_pct: "", - holding_days: "", - entry_c1_score: row.C1_Price ?? "", - entry_c2_score: row.C2_RelStr ?? "", - entry_c3_score: row.C3_VolSurge ?? "", - entry_c4_score: row.C4_Flow ?? "", - entry_c5_score: row.C5_Sector ?? "", - entry_mode: row.Entry_Mode ?? "", - entry_gate: row.Entry_Mode_Gate ?? "", - timing_action: row.Timing_Action ?? "", - timing_score_entry: row.Timing_Score_Entry ?? "", - timing_score_exit: row.Timing_Score_Exit ?? "", - anti_climax_gate: row.AC_Gate ?? "", - flow_credit: row.Flow_Credit ?? "", - entry_mrs_score: macro.mrs_score ?? "", - fc_bucket: "", - }, - current_state: { - close: row.Close, - allowed_action: row.Allowed_Action, - timing_action: row.Timing_Action, - timing_score_entry: row.Timing_Score_Entry, - timing_score_exit: row.Timing_Score_Exit, - timing_block_reason: row.Timing_Block_Reason, - sell_action: row.Sell_Action, - sell_ratio_pct: row.Sell_Ratio_Pct, - sell_qty: row.Sell_Qty, - sell_limit_price: row.Sell_Limit_Price, - sell_price_source: row.Sell_Price_Source, - sell_reason: row.Sell_Reason, - sell_validation: row.Sell_Validation, - ss001_grade: row.SS001_Grade, - ss001_total: row.SS001_Total, - flow_credit: row.Flow_Credit, - rw_partial: row.RW_Partial, - limit_price_est: row.Limit_Price_Est, - stop_price_est: row.Stop_Price_Est, - stop_price_source: row.Stop_Price_Source, - ee_est: row.EE_Est, - pos_size_qty: row.Pos_Size_Qty, - upside_pct: row.Upside_Pct, - atr20: row.ATR20, - tp1_price: row.TP1_Price, - tp1_qty: row.TP1_Qty, - tp2_price: row.TP2_Price, - tp2_qty: row.TP2_Qty, - dart_risk: row.DART_Risk, - days_to_earnings: row.Days_To_Earnings, - }, - }; -} - -function getSummaryJson() { - // ChatGPT 포트폴리오 분석에 최적화된 통합 뷰 - const sectors = getSectorFlowJson(); - const port = getPortfolioJson(); - const macro = getMacroJson(); - const events = getEventRiskJson(); - - // 포트폴리오 전체 수급 요약 - const holdings = port.holdings; - const totalFrg5 = holdings.reduce((s,h) => s + (parseFloat(h.Frg_5D) || 0), 0); - const totalInst5 = holdings.reduce((s,h) => s + (parseFloat(h.Inst_5D) || 0), 0); - const flowOkCount = holdings.filter(h => h.Flow_OK === "Y").length; - - // SS001 등급 분포 및 Allowed_Action 집계 - const ss001Dist = { A: 0, B: 0, C: 0, D: 0 }; - const actionDist = {}; - holdings.forEach(h => { - const g = h["SS001_Grade"]; - if (g in ss001Dist) ss001Dist[g]++; - const a = h["Allowed_Action"] || "UNKNOWN"; - actionDist[a] = (actionDist[a] ?? 0) + 1; - }); - - return { - portfolio_flow_summary: { - total_holdings: holdings.length, - data_ok_count: flowOkCount, - portfolio_frg_5d_total: totalFrg5, - portfolio_inst_5d_total: totalInst5, - portfolio_indiv_5d_total: -(totalFrg5 + totalInst5), - }, - ss001_grade_distribution: ss001Dist, - action_distribution: actionDist, - sector_summary: { - total_sectors: sectors.count, - top_inflow_sectors: sectors.top_inflow, - outflow_warning_sectors: sectors.outflow_warning, - strong_smart_money_sectors: sectors.strong_smart_money, - }, - macro_snapshot: { - vix: macro.vix, - usd_krw: macro.usd_krw, - kospi: macro.kospi, - sp500_5d_ret: macro.sp500_ret5d, - market_regime: macro.market_regime, - mrs_score: macro.mrs_score, - bayesian_multiplier: macro.bayesian_multiplier, - total_heat_pct: macro.total_heat_pct, - fc_budget_pct: macro.fc_budget_pct, - net_return_feedback: macro.net_return_feedback, - orbit_gap_pct: macro.orbit_gap_pct, - orbit_state: macro.orbit_state, - orbit_slot_adj: macro.orbit_slot_adj, - bucket_status: macro.bucket_status, - bucket_detail: macro.bucket_detail, - }, - event_alerts: events.upcoming_7d, - holdings_detail: holdings, - sector_detail: sectors.sectors, - macro_detail: macro.indicators, - macro_computed: macro.computed_summary, - }; -} diff --git a/src/gas/core/gas_lib.gs b/src/gas/core/gas_lib.gs index 59814c4..a61bb03 100644 --- a/src/gas/core/gas_lib.gs +++ b/src/gas/core/gas_lib.gs @@ -474,31 +474,6 @@ function appendAlphaHistory_(ss, aewRows, holdings, dfMap, marketRegime) { }); } -function getAlphaFeedbackJson_() { - var defaultPayload = { - formula_id: 'ALPHA_FEEDBACK_LOOP_V1', - as_of: '', - analysis_period: '', - status: 'DATA_MISSING', - cases_analyzed: 0, - grade_count: 0, - eligible_t20_fail_rate: null, - eligible_t60_fail_rate: null, - recommended_filter_adjustments: [], - grade_summary: [] - }; - try { - var settings = readSettingsTab_(); - var raw = settings['afl_v1_last_result']; - if (!raw) return defaultPayload; - var payload = typeof raw === 'string' ? JSON.parse(raw) : raw; - return payload && typeof payload === 'object' ? payload : defaultPayload; - } catch (e) { - Logger.log('[AFL] getAlphaFeedbackJson_ error: ' + e.message); - return defaultPayload; - } -} - // ── settings 탭 읽기 → 사용자 입력 파라미터 (total_asset 등) ──────────────── // settings 탭: row2=헤더(key|value|note), row3+=데이터 // 없으면 빈 객체 반환 (각 호출처에서 null 처리) @@ -2406,143 +2381,6 @@ function getOrbitGapJson() { }; } -// ── [2026-05-21_AFL_V1] ALPHA_FEEDBACK_LOOP_V1 -- monthly grade analysis ──────── -function runAlphaFeedbackLoop_() { - var ss = getSpreadsheet_(); - var sheet = ss.getSheetByName("alpha_history"); - var today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); - var monthKey = today.substring(0, 7); - var defaultPayload = { - formula_id: 'ALPHA_FEEDBACK_LOOP_V1', - as_of: today, - analysis_period: monthKey, - status: 'DATA_MISSING', - cases_analyzed: 0, - grade_count: 0, - eligible_t20_fail_rate: null, - eligible_t60_fail_rate: null, - recommended_filter_adjustments: [], - grade_summary: [] - }; - if (!sheet) { - writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(defaultPayload)); - Logger.log("[AFL] alpha_history sheet not found"); - return defaultPayload; - } - var data = sheet.getDataRange().getValues(); - if (data.length < 2) { - writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(defaultPayload)); - Logger.log("[AFL] alpha_history has no data"); - return defaultPayload; - } - - var hdrRow = data[0]; - var hdrMap = {}; - hdrRow.forEach(function(h, i) { hdrMap[h] = i; }); - - var gradeStats = {}; - var analyzedCases = 0; - for (var i = 1; i < data.length; i++) { - var row = data[i]; - var grade = String(row[hdrMap['SAQG_Grade_At_Entry']] || '').trim(); - var t20g = String(row[hdrMap['T20_Alpha_Gate']] || '').trim(); - var t60g = String(row[hdrMap['T60_Alpha_Gate']] || '').trim(); - if (!grade) continue; - if (!gradeStats[grade]) gradeStats[grade] = { t20_total: 0, t20_pass: 0, t60_total: 0, t60_pass: 0 }; - var s = gradeStats[grade]; - var skipVals = { 'NOT_YET': 1, 'EXEMPT': 1, 'DATA_MISSING': 1, '': 1 }; - var hasT20 = t20g && !skipVals[t20g]; - var hasT60 = t60g && !skipVals[t60g]; - if (hasT20) { s.t20_total++; if (t20g === 'T20_ALPHA_PASS') s.t20_pass++; } - if (hasT60) { s.t60_total++; if (t60g === 'T60_ALPHA_PASS') s.t60_pass++; } - if (hasT20 || hasT60) analyzedCases++; - } - - var gradeSummary = []; - Object.keys(gradeStats).sort().forEach(function(grade) { - var s = gradeStats[grade]; - var t20FailRate = s.t20_total > 0 ? parseFloat((((s.t20_total - s.t20_pass) / s.t20_total) * 100).toFixed(2)) : null; - var t60FailRate = s.t60_total > 0 ? parseFloat((((s.t60_total - s.t60_pass) / s.t60_total) * 100).toFixed(2)) : null; - var t20PassRate = s.t20_total > 0 ? parseFloat(((s.t20_pass / s.t20_total) * 100).toFixed(2)) : null; - var t60PassRate = s.t60_total > 0 ? parseFloat(((s.t60_pass / s.t60_total) * 100).toFixed(2)) : null; - gradeSummary.push({ - grade: grade, - t20_total: s.t20_total, - t20_pass: s.t20_pass, - t20_pass_rate: t20PassRate, - t20_fail_rate: t20FailRate, - t60_total: s.t60_total, - t60_pass: s.t60_pass, - t60_pass_rate: t60PassRate, - t60_fail_rate: t60FailRate, - status: (s.t20_total >= 10 || s.t60_total >= 10) ? 'ANALYZED' : 'DATA_INSUFFICIENT' - }); - }); - - var eligibleRow = gradeStats['ELIGIBLE'] || { t20_total: 0, t20_pass: 0, t60_total: 0, t60_pass: 0 }; - var eligibleT20FailRate = eligibleRow.t20_total > 0 - ? parseFloat((((eligibleRow.t20_total - eligibleRow.t20_pass) / eligibleRow.t20_total) * 100).toFixed(2)) - : null; - var eligibleT60FailRate = eligibleRow.t60_total > 0 - ? parseFloat((((eligibleRow.t60_total - eligibleRow.t60_pass) / eligibleRow.t60_total) * 100).toFixed(2)) - : null; - var eligibleT20PassRate = eligibleRow.t20_total > 0 - ? parseFloat(((eligibleRow.t20_pass / eligibleRow.t20_total) * 100).toFixed(2)) - : null; - - var recommendations = []; - if (analyzedCases >= 10) { - if (eligibleT20FailRate !== null && eligibleT20FailRate > 50) { - recommendations.push({ - filter_id: 'SAQG_F2_RECOVERY_RATIO', - current: '1.20', - recommended: '1.35', - rationale: 'ELIGIBLE T+20 fail rate > 50%', - action: 'TIGHTEN' - }); - recommendations.push({ - filter_id: 'SAQG_F3_EXCESS_DRAWDOWN', - current: '5%p', - recommended: '4%p', - rationale: 'ELIGIBLE T+20 fail rate > 50%', - action: 'TIGHTEN' - }); - } else if (eligibleT20PassRate !== null && eligibleT20PassRate > 70 && eligibleRow.t20_total >= 12) { - recommendations.push({ - filter_id: 'SAQG_F3_EXCESS_DRAWDOWN', - current: '5%p', - recommended: '7%p', - rationale: 'ELIGIBLE T+20 success rate > 70% and cases >= 12', - action: 'RELAX_REVIEW' - }); - } else { - recommendations.push({ - filter_id: 'SAQG_F1_F2_F3', - current: 'UNCHANGED', - recommended: 'HOLD', - rationale: 'No threshold change supported by current sample', - action: 'HOLD' - }); - } - } - - var payload = { - formula_id: 'ALPHA_FEEDBACK_LOOP_V1', - as_of: today, - analysis_period: monthKey, - status: analyzedCases >= 10 ? 'ANALYZED' : 'DATA_INSUFFICIENT', - cases_analyzed: analyzedCases, - grade_count: Object.keys(gradeStats).length, - eligible_t20_fail_rate: eligibleT20FailRate, - eligible_t60_fail_rate: eligibleT60FailRate, - recommended_filter_adjustments: analyzedCases >= 10 ? recommendations : [], - grade_summary: gradeSummary - }; - writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(payload)); - Logger.log('[AFL] done - ' + payload.grade_count + ' grades analyzed, cases=' + analyzedCases); - return payload; -} - // ── E2: 월말 자산 스냅샷 → monthly_history 기록 ───────────────────────────── // 트리거: 매달 마지막 영업일 16:30 독립 실행 OR runDataFeed 완료 후 호출. function runMonthlySnapshot() { diff --git a/src/gas/engines/gas_apex_alpha_watch.gs b/src/gas/engines/gas_apex_alpha_watch.gs index ce480e6..542e880 100644 --- a/src/gas/engines/gas_apex_alpha_watch.gs +++ b/src/gas/engines/gas_apex_alpha_watch.gs @@ -91,94 +91,6 @@ function calcAntiLateEntryGateV2Impl_(holdings, dfMap) { return results; } -/** - * PA5: CONSISTENCY_VALIDATOR_V2 - * [P0 GAP 해소 - 데이터 정합성 검증] - */ -function calcConsistencyValidatorV2Impl_(hApex, asResult, cashFloorInfo, capturedAtIso, now) { - var checks = []; - var passed = []; - var failed = []; - var gapList = []; - - // CV_01: sell_priority 방향 일관성 - var sellCandidates = hApex.sell_candidates_json || []; - var tierOk = true; - for (var i = 1; i < sellCandidates.length; i++) { - if (sellCandidates[i].tier < sellCandidates[i-1].tier) { - tierOk = false; - break; - } - } - if (tierOk) passed.push('CV_01'); else failed.push({check_id: 'CV_01', reason: 'tier_reversal'}); - - // CV_02: 가격 순서 검증 - var prices = hApex.prices_json || []; - var priceOk = true; - for (var i = 0; i < prices.length; i++) { - var p = prices[i]; - if (p.stop_price && p.current_price && p.stop_price >= p.current_price) priceOk = false; - } - if (priceOk) passed.push('CV_02'); else failed.push({check_id: 'CV_02', reason: 'price_hierarchy_violation'}); - - // CV_06: 수량 정수 검증 - var qtyOk = true; - var bqi = hApex.buy_qty_inputs_json || []; - for (var i = 0; i < bqi.length; i++) { - if (bqi[i].final_qty && bqi[i].final_qty % 1 !== 0) qtyOk = false; - } - if (qtyOk) passed.push('CV_06'); else failed.push({check_id: 'CV_06', reason: 'float_quantity'}); - - // CV_08: 현금 계산 경로 - if (hApex.cash_ledger_basis === 'D2_ONLY') passed.push('CV_08'); - else failed.push({check_id: 'CV_08', reason: 'invalid_cash_basis'}); - - // Score 계산 - var score = Math.floor((passed.length / 12) * 100); - var status = score >= 90 ? (score === 100 ? 'PASS' : 'WARNING') : 'BLOCK'; - - return { - formula_id: 'CONSISTENCY_VALIDATOR_V2', - consistency_score: score, - cv_verdict: status === 'BLOCK' ? 'ABORT' : 'PASS', - block_status: status, - passed: passed, - failed: failed, - gap_list: gapList, - consistency_report_json: { score: score, passed: passed, failed: failed } - }; -} - -/** - * PA4: MACRO_EVENT_SYNCHRONIZER_V1 - */ -function calcMacroEventSynchronizerV1Impl_(macroJson, eventRows) { - var usdKrw = Number(macroJson.usd_krw || 0); - var foreignSellDays = Number(macroJson.foreign_sell_consecutive_days || 0); - - var score = 0; - if (usdKrw > 1500) score += 20; - else if (usdKrw > 1480) score += 15; - - if (foreignSellDays >= 10) score += 20; - else if (foreignSellDays >= 5) score += 15; - - var regime = 'MACRO_NEUTRAL'; - var heatAdj = 0; - if (score >= 60) { regime = 'MACRO_CRITICAL'; heatAdj = -3; } - else if (score >= 40) { regime = 'MACRO_ELEVATED'; heatAdj = -1; } - else if (score < 20) { regime = 'MACRO_FAVORABLE'; heatAdj = 1; } - - return { - formula_id: 'MACRO_EVENT_SYNCHRONIZER_V1', - macro_risk_score: score, - macro_risk_regime: regime, - effective_heat_gate_adjustment: heatAdj, - mega_sell_alert: false, - macro_event_json: { score: score, regime: regime, heat_gate_adj: heatAdj } - }; -} - /** * PA1: PREDICTIVE_ALPHA_ENGINE_V1 */ @@ -216,29 +128,6 @@ function calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult, return results; } -/** - * MACRO_REGIME_ADAPTIVE_GATE_V2 - */ -function calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex) { - var totalScore = mesResult.macro_risk_score || 0; - var regime = 'MODERATE_RISK'; - var heatThreshold = 10.0; - var sizeScale = 1.0; - - if (totalScore >= 75) { regime = 'EXTREME_RISK'; heatThreshold = 5.0; sizeScale = 0.25; } - else if (totalScore >= 50) { regime = 'HIGH_RISK'; heatThreshold = 7.0; sizeScale = 0.50; } - else if (totalScore < 25) { regime = 'LOW_RISK'; heatThreshold = 12.0; sizeScale = 1.10; } - - return { - formula_id: 'MACRO_REGIME_ADAPTIVE_GATE_V2', - total_mrag_score: totalScore, - regime_label: regime, - effective_heat_gate_threshold: heatThreshold, - effective_position_size_scale: sizeScale, - mrag_v2_json: { score: totalScore, regime: regime } - }; -} - /** * applyAlegGate4And5Impl_ */ @@ -263,19 +152,6 @@ function applyAlegGate4And5Impl_(alegRows, paeRows, hApex) { return results; } -/** - * Suite Aggregators - */ -function applyApexMacroAlphaSuiteImpl_(holdings, dfMap, hApex) { - // Placeholder for macro alpha suite - return hApex; -} - -function applyApexMacroEventSuiteImpl_(hApex) { - // Placeholder for macro event suite - return hApex; -} - function applyApexPredictiveAlphaSuiteImpl_(holdings, dfMap, hApex) { var macroJson = hApex.macro_event_json || {}; var mesResult = hApex.macro_event_json || {}; From 6697863ad00f843fb62170d8cbe7630ad906c941 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 14 Jun 2026 17:24:17 +0900 Subject: [PATCH 2/2] chore(gas): auto-update gas_lib.gs Last Updated timestamp (deploy 2026-06-14 17:23:33 KST) Co-Authored-By: Claude Sonnet 4.6 --- src/gas/core/gas_lib.gs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gas/core/gas_lib.gs b/src/gas/core/gas_lib.gs index a61bb03..19bb224 100644 --- a/src/gas/core/gas_lib.gs +++ b/src/gas/core/gas_lib.gs @@ -1,5 +1,5 @@ // gas_lib.gs - Common utilities & static features -// Last Updated: 2026-06-14 13:11:22 KST +// Last Updated: 2026-06-14 17:23:33 KST // Math/KRX utils, sheet I/O, sector flow, Web API, static runners // GAS global scope: functions in gas_data_feed.gs / gas_data_collect.gs callable directly //