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:
@@ -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};
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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§ion_id=101§ion_id2=258';
|
||||
const html = fetchWithCache_(url, CFG.CACHE_TTL_SEC, headers, 'EUC-KR');
|
||||
if (html) events.push(...parseNaverNewsList_(html, 'KR', 'Naver 뉴스 속보', url));
|
||||
} catch(e) { Logger.log('[Naver News List] ' + e.message); }
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Naver Finance 뉴스 목록 HTML에서 기사 제목과 발행일을 추출.
|
||||
*/
|
||||
function parseNaverNewsList_(html, region, sourceName, sourceUrl) {
|
||||
const events = [];
|
||||
|
||||
// articleSubject 및 wdate 추출용 정규식
|
||||
const subjectPattern = /<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
File diff suppressed because it is too large
Load Diff
-2965
File diff suppressed because it is too large
Load Diff
-446
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
Reference in New Issue
Block a user