chore: 프로젝트 루트의 파편화된 .gs 파일들을 src/gas_adapter_parts/로 이동 격리
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 4s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Failing after 4s
Quant Engine CI/CD Pipeline / validate-core (pull_request) Failing after 2m17s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been skipped
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 4s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Failing after 4s
Quant Engine CI/CD Pipeline / validate-core (pull_request) Failing after 2m17s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been skipped
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* 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};
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
// 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
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
// gas_event_calendar.gs — compatibility stub
|
||||
//
|
||||
// 이벤트 캘린더 seed / risk 로직은 gas_lib.gs 에 구현되어 있다.
|
||||
// 이 파일은 upload ZIP / GAS 프로젝트 배포에서 명시적 엔트리포인트를 유지하기 위한 호환 스텁이다.
|
||||
//
|
||||
// 실제 동작 함수:
|
||||
// seedEventCalendar_()
|
||||
// runEventRisk()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,446 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user