ee3e799de1
주요 변경: - tools/build_rebalance_engine_v1.py: REBALANCE_ENGINE_V1 신규 * account_snapshot 직접 합산(_build_snap_position_map) → 소수주 분리 행 병합 * 레짐 소스 macro.REGIME_PRELIM 최우선 (GAS 와 동일) - src/gas_adapter_parts/gdf_06_rebalance.gs: runRebalanceSheet_() 신규 * Logger.log / getSpreadsheet_() 로 run_all 연동 수정 - src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs * _mergePositionRecord_(): 소수주 중복 행 합산 신규 * parseInt → parseFloat (qty, availQty) - src/gas_adapter_parts/gdf_01_price_metrics.gs * 미보유 종목 SELL_READY → WATCH_EXIT_SIGNAL - spec/41_release_dag.yaml: build_rebalance_sheet 노드 추가 (step_count 63) - spec/51_formula_lifecycle_registry.yaml: REBALANCE_ENGINE_V1 등록 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
706 lines
31 KiB
JavaScript
706 lines
31 KiB
JavaScript
// 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
|
|
});
|
|
}
|