Merge pull request 'refactor(gas): GAS 중복 함수 제거 + stale 루트 .gs 파일 정리' (#49) from feature/gas-dedup-refactor into main

Merge PR#49: GAS 중복 함수 제거 + stale .gs 정리
This commit is contained in:
2026-06-14 17:26:47 +09:00
10 changed files with 1 additions and 7173 deletions
-378
View File
@@ -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};
}
-705
View File
@@ -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
});
}
-8
View File
@@ -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
-21
View File
@@ -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
*/
-907
View File
@@ -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(/<tr[^>]*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]*?)</td>`, '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(/<tr[^>]*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(/<td[^>]*>([\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(/>([^<]+)</);
const countryIso = countryIsoMatch ? countryIsoMatch[1].trim().toUpperCase() : '';
// 4) 이벤트 이름 추출 (td[4] 내부의 calendar-event 링크 텍스트)
const td4 = tdMatches[4];
const eventMatch = td4.match(/class=["']calendar-event["'][^>]*>([^<]+)/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&section_id=101&section_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 = /<dd class=["']articleSubject["']>[\s\S]*?<a href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
const wdatePattern = /<span class=["']wdate["']>([\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);
}
-1456
View File
File diff suppressed because it is too large Load Diff
-2965
View File
File diff suppressed because it is too large Load Diff
-446
View File
@@ -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,
};
}
+1 -163
View File
@@ -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
//
@@ -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() {
-124
View File
@@ -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 || {};