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>
2256 lines
102 KiB
JavaScript
2256 lines
102 KiB
JavaScript
function calcProfitPreservationRow_(h, df, priceRow, distributionRow) {
|
||
var close = df.close || h.close || 0;
|
||
var avgCost = h.avgCost || 0;
|
||
var profitPct = close > 0 && avgCost > 0 ? (close - avgCost) / avgCost * 100 : 0;
|
||
var state = 'NORMAL';
|
||
var preserveScore = 100;
|
||
if (profitPct >= 30) state = 'PROFIT_LOCK_30';
|
||
else if (profitPct >= 20) state = 'PROFIT_LOCK_20';
|
||
else if (profitPct >= 10) state = 'PROFIT_LOCK_10';
|
||
else if (profitPct >= 8 || (df.atr20 > 0 && close >= avgCost + df.atr20)) state = 'BREAKEVEN_RATCHET';
|
||
if (state === 'PROFIT_LOCK_30') preserveScore = 20;
|
||
else if (state === 'PROFIT_LOCK_20') preserveScore = 40;
|
||
else if (state === 'PROFIT_LOCK_10') preserveScore = 60;
|
||
else if (state === 'BREAKEVEN_RATCHET') preserveScore = 80;
|
||
if (state === 'PROFIT_LOCK_30' && distributionRow && distributionRow.anti_distribution_state === 'PASS') {
|
||
state = 'APEX_TRAILING';
|
||
}
|
||
if (distributionRow && distributionRow.anti_distribution_state === 'BLOCK_BUY') {
|
||
preserveScore = Math.max(0, preserveScore - 15);
|
||
}
|
||
|
||
// L2: RATCHET_TRAILING_AUTO_V1 — ATR 기반 자동 트레일링 손절 계산
|
||
var atr20 = typeof df.atr20 === 'number' && df.atr20 > 0 ? df.atr20 : 0;
|
||
var ratchetStop = priceRow && typeof priceRow.stop_price === 'number' ? priceRow.stop_price : 0;
|
||
var highestClose = priceRow && typeof priceRow.highest_price_since_entry === 'number'
|
||
? priceRow.highest_price_since_entry : close;
|
||
var autoTrailingStop = null;
|
||
var autoTrailingNote = null;
|
||
if (atr20 > 0 && (state === 'PROFIT_LOCK_30' || state === 'APEX_TRAILING')) {
|
||
var raw = Math.max(ratchetStop, highestClose - 2.0 * atr20);
|
||
autoTrailingStop = tickNormalize_(raw);
|
||
autoTrailingNote = 'max(ratchet,' + highestClose + '-2.0×ATR)';
|
||
} else if (atr20 > 0 && state === 'PROFIT_LOCK_20') {
|
||
var raw = Math.max(ratchetStop, highestClose - 1.5 * atr20);
|
||
autoTrailingStop = tickNormalize_(raw);
|
||
autoTrailingNote = 'max(ratchet,' + highestClose + '-1.5×ATR)';
|
||
}
|
||
|
||
return {
|
||
ticker: h.ticker,
|
||
name: h.name || df.name || '',
|
||
profit_pct: round2_(profitPct),
|
||
profit_preservation_state: state,
|
||
rebound_preservation_score: Math.min(100, Math.max(0, Math.round(preserveScore))),
|
||
protected_stop_price: priceRow ? priceRow.stop_price : null,
|
||
ratchet_partial_qty: priceRow ? priceRow.ratchet_partial_qty : 0,
|
||
auto_trailing_stop: autoTrailingStop,
|
||
auto_trailing_note: autoTrailingNote,
|
||
formula_id: 'PROFIT_PRESERVATION_STATE_V1'
|
||
};
|
||
}
|
||
|
||
function calcExecutionQualityRow_(ticker, orderRow, df) {
|
||
var amount = orderRow && orderRow.order_amount_krw ? orderRow.order_amount_krw : 0;
|
||
var advKrw = 0;
|
||
if (typeof df.avgTradeVal5d === 'number') {
|
||
// AvgTradeValue_5D_M is usually million KRW in sheet label.
|
||
advKrw = df.avgTradeVal5d * 1000000;
|
||
}
|
||
var status = 'PASS';
|
||
var splitCount = 1;
|
||
var reasons = [];
|
||
if (amount > 0 && advKrw > 0 && amount > advKrw * 0.03) {
|
||
status = 'BLOCKED_ADV_3PCT'; reasons.push('order_amount_gt_3pct_adv');
|
||
} else if (amount > 0 && advKrw > 0 && amount > advKrw * 0.01) {
|
||
status = 'SPLIT_REQUIRED'; splitCount = 2; reasons.push('order_amount_gt_1pct_adv');
|
||
}
|
||
if (df.spreadStatus && String(df.spreadStatus).indexOf('WIDE') >= 0) {
|
||
status = 'BLOCKED_SPREAD'; reasons.push('wide_spread');
|
||
}
|
||
return {
|
||
ticker: ticker,
|
||
execution_quality_status: status,
|
||
split_count: splitCount,
|
||
child_order_amount_krw: splitCount > 1 ? Math.round(amount / splitCount) : amount,
|
||
hts_allowed: status === 'PASS',
|
||
reason_codes: reasons,
|
||
formula_id: 'EXECUTION_QUALITY_GUARD_V1'
|
||
};
|
||
}
|
||
|
||
// ── [2026-05-20_HARNESS_V5] H6: 뒷박 차단 — BREAKOUT_QUALITY_GATE_V2 ─────────
|
||
function calcBreakoutQualityGate_(h, df, alphaRow, distRow) {
|
||
var close = df.close || h.close || 0;
|
||
var prevClose = df.prevClose || close;
|
||
var ma20 = df.ma20 || 0;
|
||
var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : null;
|
||
var volume = typeof df.volume === 'number' ? df.volume : null;
|
||
var avgVol5d = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : null;
|
||
|
||
var ret1d = (close > 0 && prevClose > 0) ? (close - prevClose) / prevClose * 100 : null;
|
||
var ret3d = typeof df.ret5d === 'number' ? df.ret5d * 0.6 : null; // ret5d 프록시
|
||
var disparity = (close > 0 && ma20 > 0) ? (close / ma20 - 1) * 100 : null;
|
||
|
||
var timingScoreExit = alphaRow && typeof alphaRow.timing_score_exit === 'number' ? alphaRow.timing_score_exit : 0;
|
||
var distributionRiskScore = distRow && typeof distRow["distribution_risk_score"] === 'number' ? distRow["distribution_risk_score"] : 0;
|
||
var lateChaseRiskScore = alphaRow && typeof alphaRow["late_chase_risk_score"] === 'number' ? alphaRow["late_chase_risk_score"] : 0;
|
||
|
||
var score = 50;
|
||
var reasons = [];
|
||
|
||
if (ret3d !== null && ret3d >= 7) { score -= 30; reasons.push('ret3d_gte7'); }
|
||
if (disparity !== null && disparity > 10) { score -= 25; reasons.push('disparity_gt10'); }
|
||
if (ret1d !== null && ret1d >= 4 && volume !== null && avgVol5d !== null
|
||
&& avgVol5d > 0 && volume < avgVol5d * 0.9) { score -= 40; reasons.push('surge_day_low_vol'); }
|
||
if (rsi14 !== null && rsi14 > 75) { score -= 20; reasons.push('rsi14_gt75'); }
|
||
if (timingScoreExit >= 50) { score -= 50; reasons.push('timing_exit_gte50'); }
|
||
if (distributionRiskScore >= 70) { score -= 35; reasons.push('distribution_gte70'); }
|
||
if (lateChaseRiskScore >= 70) { score -= 30; reasons.push('late_chase_gte70'); }
|
||
|
||
if (volume !== null && avgVol5d !== null && avgVol5d > 0
|
||
&& volume >= avgVol5d * 1.5 && ret1d !== null && ret1d >= 2
|
||
&& ret3d !== null && ret3d < 5) { score += 25; reasons.push('quality_breakout_vol'); }
|
||
if (disparity !== null && disparity >= 0 && disparity < 6) { score += 15; reasons.push('disparity_healthy'); }
|
||
if (rsi14 !== null && rsi14 >= 45 && rsi14 <= 65) { score += 10; reasons.push('rsi14_healthy'); }
|
||
|
||
score = Math.max(0, Math.min(100, Math.round(score)));
|
||
var gate = score < 10 ? 'BLOCKED_LATE_CHASE' : score < 40 ? 'WATCH_COOLING_OFF' : 'PILOT_ALLOWED';
|
||
|
||
return {
|
||
ticker: h.ticker,
|
||
name: h.name || df.name || '',
|
||
breakout_quality_score: score,
|
||
breakout_quality_gate: gate,
|
||
reason_codes: reasons,
|
||
formula_id: 'BREAKOUT_QUALITY_GATE_V2',
|
||
version: '2026-05-20_HARNESS_V5'
|
||
};
|
||
}
|
||
|
||
// ── [2026-05-20_HARNESS_V5] H7: 가짜 매도 차단 — ANTI_WHIPSAW_HOLD_GATE_V1 ───
|
||
function calcAntiWhipsawGate_(h, df, kospiRet5d) {
|
||
var inst5d = typeof df.inst5d === 'number' ? df.inst5d : null;
|
||
var frg5d = typeof df.frg5d === 'number' ? df.frg5d : null;
|
||
var volSurge = typeof df.valSurgePct === 'number' ? df.valSurgePct : null;
|
||
var consecutiveSell5d = typeof df.consecutiveSellSignals5d === 'number'
|
||
? df.consecutiveSellSignals5d : 0;
|
||
|
||
var sectorRS5d = null;
|
||
if (typeof df.ret5d === 'number' && typeof kospiRet5d === 'number') {
|
||
var stockFactor = 1 + df.ret5d / 100;
|
||
var kospiFactor = 1 + kospiRet5d / 100;
|
||
sectorRS5d = kospiFactor > 0 ? stockFactor / kospiFactor * 100 : null;
|
||
}
|
||
|
||
var score = 0;
|
||
var reasons = [];
|
||
|
||
if (consecutiveSell5d >= 5) { score += 20; reasons.push('consecutive_sell_5d_gte5'); }
|
||
if (inst5d !== null && inst5d > 0) { score += 30; reasons.push('inst_net_buy'); }
|
||
if (frg5d !== null && frg5d > 0) { score += 20; reasons.push('frg_net_buy'); }
|
||
if (sectorRS5d !== null && sectorRS5d > 100) { score += 15; reasons.push('sector_outperforming'); }
|
||
if (volSurge !== null && volSurge >= 50) { score -= 25; reasons.push('vol_surge_50pct'); }
|
||
if (volSurge !== null && volSurge >= 100) { score -= 20; reasons.push('vol_surge_100pct'); }
|
||
|
||
score = Math.max(-50, Math.min(100, Math.round(score)));
|
||
|
||
// [V1.1] 자동 해제 조건 3개 — 충족 수에 따라 hold_days 결정
|
||
var wClose = h.close || df.close || 0;
|
||
var wMa20 = typeof df.ma20 === 'number' ? df.ma20 : 0;
|
||
var clearCnt = 0;
|
||
var clearList = [];
|
||
if (inst5d !== null && inst5d > 0) { clearCnt++; clearList.push('inst_net_buy'); }
|
||
if (frg5d !== null && frg5d > 0) { clearCnt++; clearList.push('frg_net_buy'); }
|
||
if (wMa20 > 0 && wClose > 0 && wClose > wMa20) { clearCnt++; clearList.push('price_above_ma20'); }
|
||
|
||
var gate, holdDays;
|
||
if (score >= 30) {
|
||
if (clearCnt >= 3) { gate = 'WHIPSAW_AUTO_RELEASED'; holdDays = 0; }
|
||
else if (clearCnt >= 2) { gate = 'WHIPSAW_WEAKENING'; holdDays = 1; }
|
||
else { gate = 'WHIPSAW_CONFIRMED'; holdDays = 3; }
|
||
} else if (score >= 10) {
|
||
gate = 'INCONCLUSIVE'; holdDays = 0;
|
||
} else {
|
||
gate = 'CONFIRMED_SELL'; holdDays = 0;
|
||
}
|
||
|
||
return {
|
||
ticker: h.ticker,
|
||
name: h.name || df.name || '',
|
||
anti_whipsaw_score: score,
|
||
anti_whipsaw_gate: gate,
|
||
anti_whipsaw_hold_days: holdDays,
|
||
clear_conditions_count: clearCnt,
|
||
clear_conditions: clearList,
|
||
reason_codes: reasons,
|
||
formula_id: 'ANTI_WHIPSAW_HOLD_GATE_V1',
|
||
version: '2026-05-24_V1.1'
|
||
};
|
||
}
|
||
|
||
// ── [2026-05-20_HARNESS_V5] H8: 4경로 결정론적 현금확보 라우터 ─────────────────
|
||
function calcSmartCashRaiseV2_(h, df, profitRow, priceRow, cashShortfallInfo) {
|
||
var posClass = String(h.positionClass || df.positionClass || '').toUpperCase();
|
||
var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : 50;
|
||
var profitStage = priceRow && priceRow.profit_lock_stage
|
||
? String(priceRow.profit_lock_stage)
|
||
: (profitRow ? String(profitRow.profit_preservation_state || 'NORMAL') : 'NORMAL');
|
||
var secularPass = priceRow && priceRow.secular_leader_gate_active === false; // PASS = not active restriction
|
||
var emergencyFull = !!(cashShortfallInfo && cashShortfallInfo.emergency_full_sell);
|
||
var stopPrice = priceRow && typeof priceRow.stop_price === 'number' ? priceRow.stop_price : 0;
|
||
var close = df.close || h.close || 0;
|
||
var breachImmediate = stopPrice > 0 && close > 0 && close < stopPrice;
|
||
var stopBreachGate = breachImmediate ? 'BREACH' : 'PASS';
|
||
|
||
var route, routeLabel, rationale;
|
||
|
||
if (emergencyFull || breachImmediate) {
|
||
route = 'ROUTE_D';
|
||
routeLabel = '긴급 전량매도';
|
||
rationale = emergencyFull ? 'emergency_full_sell=true' : 'close<stop_price(stop_breach_gate=BREACH)';
|
||
} else if (posClass.indexOf('SATELLITE') >= 0 && rsi14 >= 35) {
|
||
route = 'ROUTE_A';
|
||
routeLabel = '위성 비중 트림';
|
||
rationale = 'SATELLITE+RSI14(' + rsi14 + ')>=35';
|
||
} else if (rsi14 < 35) {
|
||
route = 'ROUTE_B';
|
||
routeLabel = '과매도 분할 매도';
|
||
rationale = 'RSI14(' + rsi14 + ')<35→K2_50/50';
|
||
} else if (posClass.indexOf('CORE') >= 0
|
||
&& (profitStage === 'PROFIT_LOCK_STAGE_20'
|
||
|| profitStage === 'PROFIT_LOCK_STAGE_30'
|
||
|| profitStage === 'PROFIT_LOCK_20'
|
||
|| profitStage === 'PROFIT_LOCK_30')
|
||
&& secularPass) {
|
||
route = 'ROUTE_C';
|
||
routeLabel = '코어 익절 잠금';
|
||
rationale = 'CORE+' + profitStage + '+secular_PASS';
|
||
} else {
|
||
route = 'NO_ACTION';
|
||
routeLabel = '현금확보 비대상';
|
||
rationale = 'no_condition_met';
|
||
}
|
||
|
||
return {
|
||
ticker: h.ticker,
|
||
name: h.name || df.name || '',
|
||
smart_cash_raise_route: route,
|
||
route_label: routeLabel,
|
||
rationale: rationale,
|
||
profit_lock_stage: profitStage,
|
||
stop_breach_gate: stopBreachGate,
|
||
emergency_full_sell: emergencyFull,
|
||
rebound_wait_pct: route === 'ROUTE_B' ? 50 : 0,
|
||
formula_id: 'SMART_CASH_RAISE_V2',
|
||
version: '2026-05-20_HARNESS_V5'
|
||
};
|
||
}
|
||
|
||
// ── [2026-05-20_HARNESS_V5] Gate 4b: O'Neil Follow-Through Day — FOLLOW_THROUGH_DAY_CONFIRM_V1
|
||
// 돌파 당일(Day 0)에 즉시 매수 금지. Day 2~7 사이에 수익률+거래량 조건 충족 시만 BUY_PILOT_ALLOWED.
|
||
// daysSinceBreakout / retSinceBreakout / volumeBreakoutDay 이 df에 없으면 프록시 계산으로 후퇴.
|
||
function calcFollowThroughDayConfirm_(h, df) {
|
||
var ticker = h.ticker;
|
||
var name = h.name || df.name || '';
|
||
|
||
// ── 입력 수집 (실제 필드 우선, 프록시 fallback) ──────────────────────────
|
||
var daysSince = typeof df.daysSinceBreakout === 'number' ? df.daysSinceBreakout : null;
|
||
var retSince = typeof df.retSinceBreakout === 'number' ? df.retSinceBreakout : null;
|
||
var volToday = typeof df.volume === 'number' ? df.volume : null;
|
||
var volBreakout = typeof df.volumeBreakoutDay === 'number' ? df.volumeBreakoutDay : null;
|
||
|
||
// 프록시: daysSinceBreakout — close vs MA20 돌파여부로 추정
|
||
// MA20 이하에서 위로 올라온 직후이면 daysSince=0, 그 이전이면 null
|
||
if (daysSince === null) {
|
||
var close = df.close || h.close || 0;
|
||
var ma20 = df.ma20 || 0;
|
||
var prevClose = df.prevClose || close;
|
||
// 오늘 ma20 상향 돌파면 Day 0
|
||
if (close > 0 && ma20 > 0 && close > ma20 && prevClose <= ma20) {
|
||
daysSince = 0;
|
||
}
|
||
// 이미 ma20 위에 있고 ret5d 존재 → days를 ret5d로 추정(보수적 5일 상한)
|
||
else if (close > 0 && ma20 > 0 && close > ma20 && typeof df.ret5d === 'number') {
|
||
// 5일 기준 프록시: 상승률이 클수록 이미 많이 경과했다고 가정
|
||
daysSince = df.ret5d >= 7 ? 8 : df.ret5d >= 3 ? 4 : 2;
|
||
}
|
||
}
|
||
|
||
// 프록시: retSinceBreakout — ret5d 사용
|
||
if (retSince === null && typeof df.ret5d === 'number') {
|
||
retSince = df.ret5d;
|
||
}
|
||
|
||
// 프록시: volBreakoutDay — avgVolume5d 사용
|
||
if (volBreakout === null && typeof df.avgVolume5d === 'number') {
|
||
volBreakout = df.avgVolume5d;
|
||
}
|
||
|
||
// ── 상태 분류 ──────────────────────────────────────────────────────────────
|
||
var state, result, reasons = [];
|
||
|
||
if (daysSince === null) {
|
||
state = 'PENDING_DATA';
|
||
result = 'WATCH_NO_BREAKOUT_TRACKED';
|
||
reasons.push('days_since_breakout_null');
|
||
|
||
} else if (daysSince === 0) {
|
||
state = 'BREAKOUT_DAY_1';
|
||
result = 'WATCH_FOLLOW_THROUGH_PENDING';
|
||
reasons.push('day0_no_immediate_buy');
|
||
|
||
} else if (daysSince > 7) {
|
||
state = 'EXTENDED_FOLLOW';
|
||
result = 'WATCH_TOO_LATE';
|
||
reasons.push('days_since_gt7');
|
||
|
||
} else {
|
||
// daysSince 2~7 범위
|
||
var volOk = (volToday !== null && volBreakout !== null && volBreakout > 0)
|
||
? (volToday >= volBreakout * 0.9) : true; // 데이터 없으면 통과
|
||
var retOk = (retSince !== null) ? (retSince >= 1.5) : false;
|
||
|
||
if (retOk && volOk) {
|
||
state = 'FOLLOW_THROUGH_OK';
|
||
result = 'BUY_PILOT_ALLOWED';
|
||
reasons.push('days_' + daysSince + '_ret_' + (retSince !== null ? retSince.toFixed(1) : 'N/A'));
|
||
if (volOk) reasons.push('vol_confirmed');
|
||
} else {
|
||
state = 'FOLLOW_THROUGH_FAIL';
|
||
result = 'WATCH_RESET_REQUIRED';
|
||
if (!retOk) reasons.push('ret_since_lt1.5pct');
|
||
if (!volOk) reasons.push('vol_lt90pct_breakout_day');
|
||
}
|
||
}
|
||
|
||
return {
|
||
ticker: ticker,
|
||
name: name,
|
||
days_since_breakout: daysSince,
|
||
ret_since_breakout: retSince,
|
||
vol_ratio_vs_breakout_day: (volToday !== null && volBreakout !== null && volBreakout > 0)
|
||
? Math.round(volToday / volBreakout * 100) / 100 : null,
|
||
follow_through_state: state,
|
||
follow_through_result: result,
|
||
reason_codes: reasons,
|
||
formula_id: 'FOLLOW_THROUGH_DAY_CONFIRM_V1',
|
||
version: '2026-05-20_HARNESS_V5'
|
||
};
|
||
}
|
||
|
||
|
||
function calcApexExecutionHarness_(holdings, dfMap, sectorFlowData, kospiRet5d, h1, h2, h3, h4, orderBlueprint, cashShortfallInfo, marketRegime) {
|
||
var alphaLead = [];
|
||
var followThrough = [];
|
||
var distribution = [];
|
||
var profitPreservation = [];
|
||
var entryFreshness = [];
|
||
var cashRaisePlan = [];
|
||
var reboundTriggers = [];
|
||
var smartSellQty = [];
|
||
var sellValuePreservation = [];
|
||
var executionQuality = [];
|
||
var buyPermission = [];
|
||
var limitPolicy = [];
|
||
var benchmarkRelativeRows = [];
|
||
var indexRelativeHealthRows = [];
|
||
var saqgRows = [];
|
||
var cashCreationLockRows = [];
|
||
// ── [2026-05-20_HARNESS_V5] 신규 V5 게이트 결과 배열
|
||
var breakoutQualityGate = [];
|
||
var antiWhipsawGate = [];
|
||
var smartCashRaiseV2 = [];
|
||
var followThroughConfirm = [];
|
||
var blockCount = 0;
|
||
var regime = marketRegime || 'UNKNOWN';
|
||
|
||
var priceMap = {};
|
||
(h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; });
|
||
var sellQtyMap = {};
|
||
(h3.sellQty || []).forEach(function(s) { sellQtyMap[s.ticker] = s; });
|
||
|
||
holdings.forEach(function(h) {
|
||
var df = dfMap[h.ticker] || {};
|
||
var distRow = calcDistributionRiskRow_(h, df, kospiRet5d, sectorFlowData);
|
||
// [PROPOSAL50] P1-B: DSD V1.1 — SIG_7/SIG_8 추가, weighted_sum 5.0/3.0 상향
|
||
applyDsdV1_1Signals_([distRow], dfMap);
|
||
var alphaRow = calcAlphaLeadRow_(h, df, sectorFlowData, distRow);
|
||
var ftRow = calcFollowThroughRow_(h, df);
|
||
var priceRow = priceMap[h.ticker] || {};
|
||
var profitRow = calcProfitPreservationRow_(h, df, priceRow, distRow);
|
||
var orderRow = findOrderBlueprintRow_(orderBlueprint, h.ticker) || {};
|
||
var eqRow = calcExecutionQualityRow_(h.ticker, orderRow, df);
|
||
var saqgState = df.saqg_v1 || (h.position_type === 'core' ? 'EXEMPT' : 'WATCHLIST_ONLY');
|
||
var cand = findCandidateByTicker_(h2.candidates, h.ticker) || {};
|
||
var sq = sellQtyMap[h.ticker] || {};
|
||
var tradePlan = calcApexTradePlan_(
|
||
h, df, h1, alphaRow, ftRow, distRow, priceRow, orderRow, sq, profitRow, cashShortfallInfo, saqgState
|
||
);
|
||
var buyState = tradePlan.buyState;
|
||
var buyReasons = tradePlan.buyReasons;
|
||
if (buyState === 'BLOCKED') blockCount++;
|
||
var style = tradePlan.style;
|
||
var immediateQty = tradePlan.immediateQty;
|
||
var reboundQty = tradePlan.reboundQty;
|
||
var k2Emergency = tradePlan.k2Emergency;
|
||
var tranchePhase = tradePlan.tranchePhase;
|
||
var currentTrancheAllowedPct = tradePlan.currentTrancheAllowedPct;
|
||
var nextTrancheCondition = tradePlan.nextTrancheCondition;
|
||
var normalizedSellPrice = tradePlan.normalizedSellPrice;
|
||
var normalizedBuyPrice = tradePlan.normalizedBuyPrice;
|
||
var htsLimitPrice = tradePlan.htsLimitPrice;
|
||
var close = h.close || df.close || 0;
|
||
var atr20 = df.atr20 || 0;
|
||
var holdingQty = h.holdingQty || 0;
|
||
var prevClose = df.prevClose || close;
|
||
|
||
// ── [2026-05-20_HARNESS_V5] V5 게이트 산출 ──────────────────────────────
|
||
var bqRow = calcBreakoutQualityGate_(h, df, alphaRow, distRow);
|
||
var awRow = calcAntiWhipsawGate_(h, df, kospiRet5d);
|
||
var scrV2 = calcSmartCashRaiseV2_(h, df, profitRow, priceRow, cashShortfallInfo);
|
||
var ftdRow = calcFollowThroughDayConfirm_(h, df);
|
||
|
||
// H6: 뒷박 차단 — BUY 상태 override
|
||
if (bqRow.breakout_quality_gate === 'BLOCKED_LATE_CHASE') {
|
||
if (buyState !== 'BLOCKED') { buyState = 'BLOCKED'; }
|
||
buyReasons.push('breakout_quality_BLOCKED_LATE_CHASE');
|
||
blockCount++;
|
||
}
|
||
|
||
// Gate 4b: FTD 미확인 — BUY 차단 (돌파 당일 즉시 매수 금지, 데이터 부재 시 WATCH로 후퇴)
|
||
if (ftdRow.follow_through_result === 'WATCH_FOLLOW_THROUGH_PENDING'
|
||
|| ftdRow.follow_through_result === 'WATCH_RESET_REQUIRED') {
|
||
if (buyState === 'ALLOW_PILOT') {
|
||
buyState = 'WATCH'; // PILOT → WATCH (BLOCKED 아님 — 관찰 유지)
|
||
buyReasons.push('ftd_' + ftdRow.follow_through_result);
|
||
}
|
||
} else if (ftdRow.follow_through_result === 'WATCH_TOO_LATE') {
|
||
if (buyState === 'ALLOW_PILOT') {
|
||
buyState = 'WATCH';
|
||
buyReasons.push('ftd_WATCH_TOO_LATE');
|
||
}
|
||
}
|
||
|
||
// H7: 가짜 매도 차단 — V1.1: CONFIRMED/WEAKENING만 보류 표기 (AUTO_RELEASED 제외)
|
||
if (awRow.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || awRow.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') {
|
||
buyReasons.push('whipsaw_hold_' + (awRow.anti_whipsaw_hold_days || 1) + 'd');
|
||
}
|
||
|
||
distribution.push(distRow);
|
||
alphaLead.push(alphaRow);
|
||
followThrough.push(ftRow);
|
||
profitPreservation.push(profitRow);
|
||
benchmarkRelativeRows.push({
|
||
ticker: h.ticker,
|
||
name: h.name || df.name || '',
|
||
stock_drawdown_from_high_pct: typeof df.stock_drawdown_from_high_pct === 'number' ? df.stock_drawdown_from_high_pct : null,
|
||
excess_drawdown_pctp: typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null,
|
||
recovery_ratio_5d: typeof df.recovery_ratio_5d === 'number' ? df.recovery_ratio_5d : null,
|
||
recovery_ratio_20d: typeof df.recovery_ratio_20d === 'number' ? df.recovery_ratio_20d : null,
|
||
downside_beta: typeof df.downside_beta === 'number' ? df.downside_beta : null,
|
||
rs_line_20d_slope: typeof df.rs_line_20d_slope === 'number' ? df.rs_line_20d_slope : null,
|
||
rs_line_60d_slope: typeof df.rs_line_60d_slope === 'number' ? df.rs_line_60d_slope : null,
|
||
brt_verdict: df.brt_verdict || 'UNKNOWN',
|
||
brt_method: df.brt_method || 'DATA_MISSING',
|
||
formula_id: 'BENCHMARK_RELATIVE_TIMESERIES_V1'
|
||
});
|
||
var indexRelRow = calcIndexRelativeHealthGate_(h, df, kospiRet5d);
|
||
indexRelativeHealthRows.push(indexRelRow);
|
||
saqgRows.push({
|
||
ticker: h.ticker,
|
||
name: h.name || df.name || '',
|
||
position_type: h.position_type || 'unknown',
|
||
saqg_v1: saqgState,
|
||
saqg_penalty: typeof df.saqg_penalty === 'number' ? df.saqg_penalty : null,
|
||
saqg_failed_filters: df.saqg_failed_filters || '',
|
||
hts_allowed: saqgState === 'ELIGIBLE' || saqgState === 'EXEMPT',
|
||
formula_id: 'SATELLITE_ALPHA_QUALITY_GATE_V1'
|
||
});
|
||
breakoutQualityGate.push(bqRow);
|
||
antiWhipsawGate.push(awRow);
|
||
smartCashRaiseV2.push(scrV2);
|
||
followThroughConfirm.push(ftdRow);
|
||
executionQuality.push(eqRow);
|
||
|
||
// ── 진입 신선도 게이트 (ENTRY_FRESHNESS_GATE_V1) ───────────────────────
|
||
var freshnessState = 'FRESH_PILOT';
|
||
var freshnessReasons = [];
|
||
if (bqRow.breakout_quality_gate === 'BLOCKED_LATE_CHASE' || alphaRow["late_chase_risk_score"] >= 70) {
|
||
freshnessState = 'BLOCK_LATE_CHASE';
|
||
freshnessReasons.push('late_chase');
|
||
} else if (ftRow.follow_through_state === 'WAIT_PULLBACK' || ftdRow.follow_through_result === 'WATCH_TOO_LATE' || ftdRow.follow_through_result === 'WATCH_RESET_REQUIRED') {
|
||
freshnessState = 'PULLBACK_WAIT';
|
||
freshnessReasons.push('follow_through_wait');
|
||
} else if (distRow.pre_distribution_warning === 'EARLY_WARNING') {
|
||
freshnessState = 'STALE_REVIEW';
|
||
freshnessReasons.push('pre_distribution_warning');
|
||
} else if (buyState === 'WATCH' || buyState === 'BLOCKED') {
|
||
freshnessState = 'WATCH_FRESHNESS';
|
||
freshnessReasons.push('buy_state_' + buyState.toLowerCase());
|
||
}
|
||
if (indexRelRow.relative_health_state === 'DECOUPLED' || indexRelRow.relative_health_state === 'OVER_EXTENDED') {
|
||
freshnessState = freshnessState === 'FRESH_PILOT' ? 'WATCH_FRESHNESS' : freshnessState;
|
||
freshnessReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase());
|
||
if (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON') {
|
||
buyState = 'WATCH';
|
||
buyReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase());
|
||
}
|
||
} else if (indexRelRow.relative_health_state === 'UNDERPERFORMING') {
|
||
if (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON') {
|
||
buyState = 'WATCH';
|
||
}
|
||
freshnessReasons.push('index_relative_underperforming');
|
||
}
|
||
entryFreshness.push({
|
||
ticker: h.ticker,
|
||
name: h.name || df.name || '',
|
||
alpha_lead_score: alphaRow.alpha_lead_score != null ? alphaRow.alpha_lead_score : null,
|
||
["late_chase_risk_score"]: alphaRow["late_chase_risk_score"] != null ? alphaRow["late_chase_risk_score"] : null,
|
||
follow_through_state: ftRow.follow_through_state || null,
|
||
breakout_quality_gate: bqRow.breakout_quality_gate || null,
|
||
pre_distribution_warning: distRow.pre_distribution_warning || 'NONE',
|
||
t20_alpha_gate: null,
|
||
freshness_state: freshnessState,
|
||
reason_codes: freshnessReasons,
|
||
formula_id: 'ENTRY_FRESHNESS_GATE_V1'
|
||
});
|
||
|
||
// ── 회복 보존 매도 게이트 (SELL_VALUE_PRESERVATION_GATE_V1) ─────────────
|
||
var sellPreserveState = 'HOLD';
|
||
var sellPreserveReasons = [];
|
||
if (scrV2.smart_cash_raise_route === 'ROUTE_D' || k2Emergency || scrV2.stop_breach_gate === 'BREACH') {
|
||
sellPreserveState = 'EMERGENCY_EXIT';
|
||
sellPreserveReasons.push('route_d_or_breach');
|
||
} else if (awRow.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || awRow.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') {
|
||
sellPreserveState = 'REBOUND_CONFIRM_HOLD';
|
||
sellPreserveReasons.push('whipsaw_hold_' + (awRow.anti_whipsaw_hold_days || 1) + 'd');
|
||
} else if (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0) {
|
||
sellPreserveState = 'STAGED_REBOUND';
|
||
sellPreserveReasons.push('rebound_wait_qty');
|
||
} else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_10'
|
||
|| profitRow.profit_preservation_state === 'PROFIT_LOCK_20'
|
||
|| profitRow.profit_preservation_state === 'PROFIT_LOCK_30'
|
||
|| profitRow.profit_preservation_state === 'APEX_TRAILING') {
|
||
sellPreserveState = 'PRESERVE_TIERED';
|
||
sellPreserveReasons.push('profit_lock');
|
||
} else if (distRow.anti_distribution_state === 'BLOCK_BUY') {
|
||
sellPreserveState = 'TRIM_ONLY';
|
||
sellPreserveReasons.push('distribution_exit');
|
||
} else if (indexRelRow.relative_health_state === 'OVER_EXTENDED' || indexRelRow.relative_health_state === 'DECOUPLED') {
|
||
if (style !== 'OVERSOLD_REBOUND_SELL') {
|
||
sellPreserveState = 'TRIM_ONLY';
|
||
}
|
||
sellPreserveReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase());
|
||
}
|
||
sellValuePreservation.push({
|
||
ticker: h.ticker,
|
||
name: h.name || df.name || '',
|
||
profit_preservation_state: profitRow.profit_preservation_state || 'NORMAL',
|
||
cash_raise_group: style,
|
||
anti_whipsaw_gate: awRow.anti_whipsaw_gate || null,
|
||
immediate_qty: immediateQty > 0 ? immediateQty : null,
|
||
rebound_wait_qty: reboundQty > 0 ? reboundQty : null,
|
||
auto_trailing_stop: profitRow.auto_trailing_stop || null,
|
||
sell_value_preservation_state: sellPreserveState,
|
||
reason_codes: sellPreserveReasons,
|
||
formula_id: 'SELL_VALUE_PRESERVATION_GATE_V1'
|
||
});
|
||
|
||
// K1: 트랜치 엔진 결과 포함 buy_permission_json
|
||
buyPermission.push({
|
||
ticker: h.ticker,
|
||
name: h.name || df.name || '',
|
||
buy_permission_state: buyState,
|
||
max_tranche_pct: buyState === 'ALLOW_PILOT' ? 30 : buyState === 'ALLOW_ADD_ON' ? 60 : 0,
|
||
tranche_phase: tranchePhase,
|
||
current_tranche_allowed_pct: currentTrancheAllowedPct,
|
||
next_tranche_condition: nextTrancheCondition,
|
||
blocked_reason_codes: buyReasons,
|
||
position_type: h.position_type || 'unknown',
|
||
brt_verdict: df.brt_verdict || null,
|
||
saqg_v1: saqgState,
|
||
rs_verdict: df.rs_verdict || null,
|
||
composite_verdict: df.composite_verdict || null,
|
||
rag_v1: df.rag_v1 || null,
|
||
formula_id: 'BUY_PERMISSION_MATRIX_V1+STAGED_ENTRY_TRANCHE_V1'
|
||
});
|
||
|
||
// K2: 반등 대기 분할 매도 결과 포함 cash_raise_plan_json
|
||
cashRaisePlan.push({
|
||
ticker: h.ticker,
|
||
name: h.name || df.name || '',
|
||
rank: cand.rank || null,
|
||
execution_style: style,
|
||
immediate_qty: immediateQty > 0 ? immediateQty : null,
|
||
rebound_wait_qty: reboundQty > 0 ? reboundQty : null,
|
||
emergency_full_sell: k2Emergency,
|
||
max_daily_qty: Math.floor(holdingQty * 0.50),
|
||
expected_immediate_krw: immediateQty > 0 ? Math.round(immediateQty * close) : 0,
|
||
cash_shortfall_min_krw: (cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw) || 0,
|
||
formula_id: 'SMART_CASH_RAISE_PLAN_V1+K2_STAGED_REBOUND_SELL'
|
||
});
|
||
|
||
// K2: 반등 트리거 조건부 잔여 수량
|
||
var reboundTriggerPrice = null;
|
||
if (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0) {
|
||
// 반등 트리거: prevClose + 0.5×ATR 또는 단순 close + 0.3×ATR
|
||
reboundTriggerPrice = atr20 > 0
|
||
? tickNormalize_((prevClose > 0 ? prevClose : close) + atr20 * 0.5)
|
||
: null;
|
||
}
|
||
reboundTriggers.push({
|
||
ticker: h.ticker,
|
||
rebound_trigger_state: (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0)
|
||
? 'WAIT_REBOUND_TRIGGER' : 'NOT_APPLICABLE',
|
||
trigger_price: reboundTriggerPrice,
|
||
rebound_sell_qty: reboundQty > 0 ? reboundQty : null,
|
||
emergency_override: k2Emergency,
|
||
formula_id: 'REBOUND_SELL_TRIGGER_V1'
|
||
});
|
||
|
||
smartSellQty.push({
|
||
ticker: h.ticker,
|
||
immediate_sell_qty: immediateQty > 0 ? immediateQty : null,
|
||
staged_total_qty: (typeof sq.sell_qty === 'number' && sq.sell_qty > 0) ? sq.sell_qty : null,
|
||
rebound_wait_qty: reboundQty > 0 ? reboundQty : null,
|
||
emergency_full_sell: k2Emergency,
|
||
expected_cash_recovered_krw: immediateQty > 0 ? Math.round(immediateQty * close) : 0,
|
||
formula_id: 'SELL_QUANTITY_ALLOCATOR_V1+K2_STAGED_REBOUND_SELL'
|
||
});
|
||
|
||
// J5: 스타일별 실제 지정가 산출 결과 포함 limit_price_policy_json
|
||
limitPolicy.push({
|
||
ticker: h.ticker,
|
||
execution_style: style,
|
||
sell_limit_price: normalizedSellPrice,
|
||
buy_limit_price: normalizedBuyPrice,
|
||
hts_limit_price: htsLimitPrice,
|
||
tick_status: htsLimitPrice ? 'TICK_OK' : 'NO_EXECUTION_PRICE',
|
||
sell_price_basis: style === 'URGENT_LIQUIDITY_TRIM' ? 'min(close,prevClose×0.998)'
|
||
: style === 'OVERSOLD_REBOUND_SELL' ? 'close_no_undercut'
|
||
: style === 'DISTRIBUTION_EXIT' ? 'close-0.25×ATR20'
|
||
: style === 'PROFIT_PROTECT_TRIM' ? 'ratchet_stop_or_close×0.999'
|
||
: 'close',
|
||
formula_id: 'LIMIT_PRICE_POLICY_V1'
|
||
});
|
||
});
|
||
|
||
// K3: 국면·섹터 연계 H2 동적 우선순위
|
||
var regimeAdjPriority = calcRegimeAdjustedSellPriority_(
|
||
h2.candidates, regime, dfMap, kospiRet5d
|
||
);
|
||
|
||
// ── [2026-05-21_CLA_HARNESS_V1] SATELLITE_FAILURE_GATE_V1 ────────────────────
|
||
var satelliteRowsForSFG = [];
|
||
holdings.forEach(function(h) {
|
||
if (h.position_type !== 'core') {
|
||
var df = dfMap[h.ticker] || {};
|
||
satelliteRowsForSFG.push({
|
||
composite_verdict: df.composite_verdict || null,
|
||
rs_verdict: df.rs_verdict || null,
|
||
ret20d: typeof df.ret20d === 'number' ? df.ret20d : null,
|
||
excess_ret_10d: typeof df.excess_ret_10d === 'number' ? df.excess_ret_10d : null
|
||
});
|
||
}
|
||
});
|
||
var sfgResult = calcSatelliteFailureGate_(satelliteRowsForSFG);
|
||
var sapgResult = calcSatelliteAggregatePnlGate_(holdings);
|
||
holdings.forEach(function(h) {
|
||
var df = dfMap[h.ticker] || {};
|
||
cashCreationLockRows.push(calcCashCreationPurposeLockRow_(h, df, sfgResult));
|
||
});
|
||
|
||
|
||
// ── [2026-05-21_AEW_V1] ALPHA_EVALUATION_WINDOW_V1 ──────────────────────────
|
||
var aewRows = calcAlphaEvaluationWindow_(holdings, dfMap);
|
||
|
||
// SFG-1: TRIGGERED 시 위성 BUY 전면 차단 (post-processing)
|
||
if (sfgResult.sfg_v1 === 'TRIGGERED' || sapgResult.sapg_status === 'SAPG_CRITICAL') {
|
||
buyPermission.forEach(function(bp) {
|
||
var h = holdings.find(function(x) { return x.ticker === bp.ticker; });
|
||
if (h && h.position_type !== 'core') {
|
||
if (bp.buy_permission_state !== 'BLOCKED') {
|
||
bp.buy_permission_state = 'BLOCKED';
|
||
bp.blocked_reason_codes = (bp.blocked_reason_codes || []).concat([
|
||
sfgResult.sfg_v1 === 'TRIGGERED' ? 'sfg_v1_TRIGGERED' : 'sapg_CRITICAL'
|
||
]);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── [QEH010] WHIPSAW V1.1 → order_blueprint validation_status 소급 차단 ──
|
||
// V1.1: WHIPSAW_CONFIRMED(hold_3d) + WHIPSAW_WEAKENING(hold_1d) 차단
|
||
// WHIPSAW_AUTO_RELEASED(hold_0d)은 자동 해제 — 차단 안 함
|
||
var whipsawTickers_ = {};
|
||
antiWhipsawGate.forEach(function(aw) {
|
||
if (aw.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || aw.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') {
|
||
whipsawTickers_[aw.ticker] = aw.anti_whipsaw_hold_days || 1;
|
||
}
|
||
});
|
||
var SELL_ORDER_TYPES_ = { SELL: 1, TRIM: 1, EXIT_100: 1, EXIT_FULL: 1 };
|
||
orderBlueprint.forEach(function(bp) {
|
||
var wHoldDays = whipsawTickers_[bp.ticker];
|
||
if (wHoldDays
|
||
&& SELL_ORDER_TYPES_[bp.order_type]
|
||
&& bp.validation_status === 'PASS') {
|
||
bp.validation_status = 'BLOCKED';
|
||
bp.rationale_code = 'WHIPSAW_V1_1:hold_' + wHoldDays + 'd';
|
||
}
|
||
});
|
||
|
||
// ── [2026-05-20_HARNESS_V5] V5 포트폴리오 레벨 집계
|
||
var smartCashRaiseRoute = 'NO_ACTION';
|
||
for (var sci = 0; sci < smartCashRaiseV2.length; sci++) {
|
||
if (smartCashRaiseV2[sci].smart_cash_raise_route !== 'NO_ACTION') {
|
||
smartCashRaiseRoute = smartCashRaiseV2[sci].smart_cash_raise_route;
|
||
break; // 첫 번째 실제 경로를 포트폴리오 레벨 대표 경로로 설정
|
||
}
|
||
}
|
||
|
||
return {
|
||
alpha_lead_json: alphaLead,
|
||
follow_through_json: followThrough,
|
||
distribution_risk_json: distribution,
|
||
profit_preservation_json: profitPreservation,
|
||
entry_freshness_json: entryFreshness,
|
||
cash_raise_plan_json: cashRaisePlan,
|
||
rebound_sell_trigger_json: reboundTriggers,
|
||
smart_sell_quantities_json: smartSellQty,
|
||
sell_value_preservation_json: sellValuePreservation,
|
||
execution_quality_json: executionQuality,
|
||
buy_permission_json: buyPermission,
|
||
limit_price_policy_json: limitPolicy,
|
||
regime_adjusted_sell_priority_json: regimeAdjPriority,
|
||
benchmark_relative_timeseries_json: benchmarkRelativeRows,
|
||
index_relative_health_json: indexRelativeHealthRows,
|
||
saqg_json: saqgRows,
|
||
cash_creation_purpose_lock_json: cashCreationLockRows,
|
||
// ── [2026-05-20_HARNESS_V5] 신규 V5 출력 ──────────────────────────────
|
||
breakout_quality_gate_json: breakoutQualityGate,
|
||
anti_whipsaw_gate_json: antiWhipsawGate,
|
||
smart_cash_raise_json: smartCashRaiseV2,
|
||
smart_cash_raise_route: smartCashRaiseRoute,
|
||
follow_through_confirm_json: followThroughConfirm,
|
||
breakout_quality_gate_lock: true,
|
||
anti_whipsaw_gate_lock: true,
|
||
follow_through_lock: true,
|
||
follow_through_confirm_lock: true,
|
||
apex_block_count: blockCount,
|
||
// ── [2026-05-21_CLA_HARNESS_V1] 신규 하네스 출력 ──────────────────────────
|
||
satellite_failure_gate_json: sfgResult,
|
||
sapg_json: sapgResult,
|
||
// ── [2026-05-21_AEW_V1] ─────────────────────────────────────────────────────
|
||
alpha_evaluation_window_json: aewRows,
|
||
sfg_v1_lock: true
|
||
};
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// [2026-05-23_PROPOSAL46] PA1~PA5 신규 하네스 calc 함수
|
||
// spec/13b_harness_formulas.yaml: PA1 PREDICTIVE_ALPHA_ENGINE_V1
|
||
// PA2 ANTI_LATE_ENTRY_GATE_V2
|
||
// PA3 CASH_PRESERVATION_SELL_ENGINE_V2
|
||
// PA4 MACRO_EVENT_SYNCHRONIZER_V1
|
||
// PA5 CONSISTENCY_VALIDATOR_V2
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* [PROPOSAL47_B6 / PROPOSAL48_B6_FALLBACK] prediction_accuracy_rate 읽기.
|
||
* 우선순위: ① monthly_history.prediction_accuracy_rate
|
||
* ② settings.prediction_accuracy_rate
|
||
* ③ 상수 기본값 48.48 (운영 중 실측값으로 교체 예정)
|
||
* 값이 0~1 범위면 *100 변환, 0~100 범위면 그대로 사용.
|
||
*/
|
||
var PREDICTION_ACCURACY_RATE_DEFAULT_ = 48.48; // 2026-05-23 실측, 매월 갱신
|
||
|
||
function getPredictionAccuracyRate_() {
|
||
function parseAccuracy_(val) {
|
||
if (val === '' || val === null || val === undefined) return null;
|
||
var num = typeof val === 'number' ? val : parseFloat(String(val));
|
||
if (isNaN(num)) return null;
|
||
return num <= 1 ? Math.round(num * 1000) / 10 : num;
|
||
}
|
||
|
||
try {
|
||
var ss = getSpreadsheet_();
|
||
|
||
// ① monthly_history 시트
|
||
var sh = ss.getSheetByName('monthly_history');
|
||
if (sh) {
|
||
var mhData = sh.getDataRange().getValues();
|
||
if (mhData && mhData.length >= 2) {
|
||
var header = mhData[0] || [];
|
||
var colIdx = -1;
|
||
for (var i = 0; i < header.length; i++) {
|
||
if (String(header[i]).trim().toLowerCase() === 'prediction_accuracy_rate') {
|
||
colIdx = i; break;
|
||
}
|
||
}
|
||
if (colIdx >= 0) {
|
||
for (var r = mhData.length - 1; r >= 1; r--) {
|
||
var parsed = parseAccuracy_(mhData[r][colIdx]);
|
||
if (parsed !== null) return parsed;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ② settings 시트 (Key-Value 구조)
|
||
var settingsSh = ss.getSheetByName('settings');
|
||
if (settingsSh) {
|
||
var sData = settingsSh.getDataRange().getValues();
|
||
for (var si = 0; si < sData.length; si++) {
|
||
var key = String(sData[si][0] || '').trim().toLowerCase();
|
||
if (key === 'prediction_accuracy_rate') {
|
||
var parsed2 = parseAccuracy_(sData[si][1]);
|
||
if (parsed2 !== null) return parsed2;
|
||
}
|
||
}
|
||
}
|
||
} catch(e) { /* fallback to default */ }
|
||
|
||
// ③ 상수 기본값
|
||
return PREDICTION_ACCURACY_RATE_DEFAULT_;
|
||
}
|
||
|
||
|
||
/**
|
||
* [PA1 V1.2] 팩터 가중치 오버라이드 읽��
|
||
* settings 시트의 pa1_w_<factor> 키-값을 읽어 기본값과 병합.
|
||
* 오버라이드가 존재하면 _source='DYNAMIC', 없으면 'STATIC'.
|
||
*/
|
||
function getPa1WeightOverrides_() {
|
||
var defaults = {
|
||
pullback_entry: 20, flow_strong: 20, rs_leader: 15,
|
||
volume_confirm: 15, rsi_healthy: 15, brt_leader: 15,
|
||
chase_risk: 25, distribution: 20, rsi_overbought: 20,
|
||
foreign_sell: 15, usd_krw_weak: 10, stale_position: 10,
|
||
_source: 'STATIC'
|
||
};
|
||
try {
|
||
var ss = getSpreadsheet_();
|
||
var sh = ss.getSheetByName('settings');
|
||
if (!sh) return defaults;
|
||
var data = sh.getDataRange().getValues();
|
||
var overrides = {};
|
||
for (var i = 0; i < data.length; i++) {
|
||
var key = String(data[i][0] || '').trim();
|
||
if (key.indexOf('pa1_w_') !== 0) continue;
|
||
var factorName = key.slice(6); // 'pa1_w_' = 6자
|
||
var val = parseFloat(String(data[i][1] || ''));
|
||
if (!isNaN(val) && val >= 0 && val <= 50) overrides[factorName] = val;
|
||
}
|
||
if (Object.keys(overrides).length === 0) return defaults;
|
||
var merged = {};
|
||
for (var k in defaults) merged[k] = defaults[k];
|
||
for (var k in overrides) merged[k] = overrides[k];
|
||
merged._source = 'DYNAMIC';
|
||
return merged;
|
||
} catch(e) {
|
||
return defaults;
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* [PA1 V1.3] T+5 피드백 기록
|
||
* STRONG_BUY_SIGNAL / EXIT_SIGNAL / TRIM_SIGNAL 예측 → pa1_feedback 시트 기록.
|
||
* V1.3: TRIM_SIGNAL 추가, signal_type 컬럼 추가 (BUY/SELL 분리 정확도 추적)
|
||
* evaluatePa1FeedbackBatch_() 주간 배치에서 결과를 평가.
|
||
*/
|
||
function recordPa1FeedbackEntry_(paeRows, dfMap) {
|
||
if (!paeRows || !paeRows.length) return;
|
||
// [V1.3] TRIM_SIGNAL 추가
|
||
var RECORD_VERDICTS = { STRONG_BUY_SIGNAL: 1, EXIT_SIGNAL: 1, TRIM_SIGNAL: 1 };
|
||
var toRecord = paeRows.filter(function(pa) { return !!RECORD_VERDICTS[pa.synthesis_verdict]; });
|
||
if (!toRecord.length) return;
|
||
try {
|
||
var ss = getSpreadsheet_();
|
||
var sh = ss.getSheetByName('pa1_feedback');
|
||
if (!sh) {
|
||
sh = ss.insertSheet('pa1_feedback');
|
||
sh.appendRow(['date','ticker','synthesis_verdict','direction_confidence',
|
||
'close_at_record','signal_type','t5_evaluated','t5_return_pct','t5_correct']);
|
||
} else {
|
||
// [V1.3] signal_type 컬럼 없으면 헤더 확인 — 없어도 appendRow는 동작함
|
||
}
|
||
var today = Utilities.formatDate(new Date(), 'Asia/Seoul', 'yyyy-MM-dd');
|
||
toRecord.forEach(function(pa) {
|
||
var df = dfMap[pa.ticker] || {};
|
||
var closeNow = df.close || 0;
|
||
var signalType = (pa.synthesis_verdict === 'STRONG_BUY_SIGNAL') ? 'BUY' : 'SELL';
|
||
sh.appendRow([today, pa.ticker, pa.synthesis_verdict,
|
||
pa.direction_confidence, closeNow, signalType, false, '', '']);
|
||
});
|
||
} catch(e) {
|
||
Logger.log('[PA1_FEEDBACK] recordPa1FeedbackEntry_ error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* [PA1 V1.3] 매도 PASS 정확도 조회
|
||
* pa1_feedback 시트에서 signal_type=SELL + t5_evaluated=true 행의 정확도 산출.
|
||
* @return {number|null} sell_pass_accuracy_rate (0~100) or null if insufficient data
|
||
*/
|
||
function getSellPassAccuracyRate_() {
|
||
try {
|
||
var ss = getSpreadsheet_();
|
||
var fbSh = ss.getSheetByName('pa1_feedback');
|
||
if (!fbSh) return null;
|
||
var data = fbSh.getDataRange().getValues();
|
||
if (data.length < 2) return null;
|
||
var header = data[0];
|
||
var COL = {};
|
||
header.forEach(function(h, i) { COL[String(h)] = i; });
|
||
if (COL['signal_type'] == null || COL['t5_evaluated'] == null || COL['t5_correct'] == null) return null;
|
||
var sellRows = data.slice(1).filter(function(row) {
|
||
return String(row[COL['signal_type']] || '').toUpperCase() === 'SELL'
|
||
&& (row[COL['t5_evaluated']] === true || String(row[COL['t5_evaluated']]).toUpperCase() === 'TRUE');
|
||
});
|
||
if (sellRows.length < 5) return null;
|
||
var correct = sellRows.filter(function(row) {
|
||
return row[COL['t5_correct']] === true || String(row[COL['t5_correct']]).toUpperCase() === 'TRUE';
|
||
}).length;
|
||
return Math.round(correct / sellRows.length * 1000) / 10;
|
||
} catch(e) {
|
||
Logger.log('[PA1_V1.3] getSellPassAccuracyRate_ error: ' + e.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* [PA1 V1.2] 주간 배치 — T+5(7캘린더일) 결과 평가 + prediction_accuracy_rate 갱신
|
||
* GAS 트리거에 주 1회 등록해 사용 (매주 월요일 권장).
|
||
*/
|
||
function evaluatePa1FeedbackBatch_() {
|
||
try {
|
||
var ss = getSpreadsheet_();
|
||
var fbSh = ss.getSheetByName('pa1_feedback');
|
||
if (!fbSh) { Logger.log('[PA1_V1.2] pa1_feedback 시트 없음'); return; }
|
||
|
||
var data = fbSh.getDataRange().getValues();
|
||
if (data.length < 2) return;
|
||
var header = data[0];
|
||
var COL = {};
|
||
header.forEach(function(h, i) { COL[String(h)] = i; });
|
||
var reqCols = ['date','ticker','synthesis_verdict','close_at_record','t5_evaluated','t5_return_pct','t5_correct'];
|
||
for (var ci = 0; ci < reqCols.length; ci++) {
|
||
if (COL[reqCols[ci]] == null) { Logger.log('[PA1_V1.2] 컬럼 누락: ' + reqCols[ci]); return; }
|
||
}
|
||
|
||
// 현재 종가 맵 (data_feed 시트)
|
||
var priceMap = {};
|
||
var dfSheet = ss.getSheetByName('data_feed');
|
||
if (dfSheet) {
|
||
var dfData = dfSheet.getDataRange().getValues();
|
||
if (dfData.length > 1) {
|
||
var dfHeader = dfData[0];
|
||
var tCol = dfHeader.indexOf('Ticker');
|
||
var cCol = dfHeader.indexOf('Close');
|
||
if (tCol >= 0 && cCol >= 0) {
|
||
for (var ri = 1; ri < dfData.length; ri++) {
|
||
var t = String(dfData[ri][tCol] || '').trim();
|
||
var c = parseFloat(String(dfData[ri][cCol] || ''));
|
||
if (t && !isNaN(c) && c > 0) priceMap[t] = c;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var todayMs = new Date().getTime();
|
||
var evalThisRun = 0;
|
||
for (var i = 1; i < data.length; i++) {
|
||
var row = data[i];
|
||
var evaled = row[COL['t5_evaluated']];
|
||
if (evaled === true || String(evaled).toUpperCase() === 'TRUE') continue;
|
||
var daysDiff = (todayMs - new Date(row[COL['date']]).getTime()) / 86400000;
|
||
if (daysDiff < 7) continue;
|
||
var ticker = String(row[COL['ticker']] || '');
|
||
var verdict = String(row[COL['synthesis_verdict']] || '');
|
||
var closeAt = parseFloat(String(row[COL['close_at_record']] || ''));
|
||
var closeNow = priceMap[ticker] || 0;
|
||
if (closeAt <= 0 || closeNow <= 0) continue;
|
||
var t5Ret = Math.round((closeNow - closeAt) / closeAt * 10000) / 100;
|
||
var isCorrect = (verdict === 'STRONG_BUY_SIGNAL') ? (t5Ret > 0) : (t5Ret < 0);
|
||
fbSh.getRange(i + 1, COL['t5_evaluated'] + 1).setValue(true);
|
||
fbSh.getRange(i + 1, COL['t5_return_pct'] + 1).setValue(t5Ret);
|
||
fbSh.getRange(i + 1, COL['t5_correct'] + 1).setValue(isCorrect ? 'CORRECT' : 'WRONG');
|
||
evalThisRun++;
|
||
}
|
||
|
||
// prediction_accuracy_rate 갱신 (최소 10건 평가 완료 후)
|
||
var freshData = fbSh.getDataRange().getValues();
|
||
var allEval = 0, allCorrect = 0;
|
||
for (var j = 1; j < freshData.length; j++) {
|
||
var ev = freshData[j][COL['t5_evaluated']];
|
||
if (ev !== true && String(ev).toUpperCase() !== 'TRUE') continue;
|
||
allEval++;
|
||
if (String(freshData[j][COL['t5_correct']] || '') === 'CORRECT') allCorrect++;
|
||
}
|
||
if (allEval >= 10) {
|
||
var newRate = Math.round(allCorrect / allEval * 1000) / 10;
|
||
var settingSh = ss.getSheetByName('settings');
|
||
if (settingSh) {
|
||
var sData = settingSh.getDataRange().getValues();
|
||
var updated = false;
|
||
for (var si = 0; si < sData.length; si++) {
|
||
if (String(sData[si][0] || '').trim().toLowerCase() === 'prediction_accuracy_rate') {
|
||
settingSh.getRange(si + 1, 2).setValue(newRate);
|
||
updated = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!updated) settingSh.appendRow(['prediction_accuracy_rate', newRate]);
|
||
Logger.log('[PA1_V1.2] prediction_accuracy_rate=' + newRate + '% (' + allCorrect + '/' + allEval + ')');
|
||
}
|
||
}
|
||
Logger.log('[PA1_V1.2] evaluatePa1FeedbackBatch_ 완료: 이번 평가=' + evalThisRun + '건');
|
||
|
||
// [PA1 V1.2] 정확도 기반 가중치 자동 조정 (평가 완료 후)
|
||
if (allEval >= 10) {
|
||
var accuracy7d = allCorrect / allEval;
|
||
adjustPaeWeights_();
|
||
}
|
||
} catch(e) {
|
||
Logger.log('[PA1_V1.2] evaluatePa1FeedbackBatch_ 오류: ' + e.message);
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* [PA1 V1.2] adjustPaeWeights_
|
||
* T+5 예측 정확도(7일) 기반으로 thesis/antithesis 가중치 자동 조정.
|
||
* 조정값을 settings 시트에 pa1_w_<factor> 형태로 기록 → 다음 실행 시 반영.
|
||
*/
|
||
function adjustPaeWeights_() {
|
||
try {
|
||
// 현재 precision 읽기
|
||
var accRate = getPredictionAccuracyRate_();
|
||
if (accRate === null) return; // 데이터 부족 시 조정 안 함
|
||
var accuracy = accRate / 100; // 0~1 범위로 변환
|
||
|
||
var ss = getSpreadsheet_();
|
||
var settingSh = ss.getSheetByName('settings');
|
||
if (!settingSh) return;
|
||
|
||
var sData = settingSh.getDataRange().getValues();
|
||
var currentWeights = {};
|
||
var rowIndex = {};
|
||
sData.forEach(function(row, i) {
|
||
var key = String(row[0] || '').trim().toLowerCase();
|
||
if (key.indexOf('pa1_w_') === 0) {
|
||
currentWeights[key] = parseFloat(String(row[1] || '')) || null;
|
||
rowIndex[key] = i + 1; // 1-based
|
||
}
|
||
});
|
||
|
||
// 기본 thesis/antithesis 총합 (12개 팩터 기본 가중치 합)
|
||
var DEFAULT_THESIS_TOTAL = 100; // 20+20+15+15+15+15
|
||
var DEFAULT_ANTI_TOTAL = 100; // 25+20+20+15+10+10
|
||
|
||
// 조정 방향 결정
|
||
var adjustThesis = 0;
|
||
var adjustAnti = 0;
|
||
if (accuracy < 0.55) {
|
||
// 정확도 낮음 → antithesis 강화 (+5% of base)
|
||
adjustThesis = -5;
|
||
adjustAnti = +5;
|
||
} else if (accuracy > 0.75) {
|
||
// 정확도 높음 → thesis 강화 (+3% of base)
|
||
adjustThesis = +3;
|
||
adjustAnti = 0;
|
||
} else {
|
||
Logger.log('[PA1_V1.2] adjustPaeWeights_: 정확도 정상범위(' + Math.round(accuracy*100) + '%) — 조정 불필요');
|
||
return;
|
||
}
|
||
|
||
// thesis 팩터 가중치 조정 (각 비례 분배)
|
||
var thesisFactors = ['pullback_entry','flow_strong','rs_leader','volume_confirm','rsi_healthy','brt_leader'];
|
||
var thesisDefaults = { pullback_entry: 20, flow_strong: 20, rs_leader: 15, volume_confirm: 15, rsi_healthy: 15, brt_leader: 15 };
|
||
thesisFactors.forEach(function(f) {
|
||
var key = 'pa1_w_' + f;
|
||
var baseW = thesisDefaults[f] || 0;
|
||
var currentW = currentWeights[key] != null ? currentWeights[key] : baseW;
|
||
var delta = Math.round(baseW / DEFAULT_THESIS_TOTAL * adjustThesis);
|
||
var newW = Math.max(5, Math.min(35, currentW + delta));
|
||
if (rowIndex[key]) {
|
||
settingSh.getRange(rowIndex[key], 2).setValue(newW);
|
||
} else {
|
||
settingSh.appendRow([key, newW]);
|
||
}
|
||
});
|
||
|
||
// antithesis 팩터 가중치 조정
|
||
var antiFactors = ['chase_risk','distribution','rsi_overbought','foreign_sell','usd_krw_weak','stale_position'];
|
||
var antiDefaults = { chase_risk: 25, distribution: 20, rsi_overbought: 20, foreign_sell: 15, usd_krw_weak: 10, stale_position: 10 };
|
||
antiFactors.forEach(function(f) {
|
||
var key = 'pa1_w_' + f;
|
||
var baseW = antiDefaults[f] || 0;
|
||
var currentW = currentWeights[key] != null ? currentWeights[key] : baseW;
|
||
var delta = Math.round(baseW / DEFAULT_ANTI_TOTAL * adjustAnti);
|
||
var newW = Math.max(5, Math.min(40, currentW + delta));
|
||
if (rowIndex[key]) {
|
||
settingSh.getRange(rowIndex[key], 2).setValue(newW);
|
||
} else {
|
||
settingSh.appendRow([key, newW]);
|
||
}
|
||
});
|
||
|
||
Logger.log('[PA1_V1.2] adjustPaeWeights_ 완료: accuracy=' + Math.round(accuracy*100) + '% adjustThesis=' + adjustThesis + ' adjustAnti=' + adjustAnti);
|
||
} catch(e) {
|
||
Logger.log('[PA1_V1.2] adjustPaeWeights_ 오류: ' + e.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* updatePa1WeightsManual_
|
||
* PA1 팩터 가중치를 Work-1 승인값으로 settings 시트에 직접 기록.
|
||
* 근거: 기존 8.0x 획일 비율(thesis=30, anti=240) → 2.6x 차별화(thesis=70, anti=185)
|
||
* 효과: 모든 종목이 EXIT(-83~-95)로 획일화됐던 synthesis가 종목별 차별화됨
|
||
* (예: 000270 기아 +20 BULLISH / 005930 삼성전자 -18 BEARISH 등)
|
||
* 사용법: GAS 에디터 → updatePa1WeightsManual_ 선택 → 실행
|
||
*/
|
||
function updatePa1WeightsManual_() {
|
||
try {
|
||
var ss = SpreadsheetApp.getActiveSpreadsheet();
|
||
var settingSh = ss.getSheetByName(SETTINGS_SHEET_NAME);
|
||
if (!settingSh) {
|
||
Logger.log('[updatePa1WeightsManual_] settings 시트를 찾을 수 없음');
|
||
return;
|
||
}
|
||
|
||
// Work-1 승인 PA1 가중치 (thesis 70pt, antithesis 185pt, ratio=2.6x)
|
||
var APPROVED_WEIGHTS = {
|
||
// Thesis 팩터 (개별종목 차별화 강화): 5→10~15
|
||
pa1_w_pullback_entry: 15, // 눌림목 진입 — 핵심 타이밍
|
||
pa1_w_flow_strong: 15, // 수급 강세
|
||
pa1_w_rs_leader: 10, // 상대강도 선도
|
||
pa1_w_volume_confirm: 10, // 거래량 확인
|
||
pa1_w_rsi_healthy: 10, // RSI 여력
|
||
pa1_w_brt_leader: 10, // BRT 선도
|
||
// Antithesis 팩터 (핵심만 유지, 획일화 해소): 일부 완화
|
||
pa1_w_chase_risk: 40, // 뒷박 위험 — 유지
|
||
pa1_w_distribution: 40, // 분배 신호 — 유지
|
||
pa1_w_rsi_overbought: 40, // RSI 과열 — 유지
|
||
pa1_w_foreign_sell: 30, // 외인 매도 — 완화 (단기 노이즈)
|
||
pa1_w_usd_krw_weak: 15, // 환율 약세 — 대폭 완화 (전 종목 동일 페널티 방지)
|
||
pa1_w_stale_position: 20 // 장기보유 페널티 — 완화
|
||
};
|
||
|
||
// settings 시트에서 기존 pa1_w_* 행 인덱스 수집
|
||
var data = settingSh.getDataRange().getValues();
|
||
var rowIndex = {};
|
||
data.forEach(function(row, i) {
|
||
var key = String(row[0] || '').trim().toLowerCase();
|
||
if (key.indexOf('pa1_w_') === 0) {
|
||
rowIndex[key] = i + 1; // 1-based
|
||
}
|
||
});
|
||
|
||
// 값 쓰기 (존재하면 업데이트, 없으면 추가)
|
||
var updated = []; var added = [];
|
||
Object.keys(APPROVED_WEIGHTS).forEach(function(key) {
|
||
var val = APPROVED_WEIGHTS[key];
|
||
if (rowIndex[key]) {
|
||
settingSh.getRange(rowIndex[key], 2).setValue(val);
|
||
updated.push(key + '=' + val);
|
||
} else {
|
||
settingSh.appendRow([key, val]);
|
||
added.push(key + '=' + val);
|
||
}
|
||
});
|
||
|
||
var thesisTotal = 15+15+10+10+10+10;
|
||
var antiTotal = 40+40+40+30+15+20;
|
||
Logger.log('[updatePa1WeightsManual_] 완료');
|
||
Logger.log(' 업데이트: ' + updated.join(', '));
|
||
Logger.log(' 신규 추가: ' + (added.length ? added.join(', ') : '없음'));
|
||
Logger.log(' thesis합=' + thesisTotal + 'pt antithesis합=' + antiTotal + 'pt ratio=' + (antiTotal/thesisTotal).toFixed(1) + 'x');
|
||
SpreadsheetApp.getUi().alert(
|
||
'PA1 가중치 업데이트 완료\n' +
|
||
'thesis합=' + thesisTotal + 'pt / antithesis합=' + antiTotal + 'pt (ratio=' + (antiTotal/thesisTotal).toFixed(1) + 'x)\n' +
|
||
'업데이트: ' + updated.length + '개 / 추가: ' + added.length + '개\n\n' +
|
||
'다음 runDataFeed 실행 시 새 가중치가 PA1 계산에 반영됩니다.'
|
||
);
|
||
} catch(e) {
|
||
Logger.log('[updatePa1WeightsManual_] 오류: ' + e.message);
|
||
SpreadsheetApp.getUi().alert('오류: ' + e.message);
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* PA4 — MACRO_EVENT_SYNCHRONIZER_V1
|
||
* 외국인 순매도 연속일·USD/KRW·FOMC·VIX 등 거시 변수를 macro_risk_score로 환산.
|
||
* heat_gate_adj(-3/-1/0/+1) 및 mega_sell_alert 산출.
|
||
* @param {Object} macroJson getMacroJson() 반환값
|
||
* @param {Array} eventRows getEventRiskJson().events (DaysLeft, Type 컬럼)
|
||
*/
|
||
function calcMacroEventSynchronizerV1_(macroJson, eventRows) {
|
||
return calcMacroEventSynchronizerV1Impl_(macroJson, eventRows);
|
||
}
|
||
|
||
/**
|
||
* PA1 — PREDICTIVE_ALPHA_ENGINE_V1
|
||
* 正(thesis) + 反(antithesis) = 合(direction_confidence) 3계층 점수.
|
||
* synthesis_verdict=BEARISH(EXIT/TRIM) → BUY 차단 근거.
|
||
* @param {Array} holdings
|
||
* @param {Object} dfMap
|
||
* @param {Object} macroJson getMacroJson() 반환값
|
||
* @param {Object} mesResult calcMacroEventSynchronizerV1_ 반환값
|
||
*/
|
||
function calcPredictiveAlphaEngineV1_(holdings, dfMap, macroJson, mesResult, weightOverrides) {
|
||
return calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult, weightOverrides);
|
||
}
|
||
|
||
|
||
/**
|
||
* PA2 — ANTI_LATE_ENTRY_GATE_V2
|
||
* 3중 AND 게이트: velocity_1d / velocity_5d / distribution_weighted_sum.
|
||
* ANTI_CHASING_VELOCITY_V1을 완전 대체.
|
||
* @param {Array} holdings
|
||
* @param {Object} dfMap
|
||
*/
|
||
function calcAntiLateEntryGateV2_(holdings, dfMap) {
|
||
return calcAntiLateEntryGateV2Impl_(holdings, dfMap);
|
||
}
|
||
|
||
|
||
/**
|
||
* PA3 — CASH_PRESERVATION_SELL_ENGINE_V2
|
||
* K2(분할) + C1(폭포수) + C2(타이밍)를 통합. 매도 스타일 결정 + value_preservation_score.
|
||
* h3.sellQty에 수량이 있는 종목만 처리.
|
||
* @param {Array} holdings
|
||
* @param {Object} dfMap
|
||
* @param {Object} cashShortfallInfo calcCashShortfallHarness_ 반환값
|
||
* @param {Object} h3 calcQuantities_ 반환값 (.sellQty 배열)
|
||
*/
|
||
function calcCashPreservationSellEngineV2_(holdings, dfMap, cashShortfallInfo, h3) {
|
||
var shortfallKrw = (cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw) || 0;
|
||
|
||
var sellQtyMap = {};
|
||
((h3 && h3.sellQty) || []).forEach(function(sq) {
|
||
if (typeof sq.sell_qty === 'number' && sq.sell_qty > 0) {
|
||
sellQtyMap[sq.ticker] = Math.floor(sq.sell_qty);
|
||
}
|
||
});
|
||
|
||
var rows = [];
|
||
|
||
holdings.forEach(function(h) {
|
||
var df = dfMap[h.ticker] || {};
|
||
var baseQty = sellQtyMap[h.ticker] || 0;
|
||
|
||
if (baseQty <= 0 && shortfallKrw <= 0) return;
|
||
|
||
var close = h.close || df.close || 0;
|
||
var prevClose = df.prevClose || close;
|
||
var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : 50;
|
||
var atr20 = typeof df.atr20 === 'number' ? df.atr20 : (close * 0.02);
|
||
var stopPrice = h.stopPrice || 0;
|
||
var frg5d = typeof df.frg5d === 'number' ? df.frg5d : 0;
|
||
var inst5d = typeof df.inst5d === 'number' ? df.inst5d : 0;
|
||
var volume = typeof df.volume === 'number' ? df.volume : 0;
|
||
var avgVol5d = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : 0;
|
||
|
||
// 현금 부족 시 baseQty 추정 (h3 미포함 종목)
|
||
if (baseQty <= 0 && shortfallKrw > 0 && close > 0) {
|
||
baseQty = Math.min(Math.floor(shortfallKrw / close), h.holdingQty || 0);
|
||
}
|
||
if (baseQty <= 0) return;
|
||
|
||
// distribution weighted_sum (inline)
|
||
var distWS = 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 (df.acGate === 'BLOCK') distWS += 1.0;
|
||
|
||
var emergencyFullSell = h.stopBreach === true;
|
||
|
||
// ── execution_style 결정 ─────────────────────────────────────────────────
|
||
var execStyle;
|
||
if (emergencyFullSell) execStyle = 'EMERGENCY_FULL_EXIT';
|
||
else if (rsi14 < 30) execStyle = 'OVERSOLD_REBOUND_SELL';
|
||
else execStyle = 'STAGED_WATERFALL';
|
||
|
||
// ── 수량 산출 ────────────────────────────────────────────────────────────
|
||
var immediateQty = 0, reboundWaitQty = 0, reboundTriggerPrice = 0, reboundDeadlineDays = 0;
|
||
|
||
if (execStyle === 'OVERSOLD_REBOUND_SELL') {
|
||
immediateQty = Math.floor(baseQty * 0.50);
|
||
reboundWaitQty = baseQty - immediateQty;
|
||
// TICK_NORMALIZER_V1 간소화: 10원 단위 반올림
|
||
reboundTriggerPrice = Math.round((prevClose + 0.5 * atr20) / 10) * 10;
|
||
reboundDeadlineDays = 3;
|
||
} else if (execStyle === 'EMERGENCY_FULL_EXIT') {
|
||
immediateQty = baseQty;
|
||
reboundWaitQty = 0;
|
||
reboundTriggerPrice = 0;
|
||
reboundDeadlineDays = 0;
|
||
} else {
|
||
immediateQty = Math.floor(baseQty * 0.50);
|
||
reboundWaitQty = baseQty - immediateQty;
|
||
reboundTriggerPrice = prevClose > 0 ? prevClose : close;
|
||
reboundDeadlineDays = 5;
|
||
}
|
||
|
||
// ── rebound_scenario ─────────────────────────────────────────────────────
|
||
var limitPrice = prevClose > 0 ? prevClose : close;
|
||
var immediateKrw = immediateQty * limitPrice;
|
||
var reboundUpsideKrw = reboundWaitQty * (reboundTriggerPrice > 0 ? reboundTriggerPrice : limitPrice);
|
||
var downsideRiskKrw = reboundWaitQty * (stopPrice > 0 ? stopPrice : close * 0.92);
|
||
var rrNum = reboundUpsideKrw - immediateKrw;
|
||
var rrDen = Math.max(1, immediateKrw - downsideRiskKrw);
|
||
var riskRewardRatio = round2_(rrNum / rrDen);
|
||
|
||
// ── value_preservation_score ─────────────────────────────────────────────
|
||
var vpScore = 100;
|
||
if (immediateQty >= baseQty && rsi14 < 30) vpScore -= 30; // full_sell_oversold
|
||
if (distWS >= 3.0) vpScore -= 15; // distribution_high
|
||
if (reboundWaitQty > 0) vpScore += 15; // rebound_wait_exists
|
||
if (reboundTriggerPrice > 0 && limitPrice > 0
|
||
&& reboundTriggerPrice <= limitPrice * 1.03) vpScore += 10; // tight_trigger
|
||
vpScore = Math.max(0, Math.min(100, Math.round(vpScore)));
|
||
|
||
rows.push({
|
||
ticker: h.ticker,
|
||
name: h.name || df.name || '',
|
||
execution_style: execStyle,
|
||
base_qty: baseQty,
|
||
immediate_qty: immediateQty,
|
||
rebound_wait_qty: reboundWaitQty,
|
||
rebound_trigger_price: reboundTriggerPrice,
|
||
rebound_deadline_days: reboundDeadlineDays,
|
||
risk_reward_ratio: riskRewardRatio,
|
||
value_preservation_score: vpScore,
|
||
immediate_sell_krw: Math.round(immediateKrw),
|
||
rebound_upside_krw: Math.round(reboundUpsideKrw),
|
||
emergency_full_sell_flag: emergencyFullSell,
|
||
sell_value_damage_warning: vpScore < 50,
|
||
dist_weighted_sum: Math.round(distWS * 10) / 10,
|
||
formula_id: 'CASH_PRESERVATION_SELL_ENGINE_V2'
|
||
});
|
||
});
|
||
|
||
return rows;
|
||
}
|
||
|
||
/**
|
||
* PA5 — CONSISTENCY_VALIDATOR_V2
|
||
* 12개 논리 검증 항목으로 hApex 일관성 점검. score < 90 → cv_verdict=BLOCK.
|
||
* Sprint C 마지막에 실행 — 이전 PA1~PA4 결과까지 모두 포함한 hApex 검증.
|
||
* @param {Object} hApex
|
||
* @param {Object} asResult
|
||
* @param {Object} cashFloorInfo
|
||
* @param {string} capturedAtIso
|
||
* @param {Date} now
|
||
*/
|
||
function calcConsistencyValidatorV2_(hApex, asResult, cashFloorInfo, capturedAtIso, now) {
|
||
return calcConsistencyValidatorV2Impl_(hApex, asResult, cashFloorInfo, capturedAtIso, now);
|
||
}
|
||
|
||
|
||
/**
|
||
* [PROPOSAL47_A1] WATCH_BREAKOUT_REALTIME_GATE_V1
|
||
* REVIEW / EXIT 라이프사이클 단계의 보유 종목 중 velocity_1d >= 2.0% 급등 탐지.
|
||
* 감시 중 급등 누락(49건 근본 원인) 해결 — 당일 급등 감지 시 후보 승격 검토 신호 생성.
|
||
* anti_late_entry_grade가 F(BLOCK)인 경우 승격 제외 (추격 매수 방지).
|
||
*
|
||
* @param {Array} holdings asResult.holdings
|
||
* @param {Object} dfMap 종목별 데이터 피드
|
||
* @param {Array} slgRows satellite_lifecycle_gate_json (lifecycle_stage 포함)
|
||
* @param {Array} aleRows anti_late_entry_json (entry_grade 포함, F면 제외)
|
||
* @returns {Array} watch_breakout_candidates_json
|
||
*/
|
||
function calcWatchBreakoutRealtimeGateV1_(holdings, dfMap, slgRows, aleRows) {
|
||
var VELOCITY_THRESHOLD = 2.0;
|
||
var REVIEW_STAGES = ['REVIEW', 'EXIT'];
|
||
|
||
var slgMap = {};
|
||
(slgRows || []).forEach(function(r) {
|
||
slgMap[String(r.ticker || '')] = String(r.lifecycle_stage || '');
|
||
});
|
||
var aleMap = {};
|
||
(aleRows || []).forEach(function(r) {
|
||
aleMap[String(r.ticker || '')] = r;
|
||
});
|
||
|
||
var results = [];
|
||
(holdings || []).forEach(function(h) {
|
||
var ticker = String(h.ticker || '');
|
||
var stage = slgMap[ticker] || '';
|
||
if (REVIEW_STAGES.indexOf(stage) < 0) return;
|
||
|
||
var df = dfMap[ticker] || {};
|
||
var close = Number(df.close || h.close || 0);
|
||
var prevClose = Number(df.prevClose || 0);
|
||
if (close <= 0 || prevClose <= 0) return;
|
||
|
||
var velocity1d = Math.round((close - prevClose) / prevClose * 10000) / 100;
|
||
if (velocity1d < VELOCITY_THRESHOLD) return;
|
||
|
||
var aleEntry = aleMap[ticker] || {};
|
||
var aleGrade = aleEntry.entry_grade || 'B';
|
||
if (aleGrade === 'F') return; // 추격매수 방지: anti_late_entry_grade F 제외
|
||
|
||
results.push({
|
||
ticker: ticker,
|
||
name: h.name || df.name || '',
|
||
lifecycle_stage: stage,
|
||
velocity_1d: velocity1d,
|
||
promotion_signal: 'WATCH_BREAKOUT',
|
||
anti_late_entry_grade: aleGrade,
|
||
formula_id: 'WATCH_BREAKOUT_REALTIME_GATE_V1'
|
||
});
|
||
});
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* [PROPOSAL48_A3] ANTI_WHIPSAW_REENTRY_GATE_V1
|
||
* 매도 압박(tier=1/2) 종목이 당일 +3% 이상 급반등 시 REENTRY_CANDIDATE 마킹.
|
||
* 9건 "매도 신호 후 반등" 패턴 처리. 매도 실행 전 재검토 신호 제공.
|
||
*
|
||
* @param {Array} sellCandidates hApex.sell_candidates_json (tier, ticker, action 포함)
|
||
* @param {Object} dfMap 종목별 데이터 피드
|
||
* @param {Array} holdings asResult.holdings
|
||
* @returns {Array} anti_whipsaw_reentry_json
|
||
*/
|
||
function calcAntiWhipsawReentryGateV1_(sellCandidates, dfMap, holdings) {
|
||
var REENTRY_VELOCITY_THRESHOLD = 3.0; // 재진입 급반등 기준: +3%
|
||
var WHIPSAW_TIERS = [1, 2]; // 즉시·단계 매도 압박 대상
|
||
|
||
var results = [];
|
||
(sellCandidates || []).forEach(function(cand) {
|
||
var tier = typeof cand.tier === 'number' ? cand.tier : parseInt(cand.tier) || 99;
|
||
if (WHIPSAW_TIERS.indexOf(tier) < 0) return;
|
||
|
||
var ticker = cand.ticker;
|
||
var df = dfMap[ticker] || {};
|
||
var h = (holdings || []).find(function(x) { return x.ticker === ticker; }) || {};
|
||
var close = h.close || df.close || 0;
|
||
var prevClose = df.prevClose || 0;
|
||
|
||
if (close <= 0 || prevClose <= 0) return;
|
||
var velocity1d = Math.round((close - prevClose) / prevClose * 10000) / 100;
|
||
if (velocity1d < REENTRY_VELOCITY_THRESHOLD) return;
|
||
|
||
var profitPct = h.avgCost > 0
|
||
? Math.round((close - h.avgCost) / h.avgCost * 1000) / 10
|
||
: null;
|
||
var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : null;
|
||
|
||
// 재진입 등급: A(rsi<50 + rs_leader), B(rsi<60), C(기본)
|
||
var reentryGrade = 'C';
|
||
if (rsi14 !== null && rsi14 < 50 && df.rs_verdict === 'LEADER') reentryGrade = 'A';
|
||
else if (rsi14 !== null && rsi14 < 60) reentryGrade = 'B';
|
||
|
||
results.push({
|
||
ticker: ticker,
|
||
name: h.name || df.name || '',
|
||
sell_tier: tier,
|
||
sell_action: cand.action || '',
|
||
velocity_1d: velocity1d,
|
||
close: close,
|
||
prev_close: prevClose,
|
||
rsi14: rsi14,
|
||
rs_verdict: df.rs_verdict || '',
|
||
profit_pct: profitPct,
|
||
reentry_grade: reentryGrade,
|
||
reentry_signal: 'REENTRY_CANDIDATE',
|
||
whipsaw_warning: '매도 압박 중 반등 — 실행 전 재검토 권고',
|
||
formula_id: 'ANTI_WHIPSAW_REENTRY_GATE_V1'
|
||
});
|
||
});
|
||
|
||
return results;
|
||
}
|
||
|
||
|
||
/**
|
||
* [PROPOSAL48_C7] getAlphaHistorySummary_
|
||
* alpha_history 시트의 T20/T60 alpha gate 결과를 집계.
|
||
* 위성 종목의 장기 알파 생성 능력 추적 — T+5 피드백 루프 대용 지표.
|
||
* DATA_INSUFFICIENT 상태에서도 구조를 갖춰 LLM 참조 가능하게 유지.
|
||
*/
|
||
function getAlphaHistorySummary_() {
|
||
try {
|
||
var ss = getSpreadsheet_();
|
||
var sh = ss.getSheetByName('alpha_history');
|
||
if (!sh) return { status: 'NO_SHEET', formula_id: 'ALPHA_HISTORY_SUMMARY_V1' };
|
||
|
||
var rows = sh.getDataRange().getValues();
|
||
if (!rows || rows.length < 2) return { status: 'EMPTY', formula_id: 'ALPHA_HISTORY_SUMMARY_V1' };
|
||
|
||
var header = rows[0].map(function(h) { return String(h).trim(); });
|
||
var idx = {};
|
||
['Ticker','T20_Alpha_Gate','T60_Alpha_Gate','T20_Vs_Core_Pctp','T60_Vs_Core_Pctp','SAQG_Grade_At_Entry'].forEach(function(k) {
|
||
idx[k] = header.indexOf(k);
|
||
});
|
||
|
||
var t20 = { total: 0, pass: 0, fail: 0, missing: 0 };
|
||
var t60 = { total: 0, pass: 0, fail: 0, missing: 0 };
|
||
var gradeCount = {};
|
||
|
||
for (var r = 1; r < rows.length; r++) {
|
||
var row = rows[r];
|
||
var g20 = idx['T20_Alpha_Gate'] >= 0 ? String(row[idx['T20_Alpha_Gate']] || '') : '';
|
||
var g60 = idx['T60_Alpha_Gate'] >= 0 ? String(row[idx['T60_Alpha_Gate']] || '') : '';
|
||
var grade = idx['SAQG_Grade_At_Entry'] >= 0 ? String(row[idx['SAQG_Grade_At_Entry']] || '') : '';
|
||
|
||
if (g20 && g20 !== 'PENDING') {
|
||
t20.total++;
|
||
if (g20 === 'PASS') t20.pass++;
|
||
else if (g20 === 'FAIL') t20.fail++;
|
||
else t20.missing++;
|
||
}
|
||
if (g60 && g60 !== 'PENDING') {
|
||
t60.total++;
|
||
if (g60 === 'PASS') t60.pass++;
|
||
else if (g60 === 'FAIL') t60.fail++;
|
||
else t60.missing++;
|
||
}
|
||
if (grade) gradeCount[grade] = (gradeCount[grade] || 0) + 1;
|
||
}
|
||
|
||
var t20Rate = t20.total > 0 ? Math.round(t20.pass / t20.total * 1000) / 10 : null;
|
||
var t60Rate = t60.total > 0 ? Math.round(t60.pass / t60.total * 1000) / 10 : null;
|
||
|
||
return {
|
||
status: (t20.total > 0 || t60.total > 0) ? 'OK' : 'DATA_INSUFFICIENT',
|
||
t20_total: t20.total,
|
||
t20_pass_rate: t20Rate,
|
||
t20_pass: t20.pass,
|
||
t20_fail: t20.fail,
|
||
t60_total: t60.total,
|
||
t60_pass_rate: t60Rate,
|
||
t60_pass: t60.pass,
|
||
t60_fail: t60.fail,
|
||
grade_count: gradeCount,
|
||
total_rows: rows.length - 1,
|
||
formula_id: 'ALPHA_HISTORY_SUMMARY_V1'
|
||
};
|
||
} catch(e) {
|
||
return { status: 'ERROR', error: e.message, formula_id: 'ALPHA_HISTORY_SUMMARY_V1' };
|
||
}
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] P0-1: EXPORT_GATE_V1 — PENDING_EXPORT 원인 자동 진단
|
||
// Direction G5: PENDING_EXPORT 원인 진단 의무
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* calcExportGate_
|
||
* 5개 체크리스트 자동 평가 → EXPORT_READY / PENDING_EXPORT
|
||
* PASS 전 HTS 입력 금지 조건을 결정론적으로 산출.
|
||
*/
|
||
function calcExportGate_(hApex, asResult, cashFloorInfo) {
|
||
var checks = [];
|
||
|
||
// CHECK_1: account_snapshot 캡처 완료 여부
|
||
var captureRequired = !(asResult && asResult.holdings && asResult.holdings.length > 0
|
||
&& asResult.settlementCashD2Krw > 0);
|
||
checks.push({
|
||
check_id: 'CHECK_1_SNAPSHOT_CAPTURED',
|
||
status: captureRequired ? 'FAIL' : 'PASS',
|
||
message: captureRequired
|
||
? 'account_snapshot 미캡처 — HTS 화면 캡처 후 재실행 필요'
|
||
: 'account_snapshot OK'
|
||
});
|
||
|
||
// CHECK_2: 데이터 완성도 (buy_permission_json 기준 전 종목 존재)
|
||
var bpJson = (hApex && hApex.buy_permission_json) || [];
|
||
var holdingCount = (asResult && asResult.holdings) ? asResult.holdings.length : 0;
|
||
var dataOk = holdingCount > 0 && bpJson.length >= holdingCount;
|
||
checks.push({
|
||
check_id: 'CHECK_2_DATA_COMPLETENESS',
|
||
status: dataOk ? 'PASS' : 'FAIL',
|
||
message: dataOk
|
||
? 'data_feed 완성도 OK (' + bpJson.length + '/' + holdingCount + ')'
|
||
: 'data_feed 누락 — npm run convert-data-json 후 재실행'
|
||
});
|
||
|
||
// CHECK_3: 하네스 무결성 체크섬 (consistency_score 기준)
|
||
var cvScore = (hApex && typeof hApex.consistency_score === 'number') ? hApex.consistency_score : null;
|
||
var cvOk = cvScore !== null && cvScore >= 70;
|
||
checks.push({
|
||
check_id: 'CHECK_3_HARNESS_INTEGRITY',
|
||
status: cvOk ? 'PASS' : 'FAIL',
|
||
message: cvOk
|
||
? 'consistency_score=' + cvScore + ' 무결성 OK'
|
||
: 'consistency_score=' + (cvScore !== null ? cvScore : 'null') + ' — 70 미만 또는 미산출'
|
||
});
|
||
|
||
// CHECK_4: SELL_PRICE_SANITY — INVALID 주문 없음
|
||
var blueprint = (hApex && hApex.order_blueprint_json) || [];
|
||
var invalidPrices = blueprint.filter(function(b) {
|
||
return String(b.validation_status || '').indexOf('INVALID') >= 0;
|
||
});
|
||
checks.push({
|
||
check_id: 'CHECK_4_NO_INVALID_PRICES',
|
||
status: invalidPrices.length === 0 ? 'PASS' : 'FAIL',
|
||
message: invalidPrices.length === 0
|
||
? 'SELL_PRICE_SANITY 이상 없음'
|
||
: 'INVALID 가격 ' + invalidPrices.length + '건: ' +
|
||
invalidPrices.map(function(b) { return b.ticker; }).join(',')
|
||
});
|
||
|
||
// CHECK_5: cashFloor 블록 상태 확인 (HARD_BLOCK 시 현금 부족 경보)
|
||
var cashStatus = (cashFloorInfo && cashFloorInfo.status) || 'UNKNOWN';
|
||
var cashOk = cashStatus !== 'UNKNOWN';
|
||
checks.push({
|
||
check_id: 'CHECK_5_CASH_LEDGER',
|
||
status: cashOk ? 'PASS' : 'WARN',
|
||
message: cashOk
|
||
? 'cash_floor_status=' + cashStatus + ' (기록됨)'
|
||
: 'cash_floor_status=UNKNOWN — settlement_cash_d2_krw 확인 필요'
|
||
});
|
||
|
||
// [PROPOSAL51] P1-A: CHECK_6 — SCRS_RENDER 검증 (immediate_sell_qty 유효값 필수)
|
||
var scrsV2 = (hApex && hApex.scrs_v2_json) || {};
|
||
// [PROPOSAL51-FIX] GAS는 immediate_qty 반환 (calcSmartCashRecoverySell_ 확인)
|
||
var scrsRows = scrsV2.selected_combo || scrsV2.candidates || scrsV2.rows || [];
|
||
var scrsRenderOk = scrsRows.length === 0 || scrsRows.every(function(r) {
|
||
var qty = r.immediate_qty !== undefined ? r.immediate_qty : r.immediate_sell_qty;
|
||
return qty !== null && qty !== undefined && qty !== '-' && qty !== '';
|
||
});
|
||
checks.push({
|
||
check_id: 'CHECK_6_SCRS_RENDER',
|
||
status: scrsRenderOk ? 'PASS' : 'WARN',
|
||
message: scrsRenderOk
|
||
? 'SCRS-V2 immediate_sell_qty 렌더링 OK'
|
||
: 'SCRS-V2 immediate_sell_qty 누락 — render_operational_report 키 불일치 확인 필요'
|
||
});
|
||
|
||
// [PROPOSAL51] P1-A: CHECK_7 — PORTFOLIO_HEALTH_SCORE 타입 (Boolean 금지)
|
||
var healthScore = hApex && hApex.portfolio_health_score;
|
||
var healthTypeOk = (typeof healthScore === 'number' && !isNaN(healthScore));
|
||
checks.push({
|
||
check_id: 'CHECK_7_HEALTH_SCORE_TYPE',
|
||
status: healthTypeOk ? 'PASS' : 'WARN',
|
||
message: healthTypeOk
|
||
? 'portfolio_health_score=' + healthScore + ' (숫자 OK)'
|
||
: 'portfolio_health_score=' + JSON.stringify(healthScore) + ' — 숫자여야 함 (Boolean/null 금지)'
|
||
});
|
||
|
||
// [PROPOSAL51] P1-A: CHECK_8 — CLUSTER_SYNC 교정 없음 확인
|
||
var clusterSync = (hApex && hApex.cluster_sync_result_json) || {};
|
||
var clusterSyncOk = clusterSync.status === 'SYNCED' || !clusterSync.status;
|
||
checks.push({
|
||
check_id: 'CHECK_8_CLUSTER_SYNC',
|
||
status: clusterSyncOk ? 'PASS' : 'WARN',
|
||
message: clusterSyncOk
|
||
? 'SEMICONDUCTOR_CLUSTER_SYNC: 정합성 OK'
|
||
: 'CLUSTER_SYNC 교정 발생 (cluster_pct=' + (clusterSync.cluster_pct || '?')
|
||
+ '%, threshold=' + (clusterSync.threshold_pct || '?') + '%)'
|
||
});
|
||
|
||
var failChecks = checks.filter(function(c) { return c.status === 'FAIL'; });
|
||
var warnChecks = checks.filter(function(c) { return c.status === 'WARN'; });
|
||
|
||
var exportStatus;
|
||
if (failChecks.length > 0) exportStatus = 'PENDING_EXPORT';
|
||
else if (warnChecks.length > 0) exportStatus = 'REVIEW_ONLY';
|
||
else exportStatus = 'EXPORT_READY';
|
||
|
||
var htsAllowed = exportStatus === 'EXPORT_READY';
|
||
var nonPassChecks = checks.filter(function(c) { return c.status !== 'PASS'; });
|
||
var resolutionGuide = nonPassChecks.map(function(c) {
|
||
return '[' + c.check_id + '] ' + c.message;
|
||
});
|
||
|
||
return {
|
||
json_validation_status: exportStatus,
|
||
export_gate_status: exportStatus,
|
||
all_checks_passed: failChecks.length === 0 && warnChecks.length === 0,
|
||
checks: checks,
|
||
failed_checks: failChecks.map(function(c) { return c.check_id; }),
|
||
warn_checks: warnChecks.map(function(c) { return c.check_id; }),
|
||
resolution_guide: resolutionGuide,
|
||
hts_entry_allowed: htsAllowed,
|
||
formula_id: 'EXPORT_GATE_V2'
|
||
};
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] P0-2: ROUTING_TRACE_V1 — 라우팅 Trace 필수 출력 (Direction G4)
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* buildRoutingTrace_
|
||
* 모든 보고서 선행 출력 의무 — request_route, bundle, prompt, 검증 상태 etc.
|
||
* 누락 시 보고서 전체 INCOMPLETE_REPORT.
|
||
*/
|
||
function buildRoutingTrace_(intradayLock, cashFloorInfo, hApex, capturedAtIso) {
|
||
var scope = intradayLock ? 'TRIM_ONLY' : 'FULL_ANALYSIS';
|
||
var bundleSelected = (function() {
|
||
var cv = (hApex && hApex.consistency_score);
|
||
if (cv === null || cv === undefined) return 'retirement_portfolio_ultra_compact';
|
||
if (cv < 70) return 'retirement_portfolio_ultra_compact';
|
||
return 'retirement_portfolio_compact';
|
||
})();
|
||
|
||
var exportGate = (hApex && hApex.export_gate_json) || {};
|
||
var jsonValStatus = exportGate.json_validation_status || 'PENDING_EXPORT';
|
||
var captureRequired = exportGate.checks
|
||
? !exportGate.checks.some(function(c) {
|
||
return c.check_id === 'CHECK_1_SNAPSHOT_CAPTURED' && c.status === 'PASS';
|
||
})
|
||
: true;
|
||
|
||
var cashLedgerBasis = 'D2_ONLY';
|
||
var snapshotExecGate = (cashFloorInfo && cashFloorInfo.status === 'PASS')
|
||
? 'FULL_EXECUTION' : 'REVIEW_ONLY';
|
||
|
||
return {
|
||
request_route: 'PIPELINE_EOD_BATCH',
|
||
bundle_selected: bundleSelected,
|
||
prompt_entrypoint: 'prompts/analysis_prompt.md',
|
||
json_validation_status: jsonValStatus,
|
||
capture_required: captureRequired,
|
||
intraday_scope: scope,
|
||
snapshot_execution_gate: snapshotExecGate,
|
||
price_basis: capturedAtIso || 'UNKNOWN',
|
||
cash_ledger_basis: cashLedgerBasis,
|
||
routing_trace_complete: true,
|
||
formula_id: 'ROUTING_TRACE_V1'
|
||
};
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] P0-3: WATCH_LEDGER_V1 — WATCH 감시 원장 (Direction I4)
|
||
// HTS 입력 금지 컬럼명만 허용 — 주문표와 물리적 분리
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* buildWatchLedger_
|
||
* order_blueprint_json에서 validation_status != PASS 행을 분리.
|
||
* 허용 컬럼: ticker/name, reference_stop_price, reference_tp_state, hts_allowed, reason_code
|
||
* 금지 컬럼: 지정가, 손절가, 익절가, 주문가, 주문수량 등 (INVALID_COLUMN)
|
||
*/
|
||
function buildWatchLedger_(orderBlueprint, h4) {
|
||
var priceMap = {};
|
||
((h4 && h4.prices) || []).forEach(function(p) { priceMap[p.ticker] = p; });
|
||
var blueprintRows = Array.isArray(orderBlueprint) ? orderBlueprint : [];
|
||
|
||
var watchRows = blueprintRows.filter(function(b) {
|
||
return b.validation_status !== 'PASS';
|
||
});
|
||
|
||
return watchRows.map(function(b) {
|
||
var p = priceMap[b.ticker] || {};
|
||
var tpState = (function() {
|
||
if (!p.tp1_price) return 'INVALID_TP_STALE';
|
||
if (p.tp_state === 'TP1_ALREADY_TRIGGERED') return 'TP1_ALREADY_TRIGGERED';
|
||
return 'PENDING';
|
||
})();
|
||
return {
|
||
ticker: b.ticker,
|
||
name: b.name || '',
|
||
reference_stop_price: p.stop_price || null,
|
||
reference_tp_state: tpState,
|
||
hts_allowed: false,
|
||
reason_code: b.validation_status || 'NO_EXECUTION:WATCH',
|
||
note: '주문 아님. HTS 입력 금지.'
|
||
};
|
||
});
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] P1-1: EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1 (EJCE-V1)
|
||
// 30년 전문가 수준 3관점(애널리스트·트레이더·퀀트) 합의 게이트
|
||
// Direction EJ1: consensus_result=NO_BUY 시 BUY 절대 금지
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* calcExpertJudgmentConsensus_
|
||
* 3관점 독립 채점 → majority_rule → final_allowed_action 고착화
|
||
* LLM "분위기 좋으니까" 판단을 결정론적 합의로 대체.
|
||
*/
|
||
function calcExpertJudgmentConsensus_(ticker, df, paeRow, h1, hApex, dfMap) {
|
||
df = df || {};
|
||
paeRow = paeRow || {};
|
||
|
||
// ── ANALYST_VIEW: 펀더멘털·밸류에이션 ─────────────────────────────────────
|
||
var compositeScore = toNumber_(df['SS001_Score'] || df['composite_score']) || 0;
|
||
var pegScore = toNumber_(df['PEG_Score'] || df['peg_score']) || 0;
|
||
var upsidePct = toNumber_(df['Upside_Pct'] || df['upside_pct']) || 0;
|
||
var epsMiss = toNumber_(df['EPS_Revision_Status'] === 'MISS' ? 1 : 0);
|
||
var dartRisk = String(df['DART_Risk'] || '').toUpperCase() === 'Y';
|
||
|
||
var analystScore = 0;
|
||
if (compositeScore >= 70) analystScore += 30;
|
||
else if (compositeScore >= 50) analystScore += 15;
|
||
if (pegScore >= 8 || upsidePct > 15) analystScore += 20;
|
||
if (upsidePct > 15) analystScore += 5;
|
||
if (epsMiss >= 2) analystScore -= 30;
|
||
if (dartRisk) analystScore -= 20;
|
||
|
||
var analystVerdict = analystScore >= 30 ? 'BULLISH'
|
||
: analystScore >= -10 ? 'NEUTRAL'
|
||
: 'BEARISH';
|
||
|
||
// ── TRADER_VIEW: 타이밍·수급·추세 ─────────────────────────────────────────
|
||
var flowCredit = toNumber_(df['Flow_Credit'] || df['flow_credit']) || 0;
|
||
var rsVerdict = String(df['RS_Verdict'] || df['rs_verdict'] || '').toUpperCase();
|
||
var velocity1d = toNumber_(df['Ret5D'] != null ? df['Close'] / (df['Close'] / (1 + toNumber_(df['Ret5D']) / 100)) - 1 : 0) * 100;
|
||
// 더 단순하게: Ret5D/5 근사
|
||
var ret5d = toNumber_(df['Ret5D'] || df['ret5d']) || 0;
|
||
var vel1d_approx = ret5d / 5;
|
||
var paeAnti = toNumber_(paeRow.antithesis_score) || 0;
|
||
var distCount = toNumber_(df['Dist_Signals'] || df['distribution_signals_count']) || 0;
|
||
var ma20 = toNumber_(df['MA20']) || 0;
|
||
var close = toNumber_(df['Close'] || df['close']) || 0;
|
||
var atr20 = toNumber_(df['ATR20']) || 0;
|
||
var inPullback = (ma20 > 0 && close > 0) ? close <= ma20 * 1.03 : false;
|
||
|
||
var traderScore = 0;
|
||
if (flowCredit >= 0.55 && rsVerdict === 'LEADER') traderScore += 25;
|
||
if (inPullback) traderScore += 20;
|
||
if (vel1d_approx < 1.5 && ret5d > 0) traderScore += 20;
|
||
if (vel1d_approx >= 3.0) traderScore -= 30; // 뒷박 강한 패널티
|
||
if (paeAnti >= 50) traderScore -= 25; // 설거지 경보
|
||
if (distCount >= 2) traderScore -= 25;
|
||
|
||
var traderVerdict = traderScore >= 20 ? 'ENTRY_OK'
|
||
: traderScore >= -10 ? 'WAIT'
|
||
: 'BLOCK_ENTRY';
|
||
|
||
// ── QUANT_VIEW: 통계·팩터·리스크예산 ─────────────────────────────────────
|
||
var pacVal = toNumber_((hApex && hApex.portfolio_alpha_confidence)) || 0;
|
||
var heatGate = String((hApex && hApex.heat_gate_status) || '').toUpperCase();
|
||
var ddGuard = String((hApex && hApex.drawdown_guard_state) || '').toUpperCase();
|
||
var expectedEdge = toNumber_(df['Expected_Edge'] || df['expected_edge']) || 0;
|
||
var atrAvail = atr20 > 0;
|
||
|
||
var quantScore = 0;
|
||
if (expectedEdge > 0 && atrAvail) quantScore += 25;
|
||
if (atrAvail) quantScore += 10;
|
||
if (pacVal > 20) quantScore += 20;
|
||
if (pacVal < -20) quantScore -= 30; // 전체 알파 신뢰도 BLOCK
|
||
if (heatGate === 'BLOCK_NEW_BUY') quantScore -= 20;
|
||
if (ddGuard === 'NO_BUY') quantScore -= 15;
|
||
|
||
var quantVerdict = quantScore >= 20 ? 'APPROVED'
|
||
: quantScore >= -10 ? 'REDUCED'
|
||
: 'REJECTED';
|
||
|
||
// ── CONSENSUS_MATRIX: 2/3 이상 BLOCK → NO_BUY ───────────────────────────
|
||
var blockCount = 0;
|
||
if (analystVerdict === 'BEARISH') blockCount++;
|
||
if (traderVerdict === 'BLOCK_ENTRY') blockCount++;
|
||
if (quantVerdict === 'REJECTED') blockCount++;
|
||
|
||
var consensusResult, finalAllowedAction;
|
||
if (blockCount >= 2) {
|
||
consensusResult = 'NO_BUY';
|
||
finalAllowedAction = 'HOLD';
|
||
} else if (analystVerdict === 'BULLISH' && traderVerdict === 'ENTRY_OK' && quantVerdict === 'APPROVED') {
|
||
consensusResult = 'STRONG_BUY';
|
||
finalAllowedAction = 'BUY';
|
||
} else if (analystVerdict === 'BULLISH' && traderVerdict === 'ENTRY_OK') {
|
||
consensusResult = 'BUY_HALF';
|
||
finalAllowedAction = 'BUY_HALF';
|
||
} else if (analystVerdict === 'BULLISH' && traderVerdict === 'WAIT') {
|
||
consensusResult = 'BUY_PULLBACK';
|
||
finalAllowedAction = 'WAIT_PULLBACK';
|
||
} else if (analystVerdict === 'NEUTRAL' && traderVerdict === 'ENTRY_OK') {
|
||
consensusResult = 'BUY_PILOT';
|
||
finalAllowedAction = 'PILOT';
|
||
} else {
|
||
consensusResult = 'HOLD_WATCH';
|
||
finalAllowedAction = 'WATCH';
|
||
}
|
||
|
||
var blockReasons = [];
|
||
if (analystVerdict === 'BEARISH') blockReasons.push('ANALYST_BEARISH');
|
||
if (traderVerdict === 'BLOCK_ENTRY') blockReasons.push('TRADER_BLOCK_ENTRY_vel=' + vel1d_approx.toFixed(1) + '%');
|
||
if (quantVerdict === 'REJECTED') blockReasons.push('QUANT_REJECTED_pac=' + pacVal.toFixed(1));
|
||
|
||
return {
|
||
ticker: ticker,
|
||
analyst_score: analystScore,
|
||
analyst_verdict: analystVerdict,
|
||
trader_score: traderScore,
|
||
trader_verdict: traderVerdict,
|
||
quant_score: quantScore,
|
||
quant_verdict: quantVerdict,
|
||
block_count: blockCount,
|
||
consensus_result: consensusResult,
|
||
final_allowed_action: finalAllowedAction,
|
||
block_reasons: blockReasons,
|
||
override_required: blockCount >= 2,
|
||
formula_id: 'EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1'
|
||
};
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] P1-2: SMART_CASH_RECOVERY_SELL_ENGINE_V2 (SCRS-V2)
|
||
// 세련된 현금확보 매도 — 주식가치 보호 + 반등 포착 통합 엔진
|
||
// Direction C3: SCRS-V2 selected_combo만 HTS 주문표 기재 허용
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* calcSmartCashRecoverySell_
|
||
* 현금 부족액을 최소 주식가치 훼손으로 회수.
|
||
* 반등 기대 수익(expected_rebound_gain_krw) 사전 산출.
|
||
* "현금 급함" 이유로 Stage_2 우회 원천 차단.
|
||
*/
|
||
function calcSmartCashRecoverySell_(holdings, dfMap, cashShortfallInfo, h2, hApex) {
|
||
var shortfall = toNumber_((cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw)) || 0;
|
||
var totalAsset = toNumber_((hApex && hApex.total_asset_krw) || (cashShortfallInfo && cashShortfallInfo.total_asset_krw)) || 1;
|
||
var emergencyScore = shortfall / totalAsset * 100;
|
||
|
||
var level = emergencyScore >= 15 ? 'EMERGENCY'
|
||
: emergencyScore >= 8 ? 'URGENT'
|
||
: emergencyScore >= 3 ? 'NORMAL'
|
||
: 'TRIM_ONLY';
|
||
|
||
var holdMap = {};
|
||
(holdings || []).forEach(function(h) { holdMap[h.ticker] = h; });
|
||
|
||
var sellQtyMap = {};
|
||
((hApex && hApex.sell_quantities_json) || []).forEach(function(sq) {
|
||
sellQtyMap[sq.ticker] = sq;
|
||
});
|
||
|
||
var candidates = ((h2 && h2.candidates) || []).slice();
|
||
|
||
// [Phase 3] SMART_CASH_RECOVERY_V6: value_damage_score(가치 훼손 점수) 기준 오름차순 정렬
|
||
candidates.forEach(function(c) {
|
||
var h = holdMap[c.ticker] || {};
|
||
var df = dfMap[c.ticker] || {};
|
||
var close = toNumber_(h.close || df['Close'] || df.close) || 0;
|
||
var atr20 = toNumber_(df['ATR20'] || df.atr20) || (close * 0.02);
|
||
// 가치 훼손 점수: 슬리피지 및 낙폭 리스크를 수치화 (낮을수록 매도 유리)
|
||
c.value_damage_score = close > 0 ? ((atr20 * 0.3) / close) * 100 : 100;
|
||
});
|
||
candidates.sort(function(a, b) {
|
||
return (a.value_damage_score || 0) - (b.value_damage_score || 0);
|
||
});
|
||
|
||
var cumulative = 0;
|
||
var combo = [];
|
||
|
||
for (var i = 0; i < candidates.length; i++) {
|
||
if (shortfall > 0 && cumulative >= shortfall) break;
|
||
var c = candidates[i];
|
||
var h = holdMap[c.ticker] || {};
|
||
var df = dfMap[c.ticker] || {};
|
||
var close = toNumber_(h.close || df['Close'] || df.close) || 0;
|
||
var atr20 = toNumber_(df['ATR20'] || df.atr20) || (close * 0.02);
|
||
var holding = toNumber_(h.holdingQty || h.holding_qty) || 0;
|
||
var sqRow = sellQtyMap[c.ticker] || {};
|
||
var baseQty = toNumber_(sqRow.sell_qty) || Math.floor(holding * 0.33);
|
||
|
||
if (close <= 0 || baseQty <= 0) continue;
|
||
|
||
var currentValue = holding * close;
|
||
var immediateQty = Math.floor(baseQty * 0.50);
|
||
var reboundWaitQty = baseQty - immediateQty;
|
||
var slippage = atr20 * 0.3;
|
||
var immediateKrw = immediateQty * Math.max(0, close - slippage);
|
||
var damagePct = currentValue > 0 ? immediateKrw / currentValue * 100 : 100;
|
||
|
||
if (damagePct > 30 && level !== 'EMERGENCY') continue;
|
||
|
||
var reboundTrigger = tickNormalize_(close + atr20 * 0.5, close);
|
||
var expectedReboundKrw = reboundWaitQty * Math.max(0, reboundTrigger - close);
|
||
|
||
// [Phase 3] 유동성 기준 exec_mode 강제 지정
|
||
var avgTradeValue = toNumber_(df['AvgTradeValue_20D_M'] || df.avgTradeVal20d) || 10000000000;
|
||
var execMode = 'LIMIT_NEAR_BID';
|
||
if (avgTradeValue < 5000000000) {
|
||
execMode = 'TWAP_5_SPLIT';
|
||
} else if (avgTradeValue > 50000000000) {
|
||
execMode = 'MARKET';
|
||
}
|
||
|
||
cumulative += immediateKrw;
|
||
combo.push({
|
||
rank: c.rank,
|
||
ticker: c.ticker,
|
||
name: c.name || (h.name || ''),
|
||
exec_mode: execMode,
|
||
value_damage_score: Math.round(c.value_damage_score * 10) / 10,
|
||
immediate_qty: immediateQty,
|
||
rebound_wait_qty: reboundWaitQty,
|
||
immediate_krw: Math.round(immediateKrw),
|
||
rebound_trigger_price: reboundTrigger,
|
||
expected_rebound_krw: Math.round(expectedReboundKrw),
|
||
value_damage_pct: Math.round(damagePct * 10) / 10,
|
||
rebound_deadline_date: addBusinessDays_(new Date(), 3)
|
||
});
|
||
}
|
||
|
||
var totalReboundGain = combo.reduce(function(s, c) { return s + c.expected_rebound_krw; }, 0);
|
||
var avgDamage = combo.length > 0
|
||
? combo.reduce(function(s, c) { return s + c.value_damage_pct; }, 0) / combo.length : 0;
|
||
|
||
var emergencyFullSell = combo.length > 0
|
||
&& combo[0].immediate_krw * 2 < shortfall
|
||
&& level === 'EMERGENCY';
|
||
|
||
return {
|
||
emergency_level: level,
|
||
shortfall_krw: Math.round(shortfall),
|
||
selected_combo: combo,
|
||
total_immediate_sell_krw: Math.round(cumulative),
|
||
expected_rebound_gain_krw: Math.round(totalReboundGain),
|
||
value_damage_pct_avg: Math.round(avgDamage * 10) / 10,
|
||
emergency_full_sell: emergencyFullSell,
|
||
shortfall_covered: shortfall <= 0 || cumulative >= shortfall,
|
||
formula_id: 'SMART_CASH_RECOVERY_SELL_ENGINE_V6'
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL51] P1-C: CASH_RECOVERY_DISPLAY_LOCK_V1 (CRDL-V1)
|
||
// 현금회복 금액 3분리 표시 잠금 — 207억 과대표시 차단
|
||
// min_required / optimal_combo / reference_total (주문 아님)
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* calcCashRecoveryDisplayLock_
|
||
* 현금회복 금액을 3분리(최소필요/최적조합/전체후보) 표시 잠금.
|
||
* reference_total_krw는 "주문 아님" 레이블 필수.
|
||
*/
|
||
function calcCashRecoveryDisplayLock_(scrsJson, trimPlanJson, cashInfo) {
|
||
function normalizeRows_(v) {
|
||
if (Array.isArray(v)) return v;
|
||
if (!v) return [];
|
||
if (typeof v === 'string') {
|
||
try { return normalizeRows_(JSON.parse(v)); } catch (e) { return []; }
|
||
}
|
||
if (typeof v === 'object') {
|
||
var vals = [];
|
||
for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) vals.push(v[k]);
|
||
return vals;
|
||
}
|
||
return [];
|
||
}
|
||
|
||
var scrs = scrsJson || {};
|
||
if (typeof scrs === 'string') {
|
||
try { scrs = JSON.parse(scrs); } catch (e0) { scrs = {}; }
|
||
}
|
||
var trim = normalizeRows_(trimPlanJson);
|
||
var cash = cashInfo || {};
|
||
|
||
var minRequired = toNumber_(cash.cash_shortfall_min_krw) || 0;
|
||
var combo = normalizeRows_(scrs.selected_combo);
|
||
var optimalCombo = combo.reduce(function(s, r) { return s + (toNumber_(r.immediate_krw) || 0); }, 0);
|
||
var refTotal = trim.reduce(function(s, r) {
|
||
return s + (toNumber_(r.sell_amount_krw || r.trim_amount_krw || r.trimming_krw) || 0);
|
||
}, 0);
|
||
|
||
var coverageStatus;
|
||
if (minRequired <= 0) coverageStatus = 'NO_SHORTFALL';
|
||
else if (optimalCombo < minRequired) coverageStatus = 'UNCOVERED';
|
||
else if (optimalCombo > minRequired * 2) coverageStatus = 'OVER_SELL';
|
||
else coverageStatus = 'COVERED';
|
||
|
||
return {
|
||
formula_id: 'CASH_RECOVERY_DISPLAY_LOCK_V1',
|
||
min_required_krw: Math.round(minRequired),
|
||
optimal_combo_krw: Math.round(optimalCombo),
|
||
reference_total_krw: Math.round(refTotal),
|
||
coverage_status: coverageStatus,
|
||
display_mode: 'SHOW_MIN_OPTIMAL',
|
||
reference_label: '참고용 전체 후보 누적 — 주문 아님',
|
||
over_sell_warning: coverageStatus === 'OVER_SELL'
|
||
? 'OVER_SELL_WARNING: 최적조합(' + Math.round(optimalCombo/10000) + '만원)이 최소필요(' + Math.round(minRequired/10000) + '만원)의 2배 초과' : null,
|
||
shortfall_uncovered: coverageStatus === 'UNCOVERED'
|
||
? 'CASH_SHORTFALL_UNCOVERED: SCRS-V2 재실행 필요' : null
|
||
};
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL51] P1-B: DATA_QUALITY_GATE_V2 (DQG-V2)
|
||
// 데이터 완성도 필드충족률 기반 게이트 — 행수 카운트 폐기
|
||
// COMPLETE(≥90%) / PARTIAL(≥60%) / INSUFFICIENT(<60%)
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* calcDataQualityGateV2_
|
||
* 핵심 필드 충족률로 데이터 완성도 등급 산출.
|
||
* T+20=0건, trade_quality=0건 시 특수 경고 발동.
|
||
*/
|
||
function calcDataQualityGateV2_(hApex) {
|
||
var h = hApex || {};
|
||
|
||
var pa1 = ((h.alpha_lead_json || [])[0]) || {};
|
||
var tradeQualRecords = ((h.trade_quality_report_json || {}).records || []);
|
||
var tqFirst = tradeQualRecords[0] || {};
|
||
var alphaHist = (h.alpha_history_summary_json) || {};
|
||
var scrsV2 = (h.scrs_v2_json) || {};
|
||
var combo = scrsV2.selected_combo || [];
|
||
var cluster = (h.semiconductor_cluster_json) || {};
|
||
var alphaEval = (h.alpha_evaluation_window_json || []);
|
||
var firstAlpha = alphaEval[0] || {};
|
||
var pp0 = ((h.profit_preservation_json) || [])[0] || {};
|
||
|
||
var isValid = function(v) {
|
||
return v !== null && v !== undefined && v !== '-' && v !== 'PENDING' && v !== '';
|
||
};
|
||
|
||
// [R2-1c] 필드경로 버그 수정: 실재 데이터를 0으로 깔던 false-negative 제거.
|
||
// prediction: alpha_lead_json[0] → pa1_report_json(PA1 진짜 필드).
|
||
// cash: cash_shortfall_json.cash_shortfall_min_krw(None) → 직접키 h.cash_shortfall_min_krw.
|
||
// cluster: h.semiconductor_cluster_json → h.semiconductor_cluster_gate_json 또는 직접 필드.
|
||
// stop_loss: final_stop_price/stop_price(없는 키) → protected_stop_price/auto_trailing_stop.
|
||
// trade_quality/alpha_eval/pattern: 표본 필요 → PENDING 값으로 명시(분모 제외).
|
||
var pa1Report = h.pa1_report_json || {};
|
||
if (typeof pa1Report === 'string') { try { pa1Report = JSON.parse(pa1Report); } catch(e) { pa1Report = {}; } }
|
||
var pa1Rows = Array.isArray(pa1Report) ? pa1Report : (pa1Report.rows || []);
|
||
var pa1Row0 = pa1Rows[0] || {};
|
||
|
||
var clusterDirect = h.semiconductor_cluster_json || {};
|
||
if (typeof clusterDirect === 'string') { try { clusterDirect = JSON.parse(clusterDirect); } catch(e) { clusterDirect = {}; } }
|
||
|
||
var CATEGORIES = {
|
||
prediction: [pa1Row0.direction_confidence, pa1Row0.synthesis_verdict, pa1Row0.thesis_score, pa1Row0.antithesis_score],
|
||
trade_quality: [tqFirst.grade || 'PENDING', tqFirst.feedback_tag || 'PENDING', tqFirst.t5_return_pct, tqFirst.t20_vs_core_pct],
|
||
pattern: [(h.pattern_blacklist_auto_json || {}).status || 'PENDING', (h.pattern_blacklist_auto_json || {}).accumulated_poor_count],
|
||
["stop_loss"]: [pp0.auto_trailing_stop, pp0.protected_stop_price, pp0.profit_preservation_state],
|
||
cash: [h.settlement_cash_d2_krw, h.cash_floor_status, h.cash_shortfall_min_krw],
|
||
sell_engine: [scrsV2.emergency_level, (combo[0] || {}).immediate_qty, (combo[0] || {}).rebound_wait_qty],
|
||
cluster: [clusterDirect.cluster_state, clusterDirect.combined_pct],
|
||
alpha_eval: [firstAlpha.alpha_gate_verdict || 'PENDING', alphaHist.prediction_accuracy_rate]
|
||
};
|
||
|
||
var categoryScores = {};
|
||
Object.keys(CATEGORIES).forEach(function(cat) {
|
||
var fields = CATEGORIES[cat];
|
||
var filled = fields.filter(isValid).length;
|
||
categoryScores[cat] = Math.round(filled / fields.length * 100);
|
||
});
|
||
|
||
var catVals = Object.keys(categoryScores).map(function(k) { return categoryScores[k]; });
|
||
var overallPct = catVals.length > 0
|
||
? Math.round(catVals.reduce(function(s, v) { return s + v; }, 0) / catVals.length) : 0;
|
||
|
||
var grade = overallPct >= 90 ? 'COMPLETE' : overallPct >= 60 ? 'PARTIAL' : 'INSUFFICIENT';
|
||
|
||
var warnings = [];
|
||
var t20Count = toNumber_((alphaHist).t20_evaluation_count) || 0;
|
||
var tqCount = tradeQualRecords.length;
|
||
var accRate = alphaHist.prediction_accuracy_rate;
|
||
var t5Count = toNumber_(alphaHist.t5_match_count) || 0;
|
||
|
||
if (t20Count === 0) warnings.push('warn_t20_zero: T+20 평가 0건 — 장기 예측 신뢰도 미검증');
|
||
if (tqCount === 0) warnings.push('warn_quality_unverified: 거래 품질 기록 0건');
|
||
if (!isValid(accRate)) warnings.push('warn_accuracy_unknown: 예측 정확도 미산출(PENDING)');
|
||
if (t5Count < 5) warnings.push('warn_insufficient_samples: T+5 표본 ' + t5Count + '건(최소 5건 미달)');
|
||
|
||
return {
|
||
formula_id: 'DATA_QUALITY_GATE_V2',
|
||
overall_completeness_pct: overallPct,
|
||
completeness_grade: grade,
|
||
category_scores: categoryScores,
|
||
special_warnings: warnings,
|
||
t20_evaluation_count: t20Count,
|
||
trade_quality_record_count: tqCount,
|
||
prediction_accuracy_rate: accRate || null,
|
||
confidence_ceiling: grade === 'INSUFFICIENT'
|
||
? 'BUY_SELL_CONFIDENCE_LIMITED: 핵심 데이터 부족 — 신호 신뢰도 상한 경고' : null
|
||
};
|
||
}
|
||
|
||
|
||
/**
|
||
* addBusinessDays_: 영업일 기준 날짜 계산 (토·일 제외)
|
||
*/
|
||
function addBusinessDays_(startDate, days) {
|
||
var d = new Date(startDate.getTime());
|
||
var added = 0;
|
||
while (added < days) {
|
||
d.setDate(d.getDate() + 1);
|
||
var dow = d.getDay();
|
||
if (dow !== 0 && dow !== 6) added++;
|
||
}
|
||
return Utilities.formatDate(d, 'Asia/Seoul', 'yyyy-MM-dd');
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// [PROPOSAL50] P2-1: DETERMINISTIC_SERVING_LOCK_ENGINE_V1 (DSLE-V1)
|
||
// 11단계 stage_token 잠금 + LLM 수치 생성 = 0 강제
|
||
// Direction D3: LLM 서빙 수치 생성 절대 금지
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* calcDeterministicServingLock_
|
||
* 11단계 파이프라인 각 단계의 status·checksum을 토큰으로 기록.
|
||
* integrity_checksum 불일치 시 INVALID_SERVING_OVERRIDE 자동 표시.
|
||
*/
|
||
function calcDeterministicServingLock_(hApex, capturedAtIso, now) {
|
||
var stages = [
|
||
{ id: 'Stage_01_freshness', key: 'data_freshness_status' },
|
||
{ id: 'Stage_02_intraday', key: 'intraday_scope' },
|
||
{ id: 'Stage_03_portfolio', key: 'cash_floor_status' },
|
||
{ id: 'Stage_04_macro', key: 'macro_risk_score' },
|
||
{ id: 'Stage_05_sell_radar', key: 'distribution_sell_detector_json' },
|
||
{ id: 'Stage_06_buy_gate', key: 'anti_late_entry_json' },
|
||
{ id: 'Stage_07_sell_priority', key: 'sell_candidates_json' },
|
||
{ id: 'Stage_08_cash_recovery', key: 'scrs_v2_json' },
|
||
{ id: 'Stage_09_rs_quality', key: 'rs_verdict' },
|
||
{ id: 'Stage_10_tick_norm', key: 'tick_normalized_prices_json' },
|
||
{ id: 'Stage_11_serving', key: 'order_blueprint_json' },
|
||
];
|
||
|
||
var tokens = [];
|
||
var blockDetected = false;
|
||
var blockReason = null;
|
||
|
||
for (var i = 0; i < stages.length; i++) {
|
||
var s = stages[i];
|
||
var value = hApex ? hApex[s.key] : null;
|
||
var status = (value !== null && value !== undefined) ? 'OK' : 'MISSING';
|
||
if (status === 'MISSING' && i < 4) {
|
||
blockDetected = true;
|
||
blockReason = blockReason || (s.id + '_MISSING');
|
||
}
|
||
tokens.push({
|
||
stage_id: s.id,
|
||
key: s.key,
|
||
status: status,
|
||
checksum: computeStringChecksum_(safeStringifyForChecksum_(value))
|
||
});
|
||
}
|
||
|
||
var tokenChecksum = computeStringChecksum_(safeStringifyForChecksum_(tokens));
|
||
|
||
return {
|
||
route_lock_status: blockDetected ? 'PARTIALLY_LOCKED' : 'FULLY_LOCKED',
|
||
stage_tokens: tokens,
|
||
integrity_checksum: tokenChecksum,
|
||
llm_serving_budget: {
|
||
max_tokens: 1000,
|
||
numeric_generation_allowed: 0,
|
||
constraint: 'LLM_SERVING_CONSTRAINT_V1'
|
||
},
|
||
block_reason: blockReason,
|
||
captured_at: capturedAtIso || null,
|
||
generated_at: now ? Utilities.formatDate(now, 'Asia/Seoul', 'yyyy-MM-dd HH:mm') : null,
|
||
formula_id: 'DETERMINISTIC_SERVING_LOCK_ENGINE_V1'
|
||
};
|
||
}
|
||
|