// ========================================================================= // GENERATED BUNDLE - DO NOT EDIT THIS FILE MANUALLY // Generated At: 2026-06-21 20:47:17 KST // Source Files: src/gas_adapter_parts/gdf_01_price_metrics.gs, src/gas_adapter_parts/gdf_02_harness_assembly.gs, src/gas_adapter_parts/gdf_03_portfolio_gates.gs, src/gas_adapter_parts/gdf_04_execution_quality.gs, src/gas_adapter_parts/gdf_05_alpha_engines.gs, src/gas_adapter_parts/gdf_06_rebalance.gs // Source Hash: 10444a5154d1b600dba5a60e163eca359527552810b5d1dea7361afe2e609b97 // ========================================================================= // --- Source: src/gas_adapter_parts/gdf_01_price_metrics.gs --- /** * gas_data_feed.gs — Google Apps Script 버전 * * Phase 2: GAS에서 Naver Finance를 직접 호출해 데이터 수집. * EUC-KR 인코딩을 GAS 네이티브로 처리 (iconv 불필요). * * 배포 방법: * 1. script.google.com → 새 프로젝트 * 2. 이 파일 붙여넣기 * 3. 트리거 설정: runDataFeed → 시간 기반 → 매일 → 16:30~17:30 * * 실행 시간 전략 (GAS 6분 제한): * - data_feed: 보유 10종목만 → ~30초 * - sector_flow: 분리된 섹터×3종목 → ~3분 * - macro/unified: 단순 집계 → ~30초 * - core_satellite(100종목): 별도 트리거, 청크 분할 실행 * * 하네스 통합: * - buildHarnessContext_()와 관련 헬퍼는 이 파일에 직접 포함된다. * - 별도 하네스 파일 없이 이 파일 하나만 배포해도 된다. */ const SPREADSHEET_ID = "1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM"; const SCHEMA_VERSION = "2026-05-15-qg2"; const TICKERS_BASE = [ { code: "005930", name: "삼성전자" }, { code: "000660", name: "SK하이닉스" }, { code: "000270", name: "기아" }, { code: "091160", name: "KODEX 반도체" }, { code: "463250", name: "TIGER K방산&우주" }, { code: "064350", name: "현대로템" }, { code: "012450", name: "한화에어로스페이스" }, { code: "117700", name: "KODEX 건설" }, { code: "028050", name: "삼성E&A" }, { code: "454320", name: "HANARO CAPEX설비투자iSelect" }, { code: "010120", name: "LS ELECTRIC" }, { code: "0117V0", name: "TIGER AI전력기기" }, { code: "491820", name: "HANARO 전력설비투자" }, { code: "494670", name: "TIGER 조선TOP10" }, { code: "471990", name: "KODEX AI반도체핵심장비" }, { code: "434730", name: "HANARO 원자력iSelect" }, { code: "0111J0", name: "HANARO 증권고배당TOP3플러스" }, { code: "307520", name: "TIGER 지주회사" }, { code: "0190C0", name: "RISE 현대차고정피지컬AI" }, { code: "011070", name: "LG이노텍" }, { code: "010620", name: "현대미포" }, { code: "121600", name: "나노신소재" }, ]; // TICKERS 우선순위: TICKERS_BASE → account_snapshot 보유종목 → watch_tickers_override 수동 추가. // account_snapshot에 보유수량(qty > 0)이 있는 종목은 TICKERS_BASE에 없어도 자동 포함된다. function getActiveTickers_() { let tickers = TICKERS_BASE.slice(); const existingCodes = new Set(tickers.map(t => t.code)); // ── 1. account_snapshot 자동 동기 ───────────────────────────────────────── // parse_status=CAPTURE_READ_OK + holding_quantity > 0 인 KR 종목을 자동 포함. // 미국 종목(GOOGL/MSFT 등, 순 알파벳 코드)은 Naver Finance 조회 불가 → skip. // 소수주(qty < 1)도 동일 종목코드가 이미 추가됐으면 중복 추가 없음. try { const ss = getSpreadsheet_(); const snapSh = ss.getSheetByName("account_snapshot"); if (snapSh) { const snapData = snapSh.getDataRange().getValues(); // account_snapshot은 row 1(index 0) = 안내, row 2(index 1) = 헤더 const headerRowIdx = snapData.length >= 2 ? 1 : 0; const hdr = snapData[headerRowIdx].map(h => String(h).trim()); const codeIdx = hdr.indexOf("ticker"); const nameIdx = hdr.indexOf("name"); const qtyIdx = hdr.indexOf("holding_quantity"); const parseIdx = hdr.indexOf("parse_status"); const ptIdx = hdr.indexOf("position_type"); if (codeIdx >= 0) { for (let i = headerRowIdx + 1; i < snapData.length; i++) { const row = snapData[i]; const rawCode = String(row[codeIdx] || "").trim(); if (!rawCode) continue; // 미국 종목 skip: GOOGL, MSFT, NVDA 등 순수 알파벳은 Naver 조회 불가 if (/^[A-Za-z]+$/.test(rawCode)) { Logger.log("[TICKERS_SNAPSHOT] US종목 skip: " + rawCode); continue; } const normCode = normalizeTickerCode(rawCode); if (!normCode) continue; if (parseIdx >= 0) { const ps = String(row[parseIdx] || "").trim().toUpperCase(); if (ps && ps !== "CAPTURE_READ_OK") continue; } const qty = parseFloat(row[qtyIdx] ?? 0) || 0; if (qty <= 0) continue; if (!existingCodes.has(normCode)) { const name = nameIdx >= 0 ? String(row[nameIdx] || normCode).trim() : normCode; tickers.push({ code: normCode, name: name }); existingCodes.add(normCode); Logger.log("[TICKERS_SNAPSHOT] 자동 추가: " + normCode + " (" + name + ") qty=" + qty); } } } } } catch (e) { Logger.log("[TICKERS_SNAPSHOT][WARN] account_snapshot 읽기 실패: " + e.message); } // ── 2. watch_tickers_override 수동 추가 (settings 탭) ────────────────────── // 형식: "코드1:이름1,코드2:이름2" — 위 두 소스에 없는 종목을 수동 추가할 때 사용. try { const ss = getSpreadsheet_(); const sh = ss.getSheetByName("settings"); if (sh) { const data = sh.getDataRange().getValues(); for (let i = 0; i < data.length; i++) { if (String(data[i][0] || "").trim() !== "watch_tickers_override") continue; const raw = String(data[i][1] || "").trim(); if (!raw) break; raw.split(",").forEach(entry => { const [code, name] = entry.trim().split(":").map(s => s.trim()); const normCode = normalizeTickerCode(code || ""); if (normCode && !existingCodes.has(normCode)) { tickers.push({ code: normCode, name: name || normCode }); existingCodes.add(normCode); Logger.log("[TICKERS_OVERRIDE] 수동 추가: " + normCode + " (" + (name || normCode) + ")"); } }); break; } } } catch (e) { Logger.log("[TICKERS_OVERRIDE][WARN] settings 읽기 실패: " + e.message); } Logger.log("[getActiveTickers_] 최종 종목 수: " + tickers.length + " (base=" + TICKERS_BASE.length + " total=" + tickers.length + ")"); return tickers; } // 하위 호환: 기존 코드가 TICKERS를 직접 참조하는 경우를 위해 별칭 유지. // runDataFeed()는 getActiveTickers_()를 호출해 동적 목록을 사용. const TICKERS = TICKERS_BASE; // 종목 → 섹터 매핑 (sector_flow의 Sector_Rank → C5 daily_leader_scan에 사용) const TICKER_SECTOR_MAP = { "005930": "반도체", "000660": "반도체", "042700": "반도체", "010120": "AI전력", "267260": "AI전력", "006260": "AI전력", "012450": "방산", "079550": "방산", "047810": "방산", "064350": "방산", "329180": "조선", "042660": "조선", "009540": "조선", "028050": "플랜트/EPC","000720": "건설","006360": "건설", "005380": "자동차", "000270": "자동차", "012330": "자동차", "105560": "은행","055550": "은행","086790": "은행","316140": "은행","024110": "은행", "071050": "증권","006800": "증권","005940": "증권","016360": "증권","039490": "증권", "180640": "지주회사","267250": "지주회사","034730": "지주회사","000150": "지주회사","005490": "지주회사", "003550": "지주회사","006260": "지주회사","078930": "지주회사","001040": "지주회사","010060": "지주회사", "373220": "2차전지","006400": "2차전지","051910": "2차전지", "207940": "바이오", "068270": "바이오", "128940": "바이오", "099440": "원전", "023450": "원전", "015760": "원전", "028260": "소비재", "097950": "소비재", "004370": "소비재", // ETF — 해당 섹터로 매핑 "091160": "반도체", "0117V0": "AI전력", "494670": "조선", "471990": "반도체", // KODEX AI반도체핵심장비 (누락 추가) "266410": "바이오", "091180": "자동차", "091170": "은행", "0111J0": "증권", "307520": "지주회사", "305720": "2차전지","139220": "소비재", "463250": "방산", "434730": "원전", "454320": "플랜트/EPC", "491820": "전력설비", "117700": "건설", "0190C0": "로보틱스", "011070": "로보틱스", "010620": "로보틱스", "121600": "로보틱스", }; // 섹터 → Tier 매핑 (C5 daily_leader_scan 점수 정밀화) // Tier_1=1.0(+rank≤3), Tier_2=0.5 고정, Tier_3=0 const SECTOR_TIER_MAP = { "반도체": "Tier_1", "AI전력": "Tier_1", "전력설비": "Tier_1", "방산": "Tier_1", "조선": "Tier_1", "자동차": "Tier_2", "2차전지": "Tier_2", "바이오": "Tier_2", "원전": "Tier_2", "건설": "Tier_3", "플랜트/EPC": "Tier_3", "로보틱스": "Tier_2", "은행":"Tier_3", "증권":"Tier_3", "지주회사":"Tier_3", "소비재": "Tier_3", }; // KOSDAQ 상장 종목 Set — SS001_VAL_KOSDAQ_PEG(max 12pt) 적용 대상 // 현재 보유 10종목은 모두 KOSPI 상장. KOSDAQ 종목 편입 시 코드 추가. const KOSDAQ_TICKERS = new Set([ // e.g., "035900", "003230" ]); const DART_CATALYST_KEYWORDS = [ "수주", "계약", "실적", "공급", "납품", "증설", "합병", "인수", "배당", "자사주", ]; const DART_RISK_KEYWORDS = [ "감자", // "정정" 제거: DART 제목 앞 접두어로 잠정실적·계약체결 등 모든 공시에 붙어 오탐 유발 "상장폐지", "관리종목", "횡령", "배임", "불성실", "소송", "회생", "유상증자", "감사의견", // 감사의견 거절·한정 "공시번복", // 공시 내용 번복 (실질적 정정) "조사", // 금감원 조사 ]; // GAS_CACHE_MAX_TTL: GAS CacheService 최대 허용 TTL = 21600초(6시간). // 초과 시 put()이 silently fail(try/catch 흡수) → 캐시 저장 안됨 → 매번 re-fetch 유발. const GAS_CACHE_MAX_TTL = 21600; const FETCH_GOVERNANCE = { budget: { naver_flow: 1, naver_quote: 1, naver_ohlc: 1, naver_notice: 1, naver_consensus: 1, naver_fund: 1, // 펀더멘털 fallback (분기별, 7일 캐시 우선) yahoo_price: 1, yahoo_quote: 1, yahoo_chart: 1, yahoo_financials: 1, }, ttl: { naver_flow_ok: GAS_CACHE_MAX_TTL, // 6h (GAS 최대값) naver_quote_ok: 30 * 60, // 30분 (장중 실시간 호가) naver_ohlc_ok: GAS_CACHE_MAX_TTL, // 6h — 수정: 43200 → 21600 (GAS 초과 버그 fix) naver_notice_ok: 4 * 60 * 60, // 4h naver_consensus_ok: 4 * 60 * 60, // 4h // 펀더멘털은 분기별 데이터 — CacheService 6h 저장 후 PropertiesService 7일 캐시로 이중 방어 naver_fund_ok: GAS_CACHE_MAX_TTL, // 6h yahoo_price_ok: GAS_CACHE_MAX_TTL, // 6h yahoo_quote_ok: 30 * 60, // 30분 yahoo_chart_ok: GAS_CACHE_MAX_TTL, // 6h yahoo_financials_ok: GAS_CACHE_MAX_TTL, // 6h failure: 10 * 60, // 10분 (재시도 대기) }, failureLimit: 3, coolDownMs: 3 * 60 * 60 * 1000, }; // ── 운영 임계값 상수 (magic number 50개+ → 단일 위치로 통합) ──────────────── // 수치 변경 시 반드시 이 블록만 수정. 코드 본문 하드코딩 금지. const THRESHOLDS = { // Val_Surge_Pct 상태 구간 (%) VAL_SURGE_WATCH: 15, VAL_SURGE_HOT: 35, VAL_SURGE_EXHAUSTED: 50, // 유동성 — 5D 평균 거래대금 (백만원) LIQUIDITY_PREFERRED_M: 100, LIQUIDITY_OK_M: 50, // 호가 스프레드 (%) SPREAD_OK_PCT: 0.25, SPREAD_WARN_PCT: 0.50, // Take Profit 승수 (진입가 대비) TP_CORE_1: 1.15, // core 1차 +15% TP_CORE_2: 1.25, // core 2차 +25% TP_SAT_1: 1.10, // satellite 1차 +10% TP_SAT_2: 1.20, // satellite 2차 +20% // Time Stop (calendar days) TIME_STOP_STAGE1: 60, TIME_STOP_STAGE2: 30, // Bucket 할당 목표 범위 (%) BUCKET_CORE_MIN: 60, BUCKET_CORE_MAX: 72, BUCKET_SAT_MIN: 10, BUCKET_SAT_MAX: 25, BUCKET_CASH_MIN: 10, BUCKET_CASH_MAX: 22, // Satellite 단일종목 비중 상한 (%) SAT_BAND_MAX: 7, // Orbit Gap 경보 (%p) ORBIT_MILD_BEHIND: 1, ORBIT_SIGNIFICANT_BEHIND: 3, ORBIT_AHEAD_TARGET: -2, // 포지션 수량 위험 예산 (기본 — settings 탭 override 가능) DEFAULT_RISK_BUDGET: 0.007, // ATR 기반 손절 승수 ATR_STOP_MULT: 1.5, ATR_TRAILING_MULT: 1.5, ATR_STOP_MULT_HIGH: 2.0, // ATR20_Pct >= 8% 고변동성 종목 전용 // Stage2 진입 최소 수익 (%) STAGE2_GATE_MIN_PCT: 1.5, // ── Sell_Priority_Score 산출 상수 (spec: portfolio_exposure.yaml:sell_priority_engine) ── SP_HARD_STOP: 50, // EXIT_SIGNAL / EXIT_100 SP_SELL_SIGNAL: 40, // SELL_READY / TRIM 신호 확정 SP_HOLDINGS_ROTATE: 20, // EXIT_REVIEW / 보유주 교체 후보 SP_TAKE_PROFIT: 10, // Profit_Pct >= 10% (익절 후보) SP_ETF_DUPLICATE: 20, // ETF + 섹터노출 >= 20% (중복노출 상한 초과) SP_ETF_MODERATE: 15, // ETF + 섹터노출 >= 10% SP_CASH_LARGE: 15, // Weight_Pct >= 3% (현금 회복 효과 대) SP_CASH_MID: 10, // Weight_Pct >= 1% SP_CASH_SMALL: 3, SP_RW4: 20, // RW_Partial >= 4 SP_RW3: 15, // RW_Partial == 3 SP_RW2: 8, // RW_Partial == 2 SP_BELOW_MA20: 8, // close < MA20 SP_LOSS_SATELLITE: 12, // 손실 >= -10%, 위성, 비코어리더 SP_OVERWEIGHT_LARGE: 12, // 목표비중 초과 >= 5%p SP_OVERWEIGHT_MID: 6, // 목표비중 초과 >= 2%p SP_CORE_LEADER: -20, // 직접 코어 주도주 + 상승추세 (패널티) SP_SS001_A: -12, // SS001 A등급 (패널티) SP_DUPLICATE_THRESH: 20, // 섹터노출 중복 판정 기준 (%) }; function getKrxMarketSessionStatus_(dt) { const d = dt instanceof Date ? dt : new Date(dt || new Date()); if (isNaN(d.getTime())) { return { open: false, reason: "invalid_datetime" }; } const kst = new Date(d.getTime() + 9 * 60 * 60 * 1000); const day = kst.getUTCDay(); const minutes = kst.getUTCHours() * 60 + kst.getUTCMinutes(); const open = day >= 1 && day <= 5 && minutes >= 9 * 60 && minutes < 15 * 60 + 30; return { open: open, reason: open ? "MARKET_OPEN" : "MARKET_CLOSED", kst_date: Utilities.formatDate(kst, "Asia/Seoul", "yyyy-MM-dd"), kst_time: Utilities.formatDate(kst, "Asia/Seoul", "HH:mm:ss"), }; } // account_snapshot freshness 확인 — last_updated/captured_at 최신 행 기준 경과일 반환 function checkAccountSnapshotFreshness_() { try { const ss = getSpreadsheet_(); const sheet = ss.getSheetByName("account_snapshot"); if (!sheet) return { fresh: false, reason: "account_snapshot 탭 없음" }; const session = getKrxMarketSessionStatus_(new Date()); const data = sheet.getDataRange().getValues(); if (data.length < 3) return { fresh: true, reason: "보유 원장 없음" }; const hdr = data[1].map(h => String(h).trim()); const luIdx = hdr.indexOf("last_updated") >= 0 ? hdr.indexOf("last_updated") : hdr.indexOf("captured_at"); const qtyIdx = hdr.indexOf("holding_quantity"); const statusIdx = hdr.indexOf("parse_status"); const confirmedIdx = hdr.indexOf("user_confirmed"); if (luIdx < 0) return { fresh: null, reason: "last_updated/captured_at 컬럼 없음" }; const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"); let latestDate = null; for (let i = 2; i < data.length; i++) { const parseStatus = statusIdx >= 0 ? String(data[i][statusIdx] ?? "").trim() : ""; const confirmed = confirmedIdx >= 0 ? String(data[i][confirmedIdx] ?? "").trim().toUpperCase() : ""; if (parseStatus !== "CAPTURE_READ_OK" || !["Y", "YES", "TRUE", "1"].includes(confirmed)) continue; const qty = parseInt(data[i][qtyIdx]); if (!Number.isFinite(qty) || qty <= 0) continue; const raw = data[i][luIdx]; const d = raw instanceof Date ? Utilities.formatDate(raw, "Asia/Seoul", "yyyy-MM-dd") : String(raw).trim().substring(0, 10); if (/^\d{4}-\d{2}-\d{2}$/.test(d) && d > (latestDate ?? "")) latestDate = d; } if (!latestDate) return { fresh: null, reason: "last_updated 미입력" }; const daysDiff = Math.round((new Date(today) - new Date(latestDate)) / 86400000); return { fresh: daysDiff <= 1, last_updated: latestDate, days_stale: daysDiff, reason: daysDiff <= 1 ? "최신" : `${daysDiff}일 경과 (${latestDate})`, collection_allowed: session.open, market_session_open: session.open, market_session_reason: session.reason, }; } catch(e) { return { fresh: null, reason: "읽기 오류: " + e.message }; } } function snapshotExecutionGate_(freshness) { if (!freshness || freshness.fresh == null) { return { status: "BLOCK_EXECUTION", reason: freshness && freshness.reason ? freshness.reason : "account_snapshot freshness unknown", }; } if (freshness.fresh === false) { return { status: "REVIEW_ONLY", reason: freshness.reason || "snapshot stale — proposal only", }; } return { status: "ALLOW_EXECUTION", reason: freshness.reason || "최신", }; } function calcDerivedPriceMetrics(rows, latestFirst) { if (!Array.isArray(rows) || rows.length === 0) return {}; const ordered = latestFirst ? rows.slice().reverse() : rows.slice(); // oldest -> latest const latest = ordered[ordered.length - 1] || {}; const previous = ordered[ordered.length - 2] || {}; const prior = (n) => ordered[ordered.length - 1 - n] || null; const lastN = (n) => ordered.slice(Math.max(0, ordered.length - n)); const prevN = (n) => ordered.slice(Math.max(0, ordered.length - 1 - n), ordered.length - 1); return { open: Number.isFinite(latest.open) ? latest.open : null, high: Number.isFinite(latest.high) ? latest.high : null, low: Number.isFinite(latest.low) ? latest.low : null, volume: Number.isFinite(latest.volume) ? latest.volume : null, prevClose: Number.isFinite(previous.close) ? previous.close : null, avgVolume5D: prevN(5).length >= 5 ? avgNumber_(prevN(5).map(r => r.volume)) : null, ma20: lastN(20).length >= 20 ? avgNumber_(lastN(20).map(r => r.close)) : null, ma60: lastN(60).length >= 60 ? avgNumber_(lastN(60).map(r => r.close)) : null, ret2D: prior(2) ? pctReturn_(latest.close, prior(2).close) : null, ret5D: prior(5) ? pctReturn_(latest.close, prior(5).close) : null, ret10D: prior(10) ? pctReturn_(latest.close, prior(10).close) : null, ret20D: prior(20) ? pctReturn_(latest.close, prior(20).close) : null, ret60D: prior(60) ? pctReturn_(latest.close, prior(60).close) : null, }; } // ── F1: 기술적 타이밍 지표 계산 ────────────────────────────────────────────── // rows: oldest→latest OHLCV 배열. 25행 이상 필요. function calcTimingMetrics_(rows) { if (!Array.isArray(rows) || rows.length < 21) return {}; const closes = rows.map(r => r.close); const n = closes.length; const close = closes[n - 1]; // MA20 slope: (오늘 MA20 - 5일전 MA20) / 5일전 MA20 × 100 const ma20Today = closes.slice(n - 20).reduce((a, b) => a + b, 0) / 20; let ma20Slope = null; if (n >= 25) { const ma20_5ago = closes.slice(n - 25, n - 5).reduce((a, b) => a + b, 0) / 20; if (ma20_5ago > 0) ma20Slope = parseFloat(((ma20Today - ma20_5ago) / ma20_5ago * 100).toFixed(3)); } // 이격도: (종가/MA20 - 1) × 100 const disparity = ma20Today > 0 ? parseFloat(((close / ma20Today - 1) * 100).toFixed(2)) : null; // RSI 14 (Wilder's smoothed) const rsi14 = calcRsi14_(closes); // 볼린저 밴드 (20일, 2σ) const bb20 = closes.slice(n - 20); const bbMean = bb20.reduce((a, b) => a + b, 0) / 20; const bbVar = bb20.reduce((s, c) => s + Math.pow(c - bbMean, 2), 0) / 20; const bbStd = Math.sqrt(bbVar); const bbUpper = bbMean + 2 * bbStd; const bbLower = bbMean - 2 * bbStd; const bbWidth = bbMean > 0 ? parseFloat(((bbUpper - bbLower) / bbMean * 100).toFixed(2)) : null; const bbPos = (bbUpper > bbLower) ? parseFloat(((close - bbLower) / (bbUpper - bbLower) * 100).toFixed(1)) : null; return { ma20Slope, disparity, rsi14, bbWidth, bbPosition: bbPos, bbUpper: Math.round(bbUpper), bbLower: Math.round(bbLower), }; } // RSI14 — Wilder 방식. 최대 50개 바 사용해 초기화 편향 최소화. // 14개만 초기화하면 ±5~8pt 오차 발생 — 사용 가능한 전체 데이터로 안정화. function calcRsi14_(closes) { if (closes.length < 15) return null; const lookback = Math.min(closes.length, 50); const c = closes.slice(closes.length - lookback); let avgGain = 0, avgLoss = 0; for (let i = 1; i <= 14; i++) { const d = c[i] - c[i - 1]; if (d > 0) avgGain += d; else avgLoss -= d; } avgGain /= 14; avgLoss /= 14; for (let i = 15; i < c.length; i++) { const d = c[i] - c[i - 1]; avgGain = (avgGain * 13 + Math.max(0, d)) / 14; avgLoss = (avgLoss * 13 + Math.max(0, -d)) / 14; } if (avgLoss === 0) return 100; return parseFloat((100 - 100 / (1 + avgGain / avgLoss)).toFixed(1)); } // ── F2: Entry Mode 게이트 ───────────────────────────────────────────────────── // PULLBACK: 눌림목 매수 조건 / BREAKOUT: 돌파 매수 조건 / NEUTRAL: 대기 function calcEntryMode_(timing, price) { const { ma20Slope, disparity, rsi14 } = timing; if (!Number.isFinite(disparity) || !Number.isFinite(rsi14)) { return { mode: "NEUTRAL", gate: "PENDING", reason: "지표_부족" }; } const trendUp = Number.isFinite(ma20Slope) && ma20Slope > 0; const valSurge = Number.isFinite(price.valSurge) ? price.valSurge : 0; const pct52H = Number.isFinite(price.pct52WHigh) ? price.pct52WHigh : -100; // 과열 — 두 전략 모두 진입 금지 if (disparity > 12 || rsi14 > 75) { return { mode: "OVERBOUGHT", gate: "BLOCK", reason: `과열(이격${disparity}%_RSI${rsi14})` }; } // 눌림목: 이격도 -5~+4% + MA20 상승 + RSI 35~58 if (trendUp && disparity >= -5 && disparity <= 4 && rsi14 >= 35 && rsi14 <= 58) { return { mode: "PULLBACK", gate: "PASS", reason: `눌림목(이격${disparity}%_RSI${rsi14})` }; } // 돌파: 52주 고점 -5% 이내 + 거래량 폭발 + RSI 50~72 + MA20 상승 if (trendUp && pct52H >= -5 && valSurge >= 50 && rsi14 > 50 && rsi14 <= 72) { return { mode: "BREAKOUT", gate: "PASS", reason: `돌파(52WH${pct52H.toFixed(1)}%_VOL+${valSurge.toFixed(0)}%)` }; } // MA20 하락 추세 if (!trendUp && Number.isFinite(ma20Slope)) { return { mode: "NEUTRAL", gate: "PENDING", reason: `MA20하락추세(slope${ma20Slope.toFixed(2)}%)` }; } return { mode: "NEUTRAL", gate: "PENDING", reason: `조건미충족(이격${disparity}%_RSI${rsi14})` }; } // ── F3: 매도 타이밍 신호 ────────────────────────────────────────────────────── // 복수 신호 발생 시 파이프(|) 구분. 포지션 없으면 빈 문자열. function calcExitSignalDetail_(timing, price) { const signals = []; const { disparity, rsi14, ma20Slope } = timing; const ret5D = Number.isFinite(price.ret5D) ? parseFloat(price.ret5D) : null; const valSurge = Number.isFinite(price.valSurge) ? price.valSurge : null; // 거래량 소진: 5일 수익률 양수인데 거래대금 평균 대비 -20% 미만 if (ret5D !== null && ret5D > 0 && valSurge !== null && valSurge < -20) { signals.push("VOL_EXHAUSTION"); } // MA20 붕괴: 종가 < MA20 AND MA20 하락 if (price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) && price.close < price.ma20 && Number.isFinite(ma20Slope) && ma20Slope < 0) { signals.push("MA20_BREAK"); } // 극단 과열: 이격도 > 15% if (Number.isFinite(disparity) && disparity > 15) signals.push("DISPARITY_TOP"); // RSI 과매수: RSI > 75 if (Number.isFinite(rsi14) && rsi14 > 75) signals.push("RSI_OVERBOUGHT"); return signals.join("|"); } // ── F5: 타이밍 종합 액션 ────────────────────────────────────────────────────── // 종목 점수(SS001)와 별개로 "지금 무엇을 할지"를 분리한다. var calcEntryTimingSignal_ = function(ctx) { const reasons = []; let entryScore = 0; let exitScore = 0; const entryGate = String(ctx.entryModeGate ?? ""); const entryMode = String(ctx.entryMode ?? ""); const leaderGate = String(ctx.leaderGate ?? ""); const acGate = String(ctx.acGate ?? ""); const exitSignal = String(ctx.exitSignalDetail ?? ""); const flowCredit = parseFloat(ctx.flowCredit); const leaderTotal = parseFloat(ctx.leaderTotal); const rwPartial = parseInt(ctx.rwPartial, 10); const rsi14 = parseFloat(ctx.rsi14); const disparity = parseFloat(ctx.disparity); const ma20Slope = parseFloat(ctx.ma20Slope); const spreadPct = parseFloat(ctx.spreadPct); const avgTradeValue5D = parseFloat(ctx.avgTradeValue5D); const profitPct = parseFloat(ctx.profitPct); const daysToTimeStop = parseInt(ctx.daysToTimeStop, 10); if (entryGate === "PASS") { entryScore += 25; reasons.push(`entry_${entryMode}`); } else if (entryGate === "BLOCK") { entryScore -= 25; reasons.push("entry_block"); } if (Number.isFinite(leaderTotal)) { if (leaderTotal >= 4) { entryScore += 20; reasons.push("leader_scan>=4"); } else if (leaderTotal >= 3) { entryScore += 10; reasons.push("leader_watch"); } } if (leaderGate === "PASS" || leaderGate === "EXPLORE_CANDIDATE") entryScore += 10; if (Number.isFinite(flowCredit)) { if (flowCredit >= 0.7) { entryScore += 20; reasons.push("flow_strong"); } else if (flowCredit >= 0.4) { entryScore += 10; reasons.push("flow_partial"); } } if (acGate === "CLEAR") { entryScore += 15; reasons.push("anti_climax_clear"); } else if (acGate === "CAUTION") { entryScore += 5; reasons.push("anti_climax_caution"); } else if (acGate === "BLOCK") { entryScore -= 35; exitScore += 15; reasons.push("anti_climax_block"); } if (Number.isFinite(ma20Slope)) { if (ma20Slope > 0) entryScore += 8; else { entryScore -= 8; exitScore += 8; reasons.push("ma20_down"); } } if (Number.isFinite(disparity)) { if (disparity >= -5 && disparity <= 4) entryScore += 10; else if (disparity > 4 && disparity <= 8) entryScore += 5; else if (disparity > 12) { entryScore -= 25; exitScore += 20; reasons.push("overextended"); } else if (disparity < -10) { entryScore -= 10; exitScore += 10; reasons.push("trend_damage"); } } if (Number.isFinite(rsi14)) { if (rsi14 >= 40 && rsi14 <= 65) entryScore += 10; else if (rsi14 > 65 && rsi14 <= 72) entryScore += 4; else if (rsi14 > 75) { entryScore -= 25; exitScore += 20; reasons.push("rsi_overbought"); } else if (rsi14 < 35) { entryScore -= 5; exitScore += 8; reasons.push("weak_rsi"); } } if (Number.isFinite(avgTradeValue5D) && avgTradeValue5D >= 50 && (!Number.isFinite(spreadPct) || spreadPct <= 0.8)) { entryScore += 10; } else { entryScore -= 15; reasons.push("liquidity_or_spread_fail"); } // RW: 수급 기반 상대약세 — 신뢰도 높아 25pt/건 (구: 20pt). 기술지표: 노이즈 多로 10pt/건 (구: 18pt). // 결과: RW=0 + 기술신호 4개 = 40pt → EXIT_REVIEW 미도달. RW=1 + 기술신호 2개 = 45pt → 대기. // RW=2 단독 = 50pt → EXIT_REVIEW. RW=3 단독 = 75pt → STOP_OR_TIME_EXIT_READY. if (Number.isFinite(rwPartial)) exitScore += Math.min(100, Math.max(0, rwPartial) * 25); if (exitSignal) exitScore += exitSignal.split("|").filter(Boolean).length * 10; if (Number.isFinite(daysToTimeStop) && daysToTimeStop >= 0 && daysToTimeStop <= 7) { exitScore += 20; reasons.push("time_stop_near"); } if (Number.isFinite(profitPct) && profitPct >= 10) { exitScore += 15; reasons.push("profit_protect_zone"); } entryScore = Math.max(0, Math.min(100, Math.round(entryScore))); exitScore = Math.max(0, Math.min(100, Math.round(exitScore))); let action = "HOLD_NO_TIMING_EDGE"; if (ctx.priceStatus !== "PRICE_OK" || !Number.isFinite(parseFloat(ctx.atr20))) { action = "OBSERVE_DATA_MISSING"; } else if (exitScore >= 75 || (Number.isFinite(rwPartial) && rwPartial >= 4)) { action = "STOP_OR_TIME_EXIT_READY"; } else if (exitScore >= 50 || (Number.isFinite(rwPartial) && rwPartial >= 3)) { action = "EXIT_REVIEW"; } else if (entryGate === "BLOCK" || acGate === "BLOCK" || entryMode === "OVERBOUGHT") { action = "NO_BUY_OVERHEATED"; } else if (entryScore >= 75 && entryGate === "PASS" && leaderTotal >= 4) { action = entryMode === "BREAKOUT" ? "BUY_BREAKOUT_PILOT_ONLY" : "BUY_STAGE1_READY"; } else if (entryScore >= 60 && entryGate === "PASS") { action = entryMode === "BREAKOUT" ? "BUY_BREAKOUT_PILOT_ONLY" : "BUY_PULLBACK_WAIT"; } else if (leaderTotal >= 3 || flowCredit >= 0.4) { action = "WATCH_TIMING_SETUP"; } return { entry_score: entryScore, exit_score: exitScore, action, reason: reasons.slice(0, 6).join("|"), }; } // Backward-compatible thin wrapper. // Existing data_feed callers still expect calcTimingRoute_. var calcTimingRoute_ = function(ctx) { return calcEntryTimingSignal_(ctx || {}); } // ── F6: 매도 신호·가격 산출 (방향 A: 수량 계산은 GAS 담당 아님) ────────────────── // Sell_Qty는 GAS에서 산출하지 않는다. 신호 종류 + 가격 + 비율만 출력. // 보유수량 × 비율 계산은 사용자가 ChatGPT에 캡처를 제공하는 단계에서 처리한다. var calcExitSellAction_ = function(ctx) { const close = parseFloat(ctx.close); const stopPrice = parseFloat(ctx.stopPrice); const trailingStop = parseFloat(ctx.trailingStop); const tp1Price = parseFloat(ctx.tp1Price); const tp2Price = parseFloat(ctx.tp2Price); const profitPct = parseFloat(ctx.profitPct); const rwPartial = parseInt(ctx.rwPartial, 10); const timingExitScore = parseFloat(ctx.timingExitScore); const daysToTimeStop = parseInt(ctx.daysToTimeStop, 10); const timingAction = String(ctx.timingAction ?? ""); const exitSignal = String(ctx.exitSignalDetail ?? ""); const acGate = String(ctx.acGate ?? ""); // sell_signal_priority level 2: REGIME_RISK_OFF (spec/exit/stop_loss.yaml) const regime = String(ctx.regime ?? ""); const atr20 = parseFloat(ctx.atr20); let action = "HOLD"; let ratio = 0; let reason = ""; let price = ""; let priceSource = ""; let priceBasis = ""; let executionWindow = ""; let orderType = ""; const stopCandidate = Number.isFinite(trailingStop) && trailingStop > 0 ? trailingStop : Number.isFinite(stopPrice) && stopPrice > 0 ? stopPrice : Number.isFinite(close) && close > 0 ? close * 0.995 : null; const protectiveLimit = Number.isFinite(close) && close > 0 ? Math.round(Math.min(close * 0.995, stopCandidate ?? close * 0.995)) : ""; // ATR 기반 보호 하한: close - ATR20×0.3 (변동성 비례 버퍼). ATR 없으면 0.5% 폴백. const atrBuffer = Number.isFinite(atr20) && atr20 > 0 ? atr20 * 0.3 : (Number.isFinite(close) ? close * 0.005 : 0); const closeProtectLimit = Number.isFinite(close) && close > 0 ? Math.round(close - atrBuffer) : ""; // priority 1: hard stop / strong RW exit (spec sell_signal_priority level 1) if (timingAction === "STOP_OR_TIME_EXIT_READY" || rwPartial >= 4) { action = "EXIT_100"; ratio = 100; reason = rwPartial >= 4 ? "RW_EXIT_STRONG" : "STOP_OR_TIME_EXIT_READY"; price = protectiveLimit; priceSource = Number.isFinite(trailingStop) ? "TRAILING_STOP" : "STOP_OR_CLOSE"; priceBasis = Number.isFinite(trailingStop) ? "TRAILING_STOP_TRIGGER" : "STOP_OR_CLOSE_PROTECT"; executionWindow = "INTRADAY_ON_TRIGGER"; orderType = "PROTECTIVE_LIMIT_SELL"; // priority 2: REGIME_TRIM_50 — 방향 A에서 개별 종목 신호 아님. // RISK_OFF 레짐 포트폴리오 축소 경고는 getDailyBrief() 매크로 섹션에서 처리. // priority 3: RW 신호 강 (spec level 3) } else if (rwPartial >= 3 || timingExitScore >= 75) { action = "TRIM_70"; ratio = 70; reason = rwPartial >= 3 ? "RW_EXIT" : "TIMING_EXIT_SCORE"; price = protectiveLimit; priceSource = "RISK_REDUCTION"; priceBasis = "RISK_REDUCTION_CLOSE_PROTECT"; executionWindow = "INTRADAY_AFTER_09_30"; orderType = "PROTECTIVE_LIMIT_SELL"; // priority 4: trailing stop 가격 직접 이탈 (spec level 4) — timingAction과 독립적으로 직접 비교 } else if (Number.isFinite(trailingStop) && trailingStop > 0 && Number.isFinite(close) && close <= trailingStop) { action = "TRAILING_STOP_BREACH"; ratio = 70; reason = "TRAILING_STOP_PRICE_BREACH"; price = Math.round(trailingStop); // 트레일링 스탑 이탈: 스탑 가격 자체가 보호선 — min 적용 금지 priceSource = "TRAILING_STOP_PRICE"; priceBasis = "TRAILING_STOP_TRIGGER"; executionWindow = "INTRADAY_ON_TRIGGER"; orderType = "PROTECTIVE_LIMIT_SELL"; // priority 4 (계속): RW 신호 중 (spec level 3 하위) // RW=0 + 기술지표만으로는 TRIM_50 차단 — 수급 확인(rwPartial>=1) 필수 } else if (rwPartial >= 2 || (rwPartial >= 1 && timingExitScore >= 50)) { action = "TRIM_50"; ratio = 50; reason = rwPartial >= 2 ? "RW_REVIEW" : "TIMING_EXIT_REVIEW"; price = closeProtectLimit; priceSource = "RELATIVE_WEAKNESS_CLOSE"; priceBasis = "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_AFTER_09_30"; orderType = "LIMIT_SELL"; // priority 4b: RW 약세 초기 + 기술지표 경계 — 33% 선제 경량화 } else if (rwPartial >= 1 && timingExitScore >= 30) { action = "TRIM_33"; ratio = 33; reason = "RW_EARLY_WARNING"; price = closeProtectLimit; priceSource = "EARLY_WARNING_CLOSE"; priceBasis = "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_AFTER_09_30"; orderType = "LIMIT_SELL"; // priority 4c: RW 약세 감지 단독 (기술지표 미확인) — 25% 최소 경계 } else if (rwPartial >= 1) { action = "TRIM_25"; ratio = 25; reason = "RW_SIGNAL_ONLY"; price = closeProtectLimit; priceSource = "SIGNAL_ONLY_CLOSE"; priceBasis = "PRIOR_CLOSE_X_0.998"; executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; orderType = "LIMIT_SELL"; // priority 5: 익절 사다리 (spec level 5) — time_stop보다 우선 } else if (Number.isFinite(profitPct) && profitPct >= 50) { action = "PROFIT_TRIM_50"; ratio = 50; reason = "PROFIT_PROTECT_50"; price = Number.isFinite(tp2Price) && tp2Price > 0 ? Math.round(tp2Price) : closeProtectLimit; priceSource = Number.isFinite(tp2Price) ? "TP2_PRICE" : "CLOSE_PROFIT_PROTECT"; priceBasis = Number.isFinite(tp2Price) ? "TAKE_PROFIT_TIER2_PRICE" : "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; orderType = "LIMIT_SELL"; } else if (Number.isFinite(profitPct) && profitPct >= 30) { action = "PROFIT_TRIM_35"; ratio = 35; reason = "PROFIT_PROTECT_30"; price = Number.isFinite(tp2Price) && tp2Price > 0 ? Math.round(tp2Price) : closeProtectLimit; priceSource = Number.isFinite(tp2Price) ? "TP2_PRICE" : "CLOSE_PROFIT_PROTECT"; priceBasis = Number.isFinite(tp2Price) ? "TAKE_PROFIT_TIER2_PRICE" : "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; orderType = "LIMIT_SELL"; } else if (Number.isFinite(profitPct) && profitPct >= 20) { action = "PROFIT_TRIM_25"; ratio = 25; reason = "PROFIT_PROTECT_20"; price = Number.isFinite(tp1Price) && tp1Price > 0 ? Math.round(tp1Price) : closeProtectLimit; priceSource = Number.isFinite(tp1Price) ? "TP1_PRICE" : "CLOSE_PROFIT_PROTECT"; priceBasis = Number.isFinite(tp1Price) ? "TAKE_PROFIT_TIER1_PRICE" : "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; orderType = "LIMIT_SELL"; } else if (Number.isFinite(profitPct) && profitPct >= 10) { action = "TAKE_PROFIT_TIER1"; ratio = 25; reason = "TP1_PROFIT_10PCT"; price = Number.isFinite(tp1Price) && tp1Price > 0 ? Math.round(tp1Price) : closeProtectLimit; priceSource = Number.isFinite(tp1Price) ? "TP1_PRICE" : "CLOSE_PROFIT_PROTECT"; priceBasis = Number.isFinite(tp1Price) ? "TAKE_PROFIT_TIER1_PRICE" : "PRIOR_CLOSE_X_0.998"; executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW"; orderType = "LIMIT_SELL"; // priority 6: 시간 손절 (spec level 6) — 익절 사다리보다 후순위; 손절·레짐·RW 없을 때만 도달 } else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 0) { action = "TIME_EXIT_100"; ratio = 100; reason = "TIME_STOP_EXPIRED"; price = protectiveLimit; priceSource = "TIME_STOP_CLOSE"; priceBasis = "TIME_STOP_CLOSE_PROTECT"; executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; orderType = "PROTECTIVE_LIMIT_SELL"; } else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 7) { action = "TIME_TRIM_50"; ratio = 50; reason = "TIME_STOP_NEAR"; price = closeProtectLimit; priceSource = "TIME_STOP_NEAR_CLOSE"; priceBasis = "ATR_PROTECT_LIMIT"; executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; orderType = "LIMIT_SELL"; // priority 6b: 타임스탑 14일 이내 조기 경보 — 25% 선제 축소 } else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 14) { action = "TIME_TRIM_25"; ratio = 25; reason = "TIME_STOP_APPROACHING"; price = closeProtectLimit; priceSource = "TIME_STOP_APPROACHING_CLOSE"; priceBasis = "ATR_PROTECT_LIMIT"; executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN"; orderType = "LIMIT_SELL"; } const cashPreservePlan = calcCashPreservationPlan_({ sellAction: action, cashFloorStatus: String(ctx.cashFloorStatus ?? ""), regime, isCoreLeader: !!ctx.isCoreLeader, isEtf: !!ctx.isEtf, liquidityStatus: String(ctx.liquidityStatus ?? ""), spreadStatus: String(ctx.spreadStatus ?? ""), accountType: String(ctx.accountType ?? ""), profitPct, rwPartial, reboundHoldbackScore: parseFloat(ctx.reboundHoldbackScore), }); if (action !== "EXIT_100" && action !== "TRAILING_STOP_BREACH" && action !== "HOLD") { const targetRatio = cashPreservePlan.recommended_ratio; if (Number.isFinite(targetRatio) && targetRatio > 0 && targetRatio < ratio) { ratio = targetRatio; if (ratio <= 25) action = "TRIM_25"; else if (ratio <= 33) action = "TRIM_33"; else action = "TRIM_50"; reason = reason ? `${reason}|CASH_PRESERVE:${cashPreservePlan.style}` : `CASH_PRESERVE:${cashPreservePlan.style}`; } } // SL003_PRIORITY_MATRIX: 복수 손절 조건 동시 발동 시 max(prices) 적용 — spec/exit/stop_loss.yaml // TP 계열(PROFIT_TRIM_*, TAKE_PROFIT_TIER1)은 별도 프레임워크이므로 이 블록 적용 제외 const isStopTypeAction_ = /^(EXIT_100|TRIM_70|TRAILING_STOP_BREACH|TRIM_50|TRIM_33|TRIM_25|TIME_EXIT_100|TIME_TRIM_50|TIME_TRIM_25)$/.test(action); if (isStopTypeAction_ && Number.isFinite(close) && close > 0) { const slpCands_ = []; const pushSlp_ = (src, p) => { if (Number.isFinite(p) && p > 0) slpCands_.push({ src, p }); }; if (timingAction === "STOP_OR_TIME_EXIT_READY" || rwPartial >= 4) pushSlp_("HARD_STOP", protectiveLimit); // REGIME 후보는 방향 A에서 포트폴리오 레벨 처리 — SL003에서 제외 if (rwPartial >= 3 || timingExitScore >= 75) pushSlp_("RW_TRIM70", protectiveLimit); if (Number.isFinite(trailingStop) && trailingStop > 0 && close <= trailingStop) pushSlp_("TRAILING", Math.round(trailingStop)); // 트레일링 스탑 가격이 보호선 if (rwPartial >= 2 || (rwPartial >= 1 && timingExitScore >= 50)) pushSlp_("RW_TRIM50", closeProtectLimit); if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 7) pushSlp_("TIME_STOP", closeProtectLimit); if (slpCands_.length >= 2) { const maxSlp_ = slpCands_.reduce((a, b) => b.p > a.p ? b : a); const curPrice_ = parseFloat(price); if (maxSlp_.p > (Number.isFinite(curPrice_) ? curPrice_ : 0)) { price = maxSlp_.p; priceSource = "PRIORITY_MATRIX_MAX"; priceBasis = `SL003_MAX(${slpCands_.map(c => `${c.src}:${c.p}`).join("|")})`; } } } // 방향 A: 수량 계산 없음. 가격이 유효하면 SIGNAL_CONFIRMED. let validation = "NO_SELL_ACTION"; if (action !== "HOLD") { validation = (Number.isFinite(parseFloat(price)) && parseFloat(price) > 0) ? "SIGNAL_CONFIRMED" : "NO_SELL_PRICE"; } return { action, ratio_pct: ratio, limit_price: price, price_source: priceSource, price_basis: priceBasis, execution_window: executionWindow, order_type: orderType, reason, validation, cash_preserve_style: cashPreservePlan.style, cash_preserve_ratio: cashPreservePlan.recommended_ratio, cash_preserve_reason: cashPreservePlan.reasons, }; } // Backward-compatible thin wrapper. // Existing data_feed callers still expect calcSellRoute_. var calcSellRoute_ = function(ctx) { return calcExitSellAction_(ctx || {}); } // ── [2026-05-21_CLA_HARNESS_V1] REPLACEMENT_ALPHA_GATE_V1 ─────────────────── /** * CLA 레짐에서 위성 신규 BUY 전 코어 대비 알파 우위 검증. * spec/13_formula_registry.yaml:REPLACEMENT_ALPHA_GATE_V1 * @return {{ rag_v1: 'PASS'|'FAIL'|'EXEMPT', rag_reason: string }} */ function validateReplacementAlpha_(ctx) { const posRec = ctx.posRec; if (posRec && posRec.position_type === 'core') { return { rag_v1: 'EXEMPT', rag_reason: 'core_exempt' }; } const r = String(ctx.globalRegimePrelim_ || '').toUpperCase(); const isCLA = r.indexOf('CONCENTRATED_LEADER_ADVANCE') >= 0 || r === 'CLA'; if (!isCLA) return { rag_v1: 'EXEMPT', rag_reason: 'regime_not_cla' }; const rsVerdict = String(ctx.rs_verdict || 'UNKNOWN'); const ss001Norm = typeof ctx.ss001_norm === 'number' ? ctx.ss001_norm : null; const excessRet10d = typeof ctx.excess_ret_10d === 'number' ? ctx.excess_ret_10d : null; const coreAvgSS001 = typeof ctx.coreAvgSS001 === 'number' ? ctx.coreAvgSS001 : 60; const condA = ['LEADER', 'MARKET'].includes(rsVerdict); const condB = ss001Norm !== null && ss001Norm >= coreAvgSS001 - 10; const condC = excessRet10d !== null && excessRet10d >= -5; const condD = excessRet10d === null || excessRet10d >= 0 || rsVerdict === 'LEADER'; const pass = condA && condB && condC && condD; return { rag_v1: pass ? 'PASS' : 'FAIL', rag_reason: !condA ? 'rs_verdict_weak' : !condB ? 'ss001_below_core' : !condC ? 'excess_ret_breach' : !condD ? 'rs_slope_negative' : 'pass' }; } // ── F7: 최종 액션 우선순위 엔진 ───────────────────────────────────────────── // LLM이 호출마다 임의 판단하지 않도록 최종 액션·순위 점수를 룰 엔진에서 고정한다. var calcPortfolioActionRoute_ = function(ctx) { const sellAction = String(ctx.sellAction ?? "HOLD"); const sellValidation = String(ctx.sellValidation ?? ""); const allowedAction = String(ctx.allowedAction ?? ""); const timingAction = String(ctx.timingAction ?? ""); const timingEntry = parseFloat(ctx.timingScoreEntry); const timingExit = parseFloat(ctx.timingScoreExit); const ss001Total = parseFloat(ctx.ss001Total); const flowCredit = parseFloat(ctx.flowCredit); const leaderTotal = parseFloat(ctx.leaderTotal); const rwPartial = parseFloat(ctx.rwPartial); const profitPct = parseFloat(ctx.profitPct); const daysToTimeStop = parseFloat(ctx.daysToTimeStop); const weightPct = parseFloat(ctx.weightPct); const acGate = String(ctx.acGate ?? ""); const liquidityStatus = String(ctx.liquidityStatus ?? ""); const spreadStatus = String(ctx.spreadStatus ?? ""); const dartRisk = !!ctx.dartRisk; const missingFields = String(ctx.missingFields ?? ""); let finalAction = "HOLD"; let actionPriority = 99; let sourceTag = "RULE_ENGINE"; if (sellAction !== "HOLD" && sellValidation === "SIGNAL_CONFIRMED") { // 미보유(weightPct=0) 종목에 SELL_READY를 주면 주문수량=0 이므로 WATCH_EXIT_SIGNAL 로 다운그레이드 if (!(weightPct > 0)) { finalAction = "WATCH_EXIT_SIGNAL"; actionPriority = 35; } else { finalAction = "SELL_READY"; actionPriority = 10; } } else if (allowedAction === "EXIT_SIGNAL" || timingAction === "STOP_OR_TIME_EXIT_READY") { finalAction = "EXIT_SIGNAL"; actionPriority = 28; } else if (allowedAction === "REVIEW_EXIT" || timingAction === "EXIT_REVIEW") { finalAction = "EXIT_REVIEW"; actionPriority = 32; } else if (timingAction === "NO_BUY_OVERHEATED" && !dartRisk) { finalAction = "NO_BUY_OVERHEATED"; actionPriority = 50; } else if (allowedAction === "BUY_STAGE1_READY" || timingAction === "BUY_STAGE1_READY") { finalAction = "BUY_STAGE1_READY"; actionPriority = 60; } else if (allowedAction === "BUY_BREAKOUT_PILOT_ONLY" || timingAction === "BUY_BREAKOUT_PILOT_ONLY") { finalAction = "BUY_BREAKOUT_PILOT_ONLY"; actionPriority = 70; } else if (allowedAction === "BUY_PULLBACK_WAIT" || timingAction === "BUY_PULLBACK_WAIT") { finalAction = "BUY_PULLBACK_WAIT"; actionPriority = 80; } else if (allowedAction === "WATCH_CANDIDATE") { finalAction = "WATCH_TIMING_SETUP"; actionPriority = 90; } if (missingFields) sourceTag = "RULE_ENGINE_WITH_MISSING_DATA"; const timeStopUrgency = Number.isFinite(daysToTimeStop) && daysToTimeStop >= 0 ? Math.max(0, 20 - Math.min(20, daysToTimeStop * 3)) : 0; const overweightPenalty = Number.isFinite(weightPct) && weightPct > 7 ? 15 : 0; const overheatPenalty = acGate === "BLOCK" ? 30 : acGate === "CAUTION" ? 10 : 0; const liquidityPenalty = ["LOW", "DATA_MISSING"].includes(liquidityStatus) || ["BLOCK", "WIDE", "QUOTE_NO_MATCH"].includes(spreadStatus) ? 15 : 0; let priorityScore; if (actionPriority <= 40) { priorityScore = (Number.isFinite(timingExit) ? timingExit : 0) * 0.35 + (Number.isFinite(rwPartial) ? rwPartial : 0) * 15 + Math.max(0, Number.isFinite(profitPct) ? profitPct : 0) * 0.30 + timeStopUrgency + overweightPenalty; } else if (actionPriority >= 50 && actionPriority <= 80) { priorityScore = (Number.isFinite(timingEntry) ? timingEntry : 0) * 0.35 + (Number.isFinite(ss001Total) ? ss001Total : 0) * 0.30 + (Number.isFinite(flowCredit) ? flowCredit : 0) * 20 + (Number.isFinite(leaderTotal) ? leaderTotal : 0) * 5 - overheatPenalty - liquidityPenalty; } else { priorityScore = (Number.isFinite(timingEntry) ? timingEntry : 0) * 0.20 + (Number.isFinite(timingExit) ? timingExit : 0) * 0.20 + (Number.isFinite(flowCredit) ? flowCredit : 0) * 10; } return { final_action: finalAction, action_priority: actionPriority, priority_score: parseFloat(Math.max(0, priorityScore).toFixed(2)), source_tag: sourceTag, }; } // Backward-compatible thin wrapper. // Existing data_feed callers still expect calcFinalRoute_. var calcFinalRoute_ = function(ctx) { const d = calcPortfolioActionRoute_(ctx || {}); return { final_action: d.final_action, action_priority: d.action_priority, priority_score: d.priority_score, route_source: d.source_tag, }; } // ── SS001 종목 점수 계산 (spec/08_scoring_rules.yaml SS001_SECTOR_MODEL_SCORE) ── // runDataFeed 루프에서 분리. 1개 종목 → 점수 객체 반환. // ctx 필드: rsPct20D, avgTV5D, avgTV20D, flowCredit, epsRevisionStatus, // regimePrelim, isKosdaq, sfMedPE, sfMedPBR, forwardPE, pbrVal, epsGrowth1y function calcSS001Score_(ctx) { // SS001_P: price_strength (max 25) — RS_Pct_20D → percentile 변환 const rsPercentile = Number.isFinite(ctx.rsPct20D) ? (100 - ctx.rsPct20D) : null; const ss001_p = rsPercentile !== null ? (rsPercentile <= 30 ? 25 : rsPercentile <= 60 ? 15 : 0) : 0; // SS001_V: volume_quality (max 15) const volRatio = Number.isFinite(ctx.avgTV5D) && Number.isFinite(ctx.avgTV20D) && ctx.avgTV20D > 0 ? ctx.avgTV5D / ctx.avgTV20D : null; const ss001_v = volRatio !== null ? (volRatio >= 1.20 ? 15 : volRatio >= 0.80 ? 8 : 0) : 0; // SS001_F: flow_quality (max 25) const fc = ctx.flowCredit ?? 0; const ss001_f = fc >= 0.70 ? 25 : fc >= 0.40 ? 12 : 0; // SS001_E: earnings_revision (max 20) const ss001_e = ctx.epsRevisionStatus === "UP" ? 20 : ctx.epsRevisionStatus === "FLAT" ? 10 : 0; // SS001_M: macro_regime (max 10) const r = ctx.regimePrelim ?? ""; const ss001_m = (r === "RISK_ON" || r === "LEADER_CONCENTRATION" || r === "SECULAR_LEADER_RISK_ON") ? 10 : r === "NEUTRAL" ? 5 : 0; // SS001_VAL: valuation (max 5 KOSPI / max 12 KOSDAQ) let ss001_val = 0, pegVal = "", pegGate = ""; if (ctx.isKosdaq) { const epsG = Number.isFinite(ctx.epsGrowth1y) && ctx.epsGrowth1y > 0 ? ctx.epsGrowth1y : null; if (Number.isFinite(ctx.forwardPE) && epsG !== null) { pegVal = parseFloat((ctx.forwardPE / epsG).toFixed(2)); pegGate = pegVal <= 1.5 ? "PASS" : pegVal <= 2.5 ? "CAUTION" : "REJECT"; ss001_val = pegVal <= 1.0 ? 12 : pegVal <= 1.5 ? 9 : pegVal <= 2.0 ? 5 : pegVal <= 2.5 ? 2 : 0; } else if (Number.isFinite(ctx.forwardPE) && Number.isFinite(ctx.sfMedPE) && ctx.sfMedPE > 0) { pegGate = "FALLBACK"; ss001_val = ctx.forwardPE <= ctx.sfMedPE * 2.0 ? 9 : ctx.forwardPE <= ctx.sfMedPE * 3.0 ? 4 : 0; } } else { const peOk = Number.isFinite(ctx.forwardPE) && Number.isFinite(ctx.sfMedPE) && ctx.sfMedPE > 0; const pbrOk = Number.isFinite(ctx.pbrVal) && Number.isFinite(ctx.sfMedPBR) && ctx.sfMedPBR > 0; if (peOk || pbrOk) { const atOrBelow = (peOk && ctx.forwardPE <= ctx.sfMedPE) || (pbrOk && ctx.pbrVal <= ctx.sfMedPBR); const at1_5x = (peOk && ctx.forwardPE <= ctx.sfMedPE * 1.5) || (pbrOk && ctx.pbrVal <= ctx.sfMedPBR * 1.5); ss001_val = atOrBelow ? 5 : at1_5x ? 2 : 0; } } const ss001_total = ss001_p + ss001_v + ss001_f + ss001_e + ss001_m + ss001_val; const ss001_norm = ss001_total / (ctx.isKosdaq ? 107 : 100) * 100; const ss001_grade = ss001_norm >= 80 ? "A" : ss001_norm >= 65 ? "B" : ss001_norm >= 50 ? "C" : "D"; return { ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val, ss001_total, ss001_norm, ss001_grade, pegVal, pegGate }; } function buildAllowedAction(score, priceStatus, atr20, dartSummary, flowOk, avgTradingValue5D, spreadPct) { if (priceStatus !== "PRICE_OK" || !Number.isFinite(atr20)) return "OBSERVE_ONLY"; if (dartSummary?.risk) return "HOLD_NO_ADD"; if (!flowOk) return "NO_ADD"; if (Number.isFinite(avgTradingValue5D) && avgTradingValue5D < 50) return "NO_ADD"; if (Number.isFinite(spreadPct) && spreadPct > 0.8) return "NO_ADD"; if (score >= 70 && dartSummary?.status === "NAVER_NOTICE_EMPTY") return "HOLD"; if (score >= 50) return "CONDITIONAL_HOLD"; return "SELL_ALLOWED"; } function calcCoreCandidateQualityGrade_(ctx) { const score = parseFloat(ctx.rotationScore); const flowOk = String(ctx.flowOk ?? "") === "Y" || ctx.flowOk === true; const priceStatus = String(ctx.priceStatus ?? ""); const liquidityStatus = String(ctx.liquidityStatus ?? ""); const dartRisk = String(ctx.dartRisk ?? "").trim(); const missing = String(ctx.missingFields ?? "").trim(); if (priceStatus !== "PRICE_OK" || missing || dartRisk || ["LOW", "DATA_MISSING"].includes(liquidityStatus)) return "D"; if (Number.isFinite(score) && score >= 80 && flowOk) return "A"; if (Number.isFinite(score) && score >= 65 && flowOk) return "B"; if (Number.isFinite(score) && score >= 50) return "C"; return "D"; } function calcT1ForcedSellRisk_(ctx) { let score = 0; const reasons = []; const sellAction = String(ctx.sellAction ?? ""); const sellValidation = String(ctx.sellValidation ?? ""); const timingExit = parseFloat(ctx.timingScoreExit); const rwPartial = parseFloat(ctx.rwPartial); const rsi14 = parseFloat(ctx.rsi14); const disparity = parseFloat(ctx.disparity); const valSurge = parseFloat(ctx.valSurgePct); const ret5D = parseFloat(ctx.ret5D); const dartRisk = String(ctx.dartRisk ?? "").trim(); const lateChase = parseFloat(ctx.lateChaseRiskScore); const distribution = parseFloat(ctx.distributionRiskScore); if (sellAction && sellAction !== "HOLD" && sellValidation !== "NO_SELL_ACTION") { score += 40; reasons.push("SELL_ACTION_ACTIVE"); } if (Number.isFinite(timingExit) && timingExit >= 50) { score += 25; reasons.push("TIMING_EXIT>=50"); } if (Number.isFinite(rwPartial) && rwPartial >= 2) { score += 25; reasons.push("RW>=2"); } if (Number.isFinite(distribution) && distribution >= 70) { score += 30; reasons.push("DISTRIBUTION>=70"); } if (Number.isFinite(lateChase) && lateChase >= 70) { score += 25; reasons.push("LATE_CHASE>=70"); } if ((Number.isFinite(rsi14) && rsi14 > 75) || (Number.isFinite(disparity) && disparity > 12)) { score += 20; reasons.push("OVERHEATED"); } if (Number.isFinite(valSurge) && valSurge >= 40 && Number.isFinite(ret5D) && ret5D > 8) { score += 15; reasons.push("SURGE_AFTER_RUNUP"); } if (dartRisk) { score += 30; reasons.push("DART_RISK"); } score = Math.max(0, Math.min(100, Math.round(score))); const state = score >= 70 ? "BUY_BLOCKED_T1_EXIT_RISK" : score >= 50 ? "WATCH_ONLY_T1_RISK" : "PASS"; return { score, state, reason: reasons.join("|") || "PASS" }; } function calcSellConflictScore_(ctx) { let score = 0; const reasons = []; const sellFinal = String(ctx.sellFinal ?? ""); const sellAction = String(ctx.sellAction ?? ""); const cashStyle = String(ctx.cashPreserveStyle ?? ""); const allowedAction = String(ctx.allowedAction ?? ""); if (["SELL_READY", "EXIT_SIGNAL", "EXIT_REVIEW"].includes(sellFinal) || (sellAction && sellAction !== "HOLD")) { score += 55; reasons.push("SELL_SIGNAL_ACTIVE"); } if (cashStyle && cashStyle !== "NONE") { score += 20; reasons.push("CASH_PRESERVE_ACTIVE"); } if (["NO_ADD", "HOLD_NO_ADD", "OBSERVE_ONLY"].includes(allowedAction)) { score += 20; reasons.push("NO_ADD_GATE"); } score = Math.max(0, Math.min(100, Math.round(score))); const state = score >= 70 ? "BUY_BLOCKED_SELL_CONFLICT" : score >= 40 ? "SELL_OR_TRIM_FIRST" : "PASS"; return { score, state, reason: reasons.join("|") || "PASS" }; } function calcCoreSatelliteExecutionState_(ctx) { const quality = String(ctx.candidateQualityGrade ?? ""); const timingAction = String(ctx.timingAction ?? ""); const entryGate = String(ctx.entryModeGate ?? ""); const t1State = String(ctx.t1State ?? ""); const sellConflictState = String(ctx.sellConflictState ?? ""); const allowedAction = String(ctx.allowedAction ?? ""); if (sellConflictState === "BUY_BLOCKED_SELL_CONFLICT" || sellConflictState === "SELL_OR_TRIM_FIRST") return sellConflictState; if (t1State === "BUY_BLOCKED_T1_EXIT_RISK" || t1State === "WATCH_ONLY_T1_RISK") return t1State; if (["NO_ADD", "HOLD_NO_ADD", "OBSERVE_ONLY"].includes(allowedAction)) return "BUY_BLOCKED_PORTFOLIO_GUARD"; if (quality === "A" && entryGate === "PASS" && ["BUY_STAGE1_READY", "BUY_BREAKOUT_PILOT_ONLY"].includes(timingAction)) return "BUY_PILOT_ALLOWED"; if (quality === "A" || quality === "B") { if (entryGate === "PASS") return "WATCH_BREAKOUT_RETEST"; return "WATCH_PULLBACK"; } return "CANDIDATE_ONLY"; } function calcApexTradePlan_(h, df, h1, alphaRow, ftRow, distRow, priceRow, orderRow, sq, profitRow, cashShortfallInfo, saqgState) { // THIN_ADAPTER: [sizing/normalize] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_position_size var buyState = 'BLOCKED'; var buyReasons = []; if (h1.cashFloorStatus !== 'PASS') buyReasons.push('cash_floor_not_pass'); if (h1.heatGate === 'BLOCK_NEW_BUY') buyReasons.push('heat_block_new_buy'); if (distRow.anti_distribution_state !== 'PASS') buyReasons.push('distribution_' + distRow.anti_distribution_state); if (alphaRow.lead_entry_state === 'PILOT_ALLOWED' && buyReasons.length === 0) buyState = 'ALLOW_PILOT'; else if (ftRow.follow_through_state === 'CONFIRMED_ADD_ON' && buyReasons.length === 0) buyState = 'ALLOW_ADD_ON'; else if (buyReasons.length === 0) buyState = 'WATCH'; if (saqgState === 'EXCLUDED') { buyState = 'BLOCKED'; buyReasons.push('saqg_EXCLUDED'); } else if (saqgState === 'WATCHLIST_ONLY' && (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON')) { buyState = 'WATCH'; buyReasons.push('saqg_WATCHLIST_ONLY'); } var style = 'URGENT_LIQUIDITY_TRIM'; if ((df.rsi14 && df.rsi14 < 35) || (df.bbPosition && df.bbPosition < 20) || (df.ma20 && h.close && h.close < df.ma20 * 0.92)) { style = 'OVERSOLD_REBOUND_SELL'; } else if (distRow.anti_distribution_state === 'BLOCK_BUY') { style = 'DISTRIBUTION_EXIT'; } else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20' || profitRow.profit_preservation_state === 'PROFIT_LOCK_30' || profitRow.profit_preservation_state === 'APEX_TRAILING') { style = 'PROFIT_PROTECT_TRIM'; } var baseQty = typeof sq.sell_qty === 'number' ? sq.sell_qty : 0; var close = h.close || df.close || 0; var prevClose = df.prevClose || close; var atr20 = df.atr20 || 0; var holdingQty = h.holdingQty || 0; var shortfallMin = cashShortfallInfo.cash_shortfall_min_krw || 0; var immediateQty; var reboundQty; var k2Emergency; if (style === 'OVERSOLD_REBOUND_SELL') { var halfQty = Math.floor(baseQty / 2); var halfExpectedKrw = halfQty * close; k2Emergency = shortfallMin > 0 && (halfExpectedKrw * 2 < shortfallMin); if (k2Emergency) { immediateQty = baseQty; reboundQty = 0; } else { immediateQty = halfQty; reboundQty = Math.max(0, baseQty - halfQty); } var overSoldCap = holdingQty; if (profitRow.profit_preservation_state === 'PROFIT_LOCK_30' || profitRow.profit_preservation_state === 'APEX_TRAILING') { overSoldCap = Math.floor(holdingQty * 0.40); } else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20') { overSoldCap = Math.floor(holdingQty * 0.35); } else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_10') { overSoldCap = Math.floor(holdingQty * 0.30); } else { overSoldCap = Math.floor(holdingQty * 0.50); } immediateQty = Math.min(immediateQty, overSoldCap); } else { k2Emergency = false; var capPct = 50; if (style === 'PROFIT_PROTECT_TRIM') { if (profitRow.profit_preservation_state === 'PROFIT_LOCK_30' || profitRow.profit_preservation_state === 'APEX_TRAILING') capPct = 50; else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20') capPct = 35; else capPct = 25; } else if (style === 'DISTRIBUTION_EXIT') { capPct = 50; } immediateQty = Math.min(baseQty, Math.floor(holdingQty * capPct / 100)); reboundQty = 0; } var hasPosition = holdingQty > 0; var tranchePhase; var currentTrancheAllowedPct; var nextTrancheCondition; if (!hasPosition) { if (alphaRow.lead_entry_state === 'PILOT_ALLOWED' && buyState === 'ALLOW_PILOT') { tranchePhase = 'TRANCHE_1_PILOT'; currentTrancheAllowedPct = 30; nextTrancheCondition = 'CONFIRMED_ADD_ON'; } else { tranchePhase = 'WAIT_PILOT_SETUP'; currentTrancheAllowedPct = 0; nextTrancheCondition = 'ALPHA_LEAD_SCORE_GTE_75_AND_DISTRIBUTION_PASS'; } } else if (ftRow.follow_through_state === 'CONFIRMED_ADD_ON' && buyState === 'ALLOW_ADD_ON') { tranchePhase = 'TRANCHE_2_ADD_ON'; currentTrancheAllowedPct = 30; nextTrancheCondition = 'SECONDARY_PULLBACK_TO_MA20'; } else if (alphaRow.close_vs_ma20_pct !== null && alphaRow.close_vs_ma20_pct <= 2 && profitRow.profit_pct > 3 && ftRow.follow_through_state !== 'FAILED_BREAKOUT' && buyState === 'ALLOW_ADD_ON') { tranchePhase = 'TRANCHE_3_PULLBACK_ADD'; currentTrancheAllowedPct = 40; nextTrancheCondition = 'HOLD_FULL_POSITION'; } else { tranchePhase = 'HOLD_CURRENT'; currentTrancheAllowedPct = 0; nextTrancheCondition = ftRow.follow_through_state === 'FAILED_BREAKOUT' ? 'RECOVERY_ABOVE_MA20' : 'CONFIRMED_ADD_ON_OR_PULLBACK'; } var sellRawPrice = null; if (close > 0) { if (style === 'URGENT_LIQUIDITY_TRIM') { sellRawPrice = prevClose > 0 ? Math.min(close, prevClose * 0.998) : close * 0.998; } else if (style === 'OVERSOLD_REBOUND_SELL') { sellRawPrice = close; } else if (style === 'DISTRIBUTION_EXIT') { sellRawPrice = atr20 > 0 ? close - 0.25 * atr20 : close * 0.997; } else if (style === 'PROFIT_PROTECT_TRIM') { var ratchetStop = priceRow.ratchet_stop_price || 0; sellRawPrice = ratchetStop > 0 ? Math.max(ratchetStop, close * 0.999) : close * 0.999; } } var buyRawPrice = null; if (close > 0) { if (buyState === 'ALLOW_PILOT') { buyRawPrice = Math.min(close * 1.002, df.ma20 > 0 ? df.ma20 * 1.08 : close * 1.002); } else if (buyState === 'ALLOW_ADD_ON') { buyRawPrice = prevClose > 0 ? Math.min(close * 1.002, prevClose * 1.01) : close * 1.002; } } var normalizedSellPrice = (sellRawPrice && sellRawPrice > 0) ? tickNormalize_(sellRawPrice) : null; var normalizedBuyPrice = (buyRawPrice && buyRawPrice > 0) ? tickNormalize_(buyRawPrice) : null; var htsLimitPrice = orderRow.limit_price_krw ? tickNormalize_(orderRow.limit_price_krw) : normalizedSellPrice || normalizedBuyPrice; return { buyState: buyState, buyReasons: buyReasons, style: style, immediateQty: immediateQty, reboundQty: reboundQty, k2Emergency: k2Emergency, tranchePhase: tranchePhase, currentTrancheAllowedPct: currentTrancheAllowedPct, nextTrancheCondition: nextTrancheCondition, normalizedSellPrice: normalizedSellPrice, normalizedBuyPrice: normalizedBuyPrice, htsLimitPrice: htsLimitPrice, }; } // ── account_snapshot 읽기 → TOTAL_HEAT_V1 계산 ─────────────────────────────── // account_snapshot이 보유수량·평단·선택 손절가의 단일 원장이다. // stop_price 미입력이면 ATR 기반 추정으로 대체. // total_asset_krw를 인수로 받아야 정확한 열%를 계산할 수 있음; 미제공 시 null. function readAccountSnapshotHeat_(total_asset_krw) { const UNKNOWN = { total_heat_pct: null, total_heat_krw: null, hf005_status: "UNKNOWN (account_snapshot 없음)", positions_count: 0 }; try { const ss = getSpreadsheet_(); const snapshot = readAccountSnapshotMap_(); if (!snapshot.rows_confirmed) return UNKNOWN; // data_feed ATR20 읽기 (stop_price 미입력 시 추정용) const atrMap = {}; try { const dfSheet = ss.getSheetByName("data_feed"); if (dfSheet) { const dfData = dfSheet.getDataRange().getValues(); const dfHdr = dfData[1]?.map(h => String(h).trim()) ?? []; const dfTkr = dfHdr.indexOf("Ticker"); const dfAtr = dfHdr.indexOf("ATR20"); const dfClose= dfHdr.indexOf("Close"); if (dfTkr >= 0 && dfAtr >= 0 && dfClose >= 0) { for (let i = 2; i < dfData.length; i++) { const tk = String(dfData[i][dfTkr]).trim(); const atr = parseFloat(dfData[i][dfAtr]); const cls = parseFloat(dfData[i][dfClose]); if (tk && Number.isFinite(atr) && Number.isFinite(cls)) atrMap[tk] = { atr20: atr, close: cls }; } } } } catch(e2) { } let totalHeatKrw = 0; let posCount = 0; let hasEstimate = false; const details = []; Object.values(snapshot.positions).forEach(pos => { const qty = parseInt(pos.quantity, 10); if (!Number.isFinite(qty) || qty <= 0) return; const entry = parseFloat(pos.average_cost ?? pos.entry_price); if (!Number.isFinite(entry) || entry <= 0) return; let stop = parseFloat(pos.stop_price); if (!Number.isFinite(stop) || stop <= 0) { // ATR 기반 추정 const tk = pos.ticker; const atrInfo = atrMap[tk]; if (atrInfo) { stop = entry - atrInfo.atr20 * THRESHOLDS.ATR_TRAILING_MULT; hasEstimate = true; } else { stop = entry * 0.92; // 8% 고정 추정 hasEstimate = true; } } if (stop >= entry) return; // PS002 위반 행 건너뜀 const heatKrw = (entry - stop) * qty; totalHeatKrw += heatKrw; posCount++; details.push(`${qty}주×${Math.round(entry-stop)}원`); }); if (posCount === 0) return { total_heat_pct: 0, total_heat_krw: 0, hf005_status: "PASS (포지션 없음)", positions_count: 0 }; const estTag = hasEstimate ? "(ATR추정)" : ""; if (!Number.isFinite(total_asset_krw) || total_asset_krw <= 0) { return { total_heat_pct: null, total_heat_krw: Math.round(totalHeatKrw), hf005_status: `UNKNOWN (총자산 미제공)${estTag}`, positions_count: posCount, }; } const heatPct = (totalHeatKrw / total_asset_krw) * 100; const hf005 = heatPct >= 10 ? `BLOCK (>= 10%: ${heatPct.toFixed(1)}%)${estTag}` : `PASS (< 10%: ${heatPct.toFixed(1)}%)${estTag}`; return { total_heat_pct: parseFloat(heatPct.toFixed(2)), total_heat_krw: Math.round(totalHeatKrw), hf005_status: hf005, positions_count: posCount, }; } catch(e) { handleFetchError_("readAccountSnapshotHeat_", e, "WARN"); return { total_heat_pct: null, total_heat_krw: null, hf005_status: "ERROR: " + e.message, positions_count: 0 }; } } // 상승 추세 보존 점수: 높을수록 매도 우선순위를 늦춘다. function calcReboundHoldbackScore_(ctx) { const close = parseFloat(ctx.close); const ma20 = parseFloat(ctx.ma20); const ma60 = parseFloat(ctx.ma60); const ma20Slope = parseFloat(ctx.ma20Slope); const rsi14 = parseFloat(ctx.rsi14); const bbPosition = parseFloat(ctx.bbPosition); const flowCredit = parseFloat(ctx.flowCredit); const leaderTotal = parseFloat(ctx.leaderTotal); const leaderGate = String(ctx.leaderGate ?? ""); const bandStatus = String(ctx.bandStatus ?? ""); const profitPct = parseFloat(ctx.profitPct); const isCoreLeader = !!ctx.isCoreLeader; let score = 0; const reasons = []; const aboveMa20 = Number.isFinite(close) && Number.isFinite(ma20) && close >= ma20; const aboveMa60 = Number.isFinite(close) && Number.isFinite(ma60) && close >= ma60; if (isCoreLeader && aboveMa20 && Number.isFinite(ma20Slope) && ma20Slope > 0) { score += 12; reasons.push("core_uptrend:+12"); } else if (aboveMa20 && Number.isFinite(ma20Slope) && ma20Slope > 0) { score += 8; reasons.push("trend_hold:+8"); } if (Number.isFinite(leaderTotal) && leaderTotal >= 80) { score += 6; reasons.push("leader_total:+6"); } else if (leaderGate === "PASS") { score += 4; reasons.push("leader_pass:+4"); } if (Number.isFinite(flowCredit) && flowCredit >= 0.7) { score += 6; reasons.push("flow_strong:+6"); } if (Number.isFinite(rsi14)) { if (rsi14 <= 62) { score += 4; reasons.push("rsi_room:+4"); } else if (rsi14 >= 72) { score -= 6; reasons.push("rsi_hot:-6"); } } if (Number.isFinite(bbPosition) && bbPosition <= 0.7) { score += 3; reasons.push("bb_room:+3"); } if (bandStatus === "UNDERWEIGHT") { score += 3; reasons.push("band_under:+3"); } if (Number.isFinite(profitPct) && profitPct >= 0 && aboveMa20 && aboveMa60) { score += 3; reasons.push("runner:+3"); } return { score: Math.max(0, Math.min(30, score)), reasons: reasons.join(" | "), }; } // 현금확보 시 반등 보존형 감축 계획. // score는 sell_priority_score에서 보호 보너스로 쓰고, recommended_ratio는 주문 감축비율로 쓴다. function calcCashPreservationPlan_(ctx) { const cashFloorStatus = String(ctx.cashFloorStatus ?? ""); const regime = String(ctx.regime ?? ""); const sellAction = String(ctx.sellAction ?? ctx.action ?? ""); const isSellLike = /(SELL|TRIM|EXIT)/.test(sellAction); const isCoreLeader = !!ctx.isCoreLeader; const isEtf = !!ctx.isEtf; const liquidityStatus = String(ctx.liquidityStatus ?? ""); const spreadStatus = String(ctx.spreadStatus ?? ""); const accountType = String(ctx.accountType ?? ""); const profitPct = parseFloat(ctx.profitPct); const rwPartial = parseInt(ctx.rwPartial, 10) || 0; const reboundHoldback = parseFloat(ctx.reboundHoldbackScore); const holdbackScore = Number.isFinite(reboundHoldback) ? reboundHoldback : 0; let recommendedRatio = isSellLike ? 50 : 0; let style = "STEP_50"; let protectionBonus = 0; const reasons = []; if (isCoreLeader && holdbackScore >= 12) { style = "CORE_LAST"; recommendedRatio = cashFloorStatus === "TRIM_REQUIRED" ? 25 : 0; protectionBonus += 12; reasons.push("core_last"); } else if (holdbackScore >= 18) { style = "STEP_25"; recommendedRatio = 25; protectionBonus += 10; reasons.push("strong_rebound"); } else if (holdbackScore >= 10) { style = "STEP_33"; recommendedRatio = 33; protectionBonus += 6; reasons.push("rebound_preserve"); } if (isEtf && holdbackScore < 10) { protectionBonus -= 2; reasons.push("etf_cash_raise"); } if (cashFloorStatus === "TRIM_REQUIRED" || /RISK_OFF/.test(regime)) { protectionBonus += 2; reasons.push("cash_preserve"); } if (liquidityStatus === "LOW" || spreadStatus === "WIDE" || spreadStatus === "BLOCK") { protectionBonus += 4; reasons.push("impact_avoid"); } if (accountType === "일반계좌" && Number.isFinite(profitPct) && profitPct > 0) { protectionBonus += profitPct >= 20 ? 3 : 2; reasons.push("tax_drag"); } else if (accountType === "일반계좌" && Number.isFinite(profitPct) && profitPct < 0) { protectionBonus -= 2; reasons.push("tax_loss_harvest"); } if (rwPartial >= 3 && !isCoreLeader) { recommendedRatio = Math.max(recommendedRatio, 50); protectionBonus -= 4; reasons.push("rw_force"); } if (cashFloorStatus === "HARD_BLOCK") { recommendedRatio = Math.max(recommendedRatio, 50); reasons.push("cash_hard_block"); } if (!isSellLike) recommendedRatio = 0; recommendedRatio = Math.max(0, Math.min(50, recommendedRatio)); return { style, recommended_ratio: recommendedRatio, protection_bonus: Math.max(0, Math.round(protectionBonus)), reasons: reasons.join(" | "), }; } // ── 메인: 보유 종목 완성도 매트릭스 ───────────────────────────────────── // data_feed는 보유 종목 원장 + 완성도 매트릭스의 canonical output. // ── Sell_Priority_Score 산출 헬퍼 ──────────────────────────────────────────── // spec: spec/risk/portfolio_exposure.yaml:sell_priority_engine.candidate_scoring // 입력: row 배열(data_feed headers 순서), headers 배열, sectorExposureMap(섹터→총비중%) // 반환: { score, breakdown, priority_level, is_etf, is_core_leader } // 호출 시점: runDataFeed post-loop(섹터집계 완료 후) & getDailyBrief/runSellPriority var calcSellSignalSanityScore_ = function(row, headers, sectorExposureMap) { const get = (col) => { const i = headers.indexOf(col); return i >= 0 ? row[i] : undefined; }; const flt = (col) => { const v = parseFloat(get(col)); return Number.isFinite(v) ? v : null; }; const finalAction = String(get("Final_Action") ?? ""); const sellAction = String(get("Sell_Action") ?? ""); const ticker = String(get("Ticker") ?? ""); const name_ = String(get("Name") ?? ""); const rwPartial = parseInt(get("RW_Partial")) || 0; const weightPct = flt("Weight_Pct") ?? 0; const profitPct = flt("Profit_Pct"); const close_ = flt("Close"); const ma20_ = flt("MA20"); const ma60_ = flt("MA60"); const ma20Slope_ = flt("MA20_Slope"); const rsi14_ = flt("RSI14"); const bbPos_ = flt("BB_Position"); const flowCredit_ = flt("Flow_Credit"); const leaderTotal_= flt("Leader_Scan_Total"); const leaderGate_ = String(get("Leader_Gate") ?? ""); const bandStatus_ = String(get("Band_Status") ?? ""); const ss001Grade = String(get("SS001_Grade") ?? ""); const liquidityStatus_ = String(get("Liquidity_Status") ?? ""); const avgTradeValue5DM_ = flt("AvgTradeValue_5D_M"); const avgTradeValue5DKrw_= flt("AvgTradeValue_5D_KRW"); const spreadStatus_ = String(get("Spread_Status") ?? ""); const accountType_ = String(get("account_type") ?? get("Account_Type") ?? ""); const taxCostEstimate_ = flt("Tax_Cost_Estimate"); // ETF 여부: 이름 패턴 기준 const isEtf = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(name_); // 직접 코어 주도주 (삼성전자·SK하이닉스) const isCoreLeader = (ticker === "005930" || ticker === "000660"); // 상승추세 여부 const inUptrend = Number.isFinite(close_) && Number.isFinite(ma20_) && close_ >= ma20_; // 섹터 총노출 const sector = TICKER_SECTOR_MAP[ticker] ?? ""; const sectorExp = (sectorExposureMap ?? {})[sector] ?? 0; let score = 0; const breakdown = []; // ── 1. hard_precedence_points ───────────────────────────────────────────── if (sellAction === "EXIT_100" || finalAction === "EXIT_SIGNAL") { score += THRESHOLDS.SP_HARD_STOP; breakdown.push(`hard_stop:+${THRESHOLDS.SP_HARD_STOP}`); } else if (finalAction === "SELL_READY" || sellAction.includes("TRIM") || sellAction.includes("EXIT")) { score += THRESHOLDS.SP_SELL_SIGNAL; breakdown.push(`sell_signal:+${THRESHOLDS.SP_SELL_SIGNAL}`); } else if (finalAction === "EXIT_REVIEW") { score += THRESHOLDS.SP_HOLDINGS_ROTATE; breakdown.push(`exit_review:+${THRESHOLDS.SP_HOLDINGS_ROTATE}`); } else if (Number.isFinite(profitPct) && profitPct >= 10) { score += THRESHOLDS["SP_TAKE_PROFIT"]; breakdown.push(`take_profit:+${THRESHOLDS["SP_TAKE_PROFIT"]}`); } // ── 2. duplicate_exposure_points (ETF 중복 노출) ────────────────────────── if (isEtf) { if (sectorExp >= THRESHOLDS.SP_DUPLICATE_THRESH) { score += THRESHOLDS.SP_ETF_DUPLICATE; breakdown.push(`etf_dup(${sector}${sectorExp.toFixed(1)}%):+${THRESHOLDS.SP_ETF_DUPLICATE}`); } else if (sectorExp >= 10) { score += THRESHOLDS.SP_ETF_MODERATE; breakdown.push(`etf_moderate:+${THRESHOLDS.SP_ETF_MODERATE}`); } } // ── 3. cash_relief_points (보유 비중 → 현금 회복 기여) ──────────────────── if (weightPct >= 3) { score += THRESHOLDS.SP_CASH_LARGE; breakdown.push(`cash_${weightPct.toFixed(1)}%:+${THRESHOLDS.SP_CASH_LARGE}`); } else if (weightPct >= 1) { score += THRESHOLDS.SP_CASH_MID; breakdown.push(`cash_mid:+${THRESHOLDS.SP_CASH_MID}`); } else { score += THRESHOLDS.SP_CASH_SMALL; } // ── 4. weakness_points ─────────────────────────────────────────────────── if (rwPartial >= 4) { score += THRESHOLDS.SP_RW4; breakdown.push(`rw${rwPartial}:+${THRESHOLDS.SP_RW4}`); } else if (rwPartial === 3) { score += THRESHOLDS.SP_RW3; breakdown.push(`rw3:+${THRESHOLDS.SP_RW3}`); } else if (rwPartial === 2) { score += THRESHOLDS.SP_RW2; breakdown.push(`rw2:+${THRESHOLDS.SP_RW2}`); } if (Number.isFinite(close_) && Number.isFinite(ma20_) && close_ < ma20_) { score += THRESHOLDS.SP_BELOW_MA20; breakdown.push(`below_ma20:+${THRESHOLDS.SP_BELOW_MA20}`); } // 손실 위성: -10% 이하, 비ETF, 비코어리더 if (!isEtf && !isCoreLeader && Number.isFinite(profitPct) && profitPct <= -10) { score += THRESHOLDS.SP_LOSS_SATELLITE; breakdown.push(`loss_sat(${profitPct.toFixed(1)}%):+${THRESHOLDS.SP_LOSS_SATELLITE}`); } // ── 5. overweight_points ───────────────────────────────────────────────── const targetW = isEtf ? 7 : (isCoreLeader ? 15 : 7); const excess = weightPct - targetW; if (excess >= 5) { score += THRESHOLDS.SP_OVERWEIGHT_LARGE; breakdown.push(`overweight:+${THRESHOLDS.SP_OVERWEIGHT_LARGE}`); } else if (excess >= 2) { score += THRESHOLDS.SP_OVERWEIGHT_MID; breakdown.push(`overweight:+${THRESHOLDS.SP_OVERWEIGHT_MID}`); } // ── 6. core_quality_protection_points (음수 패널티) ────────────────────── if (isCoreLeader && inUptrend) { score += THRESHOLDS.SP_CORE_LEADER; // -20 breakdown.push(`core_leader_uptrend:${THRESHOLDS.SP_CORE_LEADER}`); } if (ss001Grade === "A") { score += THRESHOLDS.SP_SS001_A; // -12 breakdown.push(`ss001_A:${THRESHOLDS.SP_SS001_A}`); } const reboundHoldback_ = calcReboundHoldbackScore_({ close: close_, ma20: ma20_, ma60: ma60_, ma20Slope: ma20Slope_, rsi14: rsi14_, bbPosition: bbPos_, flowCredit: flowCredit_, leaderTotal: leaderTotal_, leaderGate: leaderGate_, bandStatus: bandStatus_, profitPct: profitPct, isCoreLeader: isCoreLeader, }); if (reboundHoldback_.score > 0) { score -= reboundHoldback_.score; breakdown.push(`rebound_holdback:-${reboundHoldback_.score}${reboundHoldback_.reasons ? `(${reboundHoldback_.reasons})` : ""}`); } const preservationPlan_ = calcCashPreservationPlan_({ sellAction: finalAction, cashFloorStatus: String(get("Cash_Floor_Status") ?? ""), regime: String(get("Market_Regime") ?? ""), isCoreLeader: isCoreLeader, isEtf: isEtf, liquidityStatus: liquidityStatus_, spreadStatus: spreadStatus_, accountType: accountType_, profitPct: profitPct, rwPartial: rwPartial, reboundHoldbackScore: reboundHoldback_.score, }); if (preservationPlan_.protection_bonus > 0) { score -= preservationPlan_.protection_bonus; breakdown.push(`cash_preserve:-${preservationPlan_.protection_bonus}${preservationPlan_.reasons ? `(${preservationPlan_.reasons})` : ""}`); } if (liquidityStatus_ === "OK" || (Number.isFinite(avgTradeValue5DM_) && avgTradeValue5DM_ >= 1000) || (Number.isFinite(avgTradeValue5DKrw_) && avgTradeValue5DKrw_ >= 1000000000)) { score += 5; breakdown.push("liquidity_ok:+5"); } else if (liquidityStatus_ === "LOW" || (Number.isFinite(avgTradeValue5DM_) && avgTradeValue5DM_ > 0 && avgTradeValue5DM_ < 100)) { score -= 10; breakdown.push("liquidity_low:-10"); } let taxPenalty = 3; let taxReason = "tax_unknown"; if (accountType_ === "ISA" || accountType_ === "연금저축") { taxPenalty = 0; taxReason = "tax_exempt"; } else if (Number.isFinite(taxCostEstimate_) && taxCostEstimate_ > 0) { taxPenalty = Math.min(10, Math.round(taxCostEstimate_)); taxReason = "tax_cost_estimate"; } else if (Number.isFinite(profitPct) && profitPct > 0) { taxPenalty = profitPct >= 20 ? 10 : 5; taxReason = "tax_drag"; } else if (Number.isFinite(profitPct) && profitPct < 0) { taxPenalty = -5; taxReason = "tax_loss_harvest"; } score -= taxPenalty; breakdown.push(`tax_penalty:-${taxPenalty}${taxReason ? `(${taxReason})` : ""}`); // 우선순위 단계 레이블 (spec: funding_order ①~④) let priority_level; if (sellAction === "EXIT_100" || finalAction === "EXIT_SIGNAL") { priority_level = "1_hard_stop"; } else if (finalAction === "SELL_READY") { priority_level = "2_sell_signal"; } else if (isEtf && sectorExp >= 10) { priority_level = "3_duplicate_etf"; } else if (!isEtf && !isCoreLeader && Number.isFinite(profitPct) && profitPct <= -10) { priority_level = "4_loss_satellite"; } else if (!isCoreLeader && rwPartial >= 3) { priority_level = "5_rw_weakness"; } else if (Number.isFinite(profitPct) && profitPct >= 10) { priority_level = "6_profit_lock"; } else if (isCoreLeader && inUptrend) { priority_level = "9_core_leader_last"; } else { priority_level = "7_general_rebalance"; } return { score: Math.min(100, Math.max(0, score)), breakdown: breakdown.join(" | "), priority_level, is_etf: isEtf, is_core_leader: isCoreLeader, sector, sector_exposure_pct: parseFloat(sectorExp.toFixed(1)), rebound_holdback_score: reboundHoldback_.score, rebound_holdback_reason: reboundHoldback_.reasons, cash_preserve_style: preservationPlan_.style, cash_preserve_ratio: preservationPlan_.recommended_ratio, cash_preserve_reason: preservationPlan_.reasons, }; } // Backward-compatible thin wrapper. // Existing data_feed callers still expect calcSellPriorityScore_. var calcSellPriorityScore_ = function(row, headers, sectorExposureMap) { return calcSellSignalSanityScore_(row, headers, sectorExposureMap); }; // ── sell_priority_engine: 전 보유종목 매도 우선순위 순위표 ────────────────── // spec: spec/risk/portfolio_exposure.yaml:sell_priority_engine // doGet: ?view=sell_priority // 활성화 조건: 현금 < 목표, REGIME_TRIM_50, 또는 SELL/TRIM 후보 2개 이상 // 핵심 보호 원칙: SK하이닉스·삼성전자(코어 주도주)는 hard_stop 없이는 마지막 순위 function runSellPriority() { const port = getPortfolioJson(); const macro = getMacroJson(); const holdings = port.holdings ?? []; const regime_ = String(macro.market_regime ?? ""); const computedAt_ = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd'T'HH:mm:ssXXX"); // 섹터 노출 집계 const sectorExpMap_ = {}; holdings.forEach(h => { const sec_ = TICKER_SECTOR_MAP[h.Ticker] ?? ""; const w_ = parseFloat(h.Weight_Pct); if (sec_ && Number.isFinite(w_) && w_ > 0) sectorExpMap_[sec_] = (sectorExpMap_[sec_] || 0) + w_; }); const validWeightCount_ = holdings.filter(h => { const w = parseFloat(h.Weight_Pct); return Number.isFinite(w) && w > 0; }).length; const missingWeightCount_ = holdings.length - validWeightCount_; const asConfirmStats_ = getAccountSnapshotConfirmStats_(); const rows_ = holdings .filter(h => { const w = parseFloat(h.Weight_Pct); return Number.isFinite(w) && w > 0; }) .map(h => { const isEtf_ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(h.Name); const isCL_ = (h.Ticker === "005930" || h.Ticker === "000660"); const sec_ = TICKER_SECTOR_MAP[h.Ticker] ?? ""; const sExp_ = sectorExpMap_[sec_] ?? 0; const pctP_ = parseFloat(h.Profit_Pct); const rw_ = parseInt(h.RW_Partial) || 0; const cl_ = parseFloat(h.Close); const ma20_ = parseFloat(h.MA20); const ma60_ = parseFloat(h.MA60); const ma20Slope_ = parseFloat(h.MA20_Slope); const rsi14_ = parseFloat(h.RSI14); const bbPos_ = parseFloat(h.BB_Position); const flowCredit_ = parseFloat(h.Flow_Credit); const leaderTotal_ = parseFloat(h.Leader_Scan_Total); const leaderGate_ = String(h.Leader_Gate ?? ""); const bandStatus_ = String(h.Band_Status ?? ""); const inUp_ = Number.isFinite(cl_) && Number.isFinite(ma20_) && cl_ >= ma20_; const precomp = parseFloat(h.Sell_Priority_Score); let score_; if (Number.isFinite(precomp)) { score_ = precomp; } else { score_ = 0; if (h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") score_ += 50; else if (h.Final_Action === "SELL_READY") score_ += 40; else if (isEtf_ && sExp_ >= THRESHOLDS.SP_DUPLICATE_THRESH) score_ += 20; if (rw_ >= 4) score_ += 20; else if (rw_ === 3) score_ += 15; else if (rw_ === 2) score_ += 8; if (!isEtf_ && !isCL_ && Number.isFinite(pctP_) && pctP_ <= -10) score_ += 12; if (isCL_ && inUp_) score_ -= 20; if (h.SS001_Grade === "A") score_ -= 12; score_ = Math.max(0, score_); } const reboundHoldback_ = calcReboundHoldbackScore_({ close: cl_, ma20: ma20_, ma60: ma60_, ma20Slope: ma20Slope_, rsi14: rsi14_, bbPosition: bbPos_, flowCredit: flowCredit_, leaderTotal: leaderTotal_, leaderGate: leaderGate_, bandStatus: bandStatus_, profitPct: pctP_, isCoreLeader: isCL_, }); const preservationPlan_ = calcCashPreservationPlan_({ sellAction: h.Sell_Action, cashFloorStatus: String(macro.cash_floor_status ?? ""), regime: regime_, isCoreLeader: isCL_, isEtf: isEtf_, liquidityStatus: String(h.Liquidity_Status ?? h.LiquidityStatus ?? ""), spreadStatus: String(h.Spread_Status ?? h.SpreadStatus ?? ""), accountType: String(h.account_type ?? h.Account_Type ?? ""), profitPct: pctP_, rwPartial: rw_, reboundHoldbackScore: reboundHoldback_.score, }); const netScore_ = Number.isFinite(precomp) ? Math.min(100, Math.max(0, score_)) : Math.min(100, Math.max(0, score_ - reboundHoldback_.score)); const actionGroup_ = (h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") ? "EXIT" : String(h.Sell_Action ?? "").startsWith("TRIM") ? "TRIM" : String(h.Sell_Action ?? "") === "HOLD" ? "HOLD" : "WATCH"; const actionGroupOrder_ = actionGroup_ === "EXIT" ? 1 : actionGroup_ === "TRIM" ? 2 : actionGroup_ === "HOLD" ? 3 : 4; let tier_, tierLabel_; if (h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") { tier_ = 1; tierLabel_ = "①하드스탑"; } else if (h.Final_Action === "SELL_READY") { tier_ = 2; tierLabel_ = "②매도신호"; } else if (isEtf_ && sExp_ >= 10) { tier_ = 3; tierLabel_ = "③중복ETF"; } else if (!isEtf_ && !isCL_ && Number.isFinite(pctP_) && pctP_ <= -10) { tier_ = 4; tierLabel_ = "④손실위성"; } else if (!isCL_ && rw_ >= 3) { tier_ = 5; tierLabel_ = "⑤RW약세"; } else if (!isCL_ && Number.isFinite(pctP_) && pctP_ >= 10) { tier_ = 6; tierLabel_ = "⑥익절후보"; } else if (isCL_ && inUp_) { tier_ = 9; tierLabel_ = "⑨코어주도주[마지막]"; } else { tier_ = 7; tierLabel_ = "⑦일반"; } return { rank: 0, tier: tier_, tier_label: tierLabel_, ticker: h.Ticker, name: h.Name, weight_pct: h.Weight_Pct, profit_pct: h.Profit_Pct, rw_partial: rw_, ss001_grade: h.SS001_Grade, sector: sec_, sector_exp_pct: parseFloat(sExp_.toFixed(1)), is_etf: isEtf_, is_core_leader: isCL_, final_action: h.Final_Action, sell_action: h.Sell_Action, action_group: actionGroup_, action_group_order: actionGroupOrder_, sell_ratio_pct: h.Sell_Ratio_Pct, sell_qty: h.Sell_Qty, sell_limit_price: h.Sell_Limit_Price, sell_validation: h.Sell_Validation, action_reason: h.Action_Reason, action_params: h.Action_Params ?? "", score: netScore_, sell_priority_score: netScore_, raw_sell_priority_score: score_, rebound_holdback_score: reboundHoldback_.score, rebound_holdback_reason: reboundHoldback_.reasons, cash_preserve_style: preservationPlan_.style, cash_preserve_ratio: preservationPlan_.recommended_ratio, cash_preserve_reason: preservationPlan_.reasons, trim_style: isCL_ && inUp_ ? "CORE_LAST" : reboundHoldback_.score >= 18 ? "STEP_25" : reboundHoldback_.score >= 10 ? "STEP_33" : "STEP_50", hold_reason: (isCL_ && inUp_) ? "core_leader_uptrend — 매도 마지막(spec:portfolio_exposure.yaml:funding_order④)" : "", quantity_note: "매도수량은 HTS 캡처 제공 후 결정. 미제공 시 수량 기재 금지(spec:00_execution_contract.yaml:P1규칙).", }; }) // Hard-lock sort policy: tier asc -> score desc -> action_group_order asc .sort((a, b) => a.tier - b.tier || b.sell_priority_score - a.sell_priority_score || a.action_group_order - b.action_group_order); rows_.forEach((r, i) => { r.rank = i + 1; }); const sheetHeaders_ = [ "Rank","Tier","Tier_Label","Action_Group","Ticker","Name","Sector","Weight_Pct","Profit_Pct", "Final_Action","Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Validation", "Sell_Priority_Score","Raw_Sell_Priority_Score","Rebound_Holdback_Score", "Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason", "Trim_Style","Hold_Reason","Action_Reason","Action_Params", "Computed_At","Engine_Version","Sort_Policy_ID","Source_Context_Checksum" ]; const sourceContextChecksum_ = computeStringChecksum_(JSON.stringify({ market_regime: regime_, cash_floor_status: String(macro.cash_floor_status ?? ""), holdings_count: rows_.length, holdings_keys: rows_.map(r => `${r.ticker}:${r.final_action}:${r.sell_action}:${r.tier}:${r.sell_priority_score}`) })); let sheetRows_ = rows_.map(r => ([ r.rank, r.tier, r.tier_label, r.action_group, r.ticker, r.name, r.sector, r.weight_pct, r.profit_pct, r.final_action, r.sell_action, r.sell_ratio_pct ?? "", r.sell_qty ?? "", r.sell_limit_price, r.sell_validation ?? "", r.sell_priority_score, r.raw_sell_priority_score, r.rebound_holdback_score, r.cash_preserve_style, r.cash_preserve_ratio, r.cash_preserve_reason, r.trim_style, r.hold_reason, r.action_reason ?? "", r.action_params, computedAt_, "sell_priority_engine_v2", "SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION", sourceContextChecksum_, ])); // 데이터 준비 미흡 상태를 빈 시트로 숨기지 않고 명시적으로 기록한다. if (!sheetRows_.length) { sheetRows_ = [[ 0, "", "DATA_MISSING", "WATCH", "", "매도우선순위 산출 불가", "", "", "", "", "", "", "", "", "DATA_MISSING", "", "", "", "", "", "", "", "", `[SELL_PRIORITY_INPUT_MISSING] holdings=${holdings.length}, valid_weight=${validWeightCount_}, missing_weight=${missingWeightCount_}`, "runDataFeed/account_snapshot 갱신 후 재실행 필요" + ` | account_snapshot confirmed=${asConfirmStats_.confirmed_rows}/${asConfirmStats_.rows_read}` + ` parse_ok_unconfirmed=${asConfirmStats_.parse_ok_unconfirmed}`, computedAt_, "sell_priority_engine_v2", "SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION", sourceContextChecksum_, ]]; } writeToSheet("sell_priority", sheetHeaders_, sheetRows_); const cashPct_ = parseFloat(macro.immediate_cash_pct ?? macro.cash_pct ?? ""); return { engine: "sell_priority_engine_v2", status: rows_.length ? "READY" : "DATA_MISSING", activation_reason: regime_.includes("RISK_OFF") ? "REGIME_TRIM_50" : (Number.isFinite(cashPct_) && cashPct_ < 10 ? `cash_below_floor(${cashPct_.toFixed(1)}%)` : "manual_request"), market_regime: regime_, computed_at: computedAt_, engine_version: "sell_priority_engine_v2", sort_policy_id: "SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION", sector_exposure: sectorExpMap_, prohibition: [ "주도주(SK하이닉스·삼성전자) 매도는 hard_stop 또는 thesis 훼손 근거 필수(tier=9는 마지막).", "매도수량은 HTS 캡처 제공 후 결정. 수량 미제공 시 수량 기재 금지(spec:P1규칙).", ], source_context_checksum: sourceContextChecksum_, diagnostics: { holdings_count: holdings.length, valid_weight_count: validWeightCount_, missing_weight_count: missingWeightCount_, account_snapshot_rows_read: asConfirmStats_.rows_read, account_snapshot_confirmed_rows: asConfirmStats_.confirmed_rows, account_snapshot_parse_ok_unconfirmed: asConfirmStats_.parse_ok_unconfirmed, }, sell_priority_checksum: computeStringChecksum_(JSON.stringify(rows_.map(function(r) { return { rank: r.rank, ticker: r.ticker, tier: r.tier, sell_priority_score: r.sell_priority_score, final_action: r.final_action, sell_action: r.sell_action }; }))), sell_priority_table: rows_, candidates: rows_, // backward-compat alias }; } function getAccountSnapshotConfirmStats_() { var out = { rows_read: 0, confirmed_rows: 0, parse_ok_unconfirmed: 0 }; try { var ss = getSpreadsheet_(); var sh = ss.getSheetByName("account_snapshot"); if (!sh) return out; var data = sh.getDataRange().getValues(); if (!data || data.length < 3) return out; var hdr = data[1].map(function(h) { return String(h || "").trim(); }); var statusIdx = hdr.indexOf("parse_status"); var confIdx = hdr.indexOf("user_confirmed"); if (statusIdx < 0) return out; for (var i = 2; i < data.length; i++) { var parseStatus = String(data[i][statusIdx] || "").trim(); var confirmed = confIdx >= 0 ? String(data[i][confIdx] || "").trim().toUpperCase() : ""; if (!parseStatus && !confirmed) continue; out.rows_read++; var isParseOk = parseStatus === "CAPTURE_READ_OK"; var hasConfirm = confirmed === "Y" || confirmed === "YES" || confirmed === "TRUE" || confirmed === "1"; if (isParseOk && hasConfirm) out.confirmed_rows++; if (isParseOk && !hasConfirm) out.parse_ok_unconfirmed++; } } catch (e) {} return out; } // ============================================================================ // INTEGRATED HARNESS // ============================================================================ /** * [HARNESS] gas_data_feed.gs 통합 하네스 — H3 확장판 * * H1: 포트폴리오 가드 (intraday_lock, cash_floor, total_heat) * H2: 매도후보 순위 (Sell_Priority_Score 0~100 clamp, tier 배정) * H3: 수량 사전산출 (Sell_Qty, POSITION_SIZE_V1 입력값) * H4: 가격 사전산출 (STOP_PRICE_CORE_V1 + TICK_NORMALIZER_V1) * H5: 결정 상태머신 게이팅 (Final_Action per ticker + gate_trace) * H6: Blueprint 무결성 해시 (row_count + CRC32_V1 checksum, LLM 위변조 탐지) * * 호출: runEventRisk() 완료 후 runHarnessRefresh_() → buildHarnessContext_() * 출력: harness_context 시트 (key-value) * → Python converter → blueprint_checksum 검증 → JSON data._harness_context * → LLM: HARNESS_AUTHORITATIVE_V3 지침 (Zero-Adjective + Structured Output) * * 버전: 2026-05-18-H3 */ // ── 상수 ───────────────────────────────────────────────────────────────────── var HARNESS_VERSION = '2026-05-22-3RD_HARNESS_V1'; var HARNESS_SHEET_NAME = 'harness_context'; var AS_SHEET_NAME = 'account_snapshot'; var SETTINGS_SHEET_NAME = 'settings'; var DATA_FEED_SHEET_NAME = 'data_feed'; // 헤더 행 위치 (0-indexed) var AS_HEADER_ROW_IDX = 1; var DF_HEADER_ROW_IDX = 1; // 코어 주도주 — tier=9 (마지막 매도 순위) 고정 // spec/risk/portfolio_exposure.yaml:regime_leading_sector_protection var CORE_TICKERS = ['005930', '000660']; // 삼성전자, SK하이닉스 // P4: 장중 차단 키워드 (spec/00_execution_contract.yaml:P4.keyword_lock) var INTRADAY_BLOCKED_KEYWORDS = ['EXIT_100', 'SELL_FULL', 'EXIT_FULL', 'BUY', 'STAGED_BUY']; var INTRADAY_CUTOFF_MINUTES = 15 * 60 + 30; // 15:30 KST // P4: 장중 허용 액션 목록 — 이 목록 외 모든 매도/매수 액션은 장중 금지 // (차단목록 기반 다운그레이드를 통과한 후 최종 허용 여부를 이중 검증) var INTRADAY_ALLOWED_ACTIONS = [ 'HOLD', 'WATCH', 'TRIM_25', 'TRIM_33', 'TRIM_50', 'OBSERVE_DATA_MISSING', 'INSUFFICIENT_DATA', 'NO_BUY_OVERHEATED' ]; // Heat 게이트 (spec/13_formula_registry.yaml:TOTAL_HEAT_V1.gates) // L3: 국면별 동적 임계값으로 대체 — calcDynamicHeatThresholds_() 참조 var HEAT_HARD_BLOCK_PCT = 10.0; // fallback (regime unknown) var HEAT_HALVE_PCT = 7.0; // fallback (regime unknown) /** * L3: DYNAMIC_HEAT_GATE_V1 * 국면에 따라 Heat Gate 임계값을 동적으로 반환한다. * spec/13b_harness_formulas.yaml:DYNAMIC_HEAT_GATE_V1 * @param {string} regime — marketRegime string * @return {{hardBlock: number, halve: number}} */ var calcHeatThresholdsByRegime_ = function(regime) { var r = String(regime || '').toUpperCase(); if (r.indexOf('EVENT_SHOCK') >= 0) return { hardBlock: 5.0, halve: 3.5 }; if (r.indexOf('RISK_OFF') >= 0) return { hardBlock: 7.0, halve: 5.0 }; if (r.indexOf('SECULAR_LEADER') >= 0 && r.indexOf('RISK_ON') >= 0) return { hardBlock: 13.0, halve: 9.0 }; if (r.indexOf('RISK_ON') >= 0) return { hardBlock: 12.0, halve: 8.5 }; // NEUTRAL or unknown return { hardBlock: 10.0, halve: 7.0 }; } // cash_floor MRS 구간 (spec/risk/portfolio_exposure.yaml:cash_floor.regime_numbers) var CASH_FLOOR_BY_MRS = [ { maxMrs: 3, minPct: 7, label: 'normal' }, { maxMrs: 7, minPct: 10, label: 'overheated_or_event_week' }, { maxMrs: 10, minPct: 15, label: 'risk_off' } ]; // KRX 호가단위 테이블 (spec/13_formula_registry.yaml:TICK_NORMALIZER_V1) var TICK_TABLE = [ { maxPrice: 2000, tick: 1 }, { maxPrice: 5000, tick: 5 }, { maxPrice: 20000, tick: 10 }, { maxPrice: 50000, tick: 50 }, { maxPrice: 200000, tick: 100 }, { maxPrice: 500000, tick: 500 } // >= 500000: tick = 1000 ]; // Sell_Priority_Score 컴포넌트 가중치 // spec/risk/portfolio_exposure.yaml:candidate_scoring.components var SP = { HARD_STOP_BREACH: 50, CASH_FLOOR_TRIM: 40, DUPLICATE_ETF: 30, HOLDING_TRIM_ROTATE: 20, TAKE_PROFIT_BASE: 10, DUP_SAME_SECTOR: 20, CASH_RELIEF_GE3: 15, CASH_RELIEF_1_3: 10, CASH_RELIEF_LT1: 3, RW_GE4: 20, RW_3: 15, RW_2: 8, FLOW_NEGATIVE: 8, BELOW_MA20: 8, OVERWEIGHT_5P: 12, OVERWEIGHT_2P: 6, LIQUIDITY_OK: 5, LIQUIDITY_LOW: -10, TAX_UNKNOWN: 3, CORE_LEADER: 20, A_GRADE_CORE: 12 }; // POSITION_SIZE_V1 기본 위험예산 (spec/13_formula_registry.yaml:RISK_BUDGET_CASCADE_V1) var BASE_RISK_BUDGET = 0.007; // 총자산의 0.7% // ── 메인 함수 ──────────────────────────────────────────────────────────────── /** * buildHarnessContext_ * GAS 확정값을 harness_context 시트에 기록한다. * runEventRisk() 완료 후 runHarnessRefresh_()가 호출한다. */ function buildHarnessContext_() { var ss = getSpreadsheet_(); var now = new Date(); logHarnessSub_('[HARNESS_LAYER] L0: prepareHarnessContextInputs_'); var harnessInputs = prepareHarnessContextInputs_(ss) || {}; var settings = harnessInputs.settings; var performance = harnessInputs.performance; var totalAsset = harnessInputs.totalAsset; var mrsScore = harnessInputs.mrsScore; var dfMap = harnessInputs.dfMap; var asResult = harnessInputs.asResult || {}; asResult.holdings = Array.isArray(asResult.holdings) ? asResult.holdings : []; var kospiRet5d = harnessInputs.kospiRet5d; var kospiRet20d = harnessInputs.kospiRet20d || 0; var sectorFlowRadar = harnessInputs.sectorFlowRadar; var harnessState = harnessInputs.harnessState || {}; var intradayLock = harnessState.intradayLock; var capturedAtIso = harnessState.capturedAtIso; var snapshotFreshness = harnessState.snapshotFreshness; var snapshotGate = harnessState.snapshotGate; var cashFloorInfo = harnessState.cashFloorInfo; var cashShortfallInfo = harnessState.cashShortfallInfo; var drawdownGuard = harnessState.drawdownGuard; var winLossStreakGuard = harnessState.winLossStreakGuard; var marketRegime = harnessState.marketRegime; var regimeTrimGuidance = harnessState.regimeTrimGuidance; var regimeTransitionAlert = harnessState.regimeTransitionAlert; var regimeSizeScale = harnessState.regimeSizeScale; var regimeCashMinPct = harnessState.regimeCashMinPct; var heatThresholds = harnessState.heatThresholds; var heatGate = harnessState.heatGate; var actions = harnessState.actions; var h1 = harnessState.h1; h1.kospiRet20d = kospiRet20d; var settlementCashPct = harnessState.settlementCashPct; var totalHeatPct = harnessState.totalHeatPct; var buyPowerKrw = harnessState.buyPowerKrw; try { if (cashFloorInfo && cashFloorInfo.status === 'HARD_BLOCK') { writeSettingValue_(ss, 'cash_floor_hard_block_warning', '[CASH_FLOOR_HARD_BLOCK] 신규 매수 차단 상태 — 현금 회복(TRIM) 우선'); } else { writeSettingValue_(ss, 'cash_floor_hard_block_warning', ''); } } catch (e) { Logger.log('[WARN] cash_floor_hard_block_warning 기록 실패: ' + e.message); } logHarnessSub_('[HARNESS_LAYER] L1: assembleHarnessCoreLayers_ holdings=' + (asResult.holdings || []).length); var coreLayers = assembleHarnessCoreLayers_( ss, now, settings, asResult, dfMap, performance, totalAsset, mrsScore, buyPowerKrw, settlementCashPct, totalHeatPct, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo, cashShortfallInfo, capturedAtIso, drawdownGuard, winLossStreakGuard, marketRegime, regimeTrimGuidance, regimeTransitionAlert, regimeSizeScale, regimeCashMinPct, heatThresholds, heatGate, actions, h1, kospiRet5d, sectorFlowRadar ); coreLayers = coreLayers || {}; var h2 = coreLayers.h2 || {}; var h3 = coreLayers.h3 || {}; var h4 = coreLayers.h4 || {}; var h5 = coreLayers.h5 || {}; var orderBlueprint = Array.isArray(coreLayers.orderBlueprint) ? coreLayers.orderBlueprint : []; logHarnessSub_('[HARNESS_LAYER] L1 done: h2.candidates=' + ((h2 && h2.candidates) ? h2.candidates.length : 0) + ' h3.sellQty=' + ((h3 && h3.sellQty) ? h3.sellQty.length : 0) + ' h4.prices=' + ((h4 && h4.prices) ? h4.prices.length : 0) + ' h5.decisions=' + ((h5 && h5["decisions"]) ? h5["decisions"].length : 0) + ' blueprint=' + (orderBlueprint ? orderBlueprint.length : 0)); logHarnessSub_('[HARNESS_LAYER] L2: assembleHarnessRiskLayers_'); var riskLayers = assembleHarnessRiskLayers_( ss, settings, asResult, dfMap, totalAsset, marketRegime, kospiRet5d, sectorFlowRadar, h4 ); riskLayers = riskLayers || {}; var portfolioBetaGate = riskLayers.portfolioBetaGate; var eventRiskRows = riskLayers.eventRiskRows; var sectorConcentration = riskLayers.sectorConcentration; var tpLadderRows = riskLayers.tpLadderRows; var stopAdequacyRows = riskLayers.stopAdequacyRows; var staleRows = riskLayers.staleRows; var singlePositionWeightCap = riskLayers.singlePositionWeightCap; var semiconductorClusterGate = riskLayers.semiconductorClusterGate; var portfolioDrawdownGate = riskLayers.portfolioDrawdownGate; var positionCountLimit = riskLayers.positionCountLimit; var stopBreachAlert = riskLayers.stopBreachAlert; var relativeStopSignal = riskLayers.relativeStopSignal; var tpTriggerAlert = riskLayers.tpTriggerAlert; var heatConcentrationAlert = riskLayers.heatConcentrationAlert; var sectorMomentumRows = riskLayers.sectorMomentumRows; logHarnessSub_('[HARNESS_LAYER] L2 done'); logHarnessSub_('[HARNESS_LAYER] L3: assembleHarnessAlphaLayers_'); var alphaLayers = assembleHarnessAlphaLayers_( ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, drawdownGuard, snapshotGate, cashFloorInfo, portfolioBetaGate, sectorConcentration, portfolioDrawdownGate, winLossStreakGuard, positionCountLimit, singlePositionWeightCap, semiconductorClusterGate, stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert, heatGate ); alphaLayers = alphaLayers || {}; var hAlpha = alphaLayers.hAlpha; var hApex = alphaLayers.hApex; var backdataRows = alphaLayers.backdataRows; var dfgResult = alphaLayers.dfgResult; var claExitJson = alphaLayers.claExitJson; var slgRows = alphaLayers.slgRows; var pcgResult = alphaLayers.pcgResult; var portfolioHealthScore = alphaLayers.portfolioHealthScore; hAlpha = hAlpha || {}; hApex = hApex || {}; // [PROPOSAL51-FIX] P2-B: portfolio_health_score 숫자형 보장 (Export Gate CHECK_7 연동) // portfolioHealthScore 객체를 hApex에 숫자 필드로 주입 (기존엔 hApex에 미등록 → Boolean/undefined) if (portfolioHealthScore) { var phsVal = portfolioHealthScore.score; hApex.portfolio_health_score = (typeof phsVal === 'number' && !isNaN(phsVal)) ? phsVal : 50; hApex.portfolio_health_label = portfolioHealthScore.label || 'CAUTION'; hApex.portfolio_health_json = portfolioHealthScore; } if (relativeStopSignal) hApex.relative_stop_signal = relativeStopSignal; logHarnessSub_('[HARNESS_LAYER] L3 done'); logHarnessSub_('[HARNESS_LAYER] L4: finalizeHarnessContextRows_'); finalizeHarnessContextRows_( ss, now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo, heatGate, heatThresholds, mrsScore, asResult, dfMap, settlementCashPct, totalHeatPct, buyPowerKrw, totalAsset, actions, performance, h2, h3, h4, h5, orderBlueprint, hAlpha, regimeTrimGuidance, cashShortfallInfo, hApex, sectorMomentumRows, drawdownGuard, portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows, regimeSizeScale, regimeCashMinPct, stopAdequacyRows, staleRows, singlePositionWeightCap, semiconductorClusterGate, portfolioDrawdownGate, winLossStreakGuard, positionCountLimit, stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert, portfolioHealthScore ); logHarnessSub_('[HARNESS_LAYER] L4 done'); var sellCandidatesCount = ((h2 && h2.candidates) ? h2.candidates.length : 0); var routeCount = ((h5 && h5["decisions"]) ? h5["decisions"].length : 0); Logger.log('[HARNESS H2] 완료' + ' | intraday=' + intradayLock + ' | cash=' + settlementCashPct + '%' + ' | heat=' + totalHeatPct + '%' + ' | cashFloor=' + cashFloorInfo.status + ' | heatGate=' + heatGate + ' | perf=' + performance.bayesian_label + '×' + performance.bayesian_multiplier + ' | sellCandidates=' + sellCandidatesCount + ' | decisions=' + routeCount + ' | alphaShield_critical=' + (hAlpha.critical_alert_count || 0) + ' | apex_buy_blocks=' + (hApex.apex_block_count || 0)); if (routeCount > 0 && sellCandidatesCount === 0) { Logger.log('[LOG_METRIC_MISMATCH_WARN] decisions>0 이지만 sellCandidates=0 (정책/입력 상태 점검 필요)'); } } // FORMULA_STUB: MARKET_RISK_SCORE_V1 — 시장리스크 점수 (calcMarketRiskScore_) GAS 미구현, Python pipeline 산출 // FORMULA_STUB: PORTFOLIO_BETA_V1 — 포트폴리오 베타 (calcPortfolioBeta_) GAS 미구현, Python pipeline 산출 // --- Source: src/gas_adapter_parts/gdf_02_harness_assembly.gs --- function shouldEmitHarnessVerboseLogs_() { try { var props = PropertiesService.getScriptProperties(); var profile = String(props.getProperty('HARNESS_LOG_PROFILE') || '').toUpperCase(); if (profile === 'DEBUG') return true; if (profile === 'NORMAL') return false; // Backward compatibility var v = props.getProperty('HARNESS_VERBOSE_LOG'); return String(v || '').toLowerCase() === 'true'; } catch (e) { return false; } } function logHarnessSub_(msg) { if (shouldEmitHarnessVerboseLogs_()) Logger.log(msg); } function setHarnessLogProfile_(profile) { var p = String(profile || '').toUpperCase(); if (p !== 'NORMAL' && p !== 'DEBUG') { throw new Error("setHarnessLogProfile_: profile must be 'NORMAL' or 'DEBUG'"); } var props = PropertiesService.getScriptProperties(); props.setProperty('HARNESS_LOG_PROFILE', p); if (p === 'DEBUG') props.setProperty('HARNESS_VERBOSE_LOG', 'true'); if (p === 'NORMAL') props.deleteProperty('HARNESS_VERBOSE_LOG'); Logger.log('[HARNESS_LOG_PROFILE] set to ' + p); return { profile: p, formula_id: 'HARNESS_LOG_PROFILE_V1' }; } function setHarnessLogProfileNormal_() { return setHarnessLogProfile_('NORMAL'); } function setHarnessLogProfileDebug_() { return setHarnessLogProfile_('DEBUG'); } function getHarnessLogProfile_() { var profile = 'NORMAL'; var verboseFallback = false; try { var props = PropertiesService.getScriptProperties(); var p = String(props.getProperty('HARNESS_LOG_PROFILE') || '').toUpperCase(); if (p === 'NORMAL' || p === 'DEBUG') profile = p; verboseFallback = String(props.getProperty('HARNESS_VERBOSE_LOG') || '').toLowerCase() === 'true'; } catch (e) {} return { profile: profile, verbose_fallback: verboseFallback, formula_id: 'HARNESS_LOG_PROFILE_V1' }; } function assembleHarnessCoreLayers_( ss, now, settings, asResult, dfMap, performance, totalAsset, mrsScore, buyPowerKrw, settlementCashPct, totalHeatPct, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo, cashShortfallInfo, capturedAtIso, drawdownGuard, winLossStreakGuard, marketRegime, regimeTrimGuidance, regimeTransitionAlert, regimeSizeScale, regimeCashMinPct, heatThresholds, heatGate, actions, h1, kospiRet5d, sectorFlowRadar ) { // THIN_ADAPTER: [sizing] delegated to Python — src/quant_engine/inject_computed_harness.py:main var h2 = calcSellPriority_(asResult.holdings, dfMap, h1); var h3 = calcQuantities_(asResult.holdings, dfMap, totalAsset, buyPowerKrw, h1); var h4 = calcPrices_(asResult.holdings, dfMap, marketRegime); var h5 = runRouteFlow_(asResult.holdings, dfMap, h1); var orderBlueprint = buildOrderBlueprint_(asResult.holdings, dfMap, { intradayLock: intradayLock, heatGate: heatGate, cashFloorStatus: cashFloorInfo.status, blockedActions: actions.blocked }, h3, h4, h5); return { h2: h2, h3: h3, h4: h4, h5: h5, orderBlueprint: orderBlueprint }; } function assembleHarnessRiskLayers_( ss, settings, asResult, dfMap, totalAsset, marketRegime, kospiRet5d, sectorFlowRadar, h4 ) { var portfolioBetaGate = calcPortfolioBetaGate_(asResult.holdings, dfMap, kospiRet5d, marketRegime); var eventRiskRows = calcEventRiskHoldGate_(asResult.holdings, dfMap); var sectorConcentration = calcSectorConcentrationGate_(asResult.holdings, marketRegime); var tpLadderRows = calcTpQuantityLadder_(asResult.holdings, h4); var stopAdequacyRows = calcStopAdequacyRows_(asResult.holdings, dfMap); var staleRows = calcHoldingStaleReview_(asResult.holdings); // KOSPI 반도체 시총 비중 — settings 시트에서만 입력. 미설정 시 0 (DATA_MISSING 처리) // 주의: 하드코딩 기본값 금지. 실제 비중은 KRX/FnGuide 시총 데이터에서 수동 입력. var kospiSemiWt = toNumber_(settings['kospi_semi_weight_pct']) || 0; var kospiSamsungWt = toNumber_(settings['kospi_samsung_weight_pct']) || 0; var kospiHynixWt = toNumber_(settings['kospi_hynix_weight_pct']) || 0; var singlePositionWeightCap = calcSinglePositionWeightCap_(asResult.holdings, marketRegime, kospiSamsungWt, kospiHynixWt); var semiconductorClusterGate = calcSemiconductorClusterGate_(asResult.holdings, marketRegime, kospiSemiWt); var portfolioDrawdownGate = calcPortfolioDrawdownGate_(totalAsset, ss, settings); var positionCountLimit = calcPositionCountLimit_(asResult.holdings, marketRegime); var stopBreachAlert = calcStopBreachAlert_(asResult.holdings, dfMap); var kospiRet20d_ = readKospiRet20d_(ss); var relativeStopSignal = calcRelativeStopSignal_(asResult.holdings, dfMap, kospiRet20d_); var tpTriggerAlert = calcTpTriggerAlert_(asResult.holdings, dfMap, h4, tpLadderRows); var heatConcentrationAlert = calcHeatConcentrationAlert_(asResult.holdings, asResult.totalHeatKrw); var sectorMomentumRows = calcSectorRotationMomentum_(sectorFlowRadar); return { portfolioBetaGate: portfolioBetaGate, eventRiskRows: eventRiskRows, sectorConcentration: sectorConcentration, tpLadderRows: tpLadderRows, stopAdequacyRows: stopAdequacyRows, staleRows: staleRows, singlePositionWeightCap: singlePositionWeightCap, semiconductorClusterGate: semiconductorClusterGate, portfolioDrawdownGate: portfolioDrawdownGate, positionCountLimit: positionCountLimit, stopBreachAlert: stopBreachAlert, relativeStopSignal: relativeStopSignal, tpTriggerAlert: tpTriggerAlert, heatConcentrationAlert: heatConcentrationAlert, sectorMomentumRows: sectorMomentumRows }; } function assembleHarnessAlphaLayers_( ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, drawdownGuard, snapshotGate, cashFloorInfo, portfolioBetaGate, sectorConcentration, portfolioDrawdownGate, winLossStreakGuard, positionCountLimit, singlePositionWeightCap, semiconductorClusterGate, stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert, heatGate ) { logHarnessSub_('[HARNESS_SUB] L3-A: assembleHarnessAlphaRadar_'); var alphaLayer = assembleHarnessAlphaRadar_(asResult, dfMap, kospiRet5d, sectorFlowRadar); logHarnessSub_('[HARNESS_SUB] L3-B: assembleHarnessApexLayer_'); var apexLayer = assembleHarnessApexLayer_( ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, cashFloorInfo ); logHarnessSub_('[HARNESS_SUB] L3-C: syncBackdataFeatureBank_'); var hAlphaResult = alphaLayer.hAlpha; var hApexResult = apexLayer.hApex; var backdataRows = syncBackdataFeatureBank_(now, asResult.holdings, dfMap, hAlphaResult, hApexResult); hApexResult.backdata_feature_bank_json = backdataRows; hApexResult.backdata_learning_lock = true; var dfgResult = apexLayer.dfgResult; var claExitJson = apexLayer.claExitJson; var slgRows = apexLayer.slgRows; var pcgResult = apexLayer.pcgResult; logHarnessSub_('[HARNESS_SUB] L3-D: calcHarnessPortfolioHealthScore_'); var portfolioHealthScore = calcHarnessPortfolioHealthScore_({ heat_gate: heatGate, cash_floor_status: cashFloorInfo.status, drawdown_guard_state: drawdownGuard.state, snapshot_execution_gate: snapshotGate.status, portfolio_beta_gate: (portfolioBetaGate || {}).gate_status, sector_concentration: (sectorConcentration || {}).gate_status, portfolio_drawdown_gate: (portfolioDrawdownGate || {}).gate, win_loss_streak_state: winLossStreakGuard.state, position_count_gate: (positionCountLimit || {}).gate_status, single_position_weight: (singlePositionWeightCap || {}).gate_status, semiconductor_cluster: (semiconductorClusterGate || {}).gate_status, stop_breach_gate: (stopBreachAlert || {}).gate, tp_trigger_gate: (tpTriggerAlert || {}).gate, heat_concentration_gate: (heatConcentrationAlert || {}).gate, regime_transition_type: (regimeTransitionAlert || {}).transition_type }); // [PROPOSAL50] P1-C: M5 V1.1 — 반도체 클러스터 한도 2배 초과 시 4주 의무 감축 계획 var mandatoryReduction = calcMandatoryReductionPlan_( semiconductorClusterGate, asResult.holdings, dfMap, h3, totalAsset ); hApexResult.mandatory_reduction_json = mandatoryReduction; if (mandatoryReduction.is_mandatory) { Logger.log('[M5_V1.1] MANDATORY_REDUCTION: ' + mandatoryReduction.current_excess_pct + '%p 초과 → 주당 ' + mandatoryReduction.weekly_reduction_target_krw + '원 감축 필요'); } return { hAlpha: hAlphaResult, hApex: hApexResult, backdataRows: backdataRows, dfgResult: dfgResult, claExitJson: claExitJson, slgRows: slgRows, pcgResult: pcgResult, portfolioHealthScore: portfolioHealthScore }; } function assembleHarnessAlphaRadar_(asResult, dfMap, kospiRet5d, sectorFlowRadar) { var hAlpha = calcAlphaShield_(asResult.holdings, dfMap, kospiRet5d, sectorFlowRadar); return { hAlpha: hAlpha }; } function assembleHarnessApexLayer_( ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, cashFloorInfo ) { logHarnessSub_('[HARNESS_SUB] L3-B1: assembleHarnessApexCore_'); var apexCore = assembleHarnessApexCore_( ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso ); logHarnessSub_('[HARNESS_SUB] L3-B2: applyApexProposal46Suite_'); var hApex = applyApexProposal46Suite_( ss, asResult.holdings, dfMap, h2, h3, cashShortfallInfo, asResult, cashFloorInfo, capturedAtIso, now, apexCore.hApex ); hApex = hApex || {}; orderBlueprint = Array.isArray(orderBlueprint) ? orderBlueprint : []; logHarnessSub_('[HARNESS_SUB] L3-B3: buildRoutingTrace_'); // [PROPOSAL50] P0-2: ROUTING_TRACE_V1 — export_gate 완료 후 라우팅 trace 확정 var routingTrace = buildRoutingTrace_( (h1 && h1.intradayLock) || false, cashFloorInfo, hApex, capturedAtIso ); hApex.routing_trace_json = routingTrace; hApex.routing_serving_trace_v2_json = buildRoutingServingTraceV2_(routingTrace, hApex); logHarnessSub_('[HARNESS_SUB] L3-B4: buildWatchLedger_'); // [PROPOSAL50] P0-3: WATCH_LEDGER_V1 — validation_status != PASS 행 물리 분리 hApex.watch_ledger_json = buildWatchLedger_(orderBlueprint, h4); logHarnessSub_('[HARNESS_SUB] L3-B5: buildShadowLedger_'); // [PROPOSAL50] H10: SHADOW_LEDGER_V1 — BLOCKED 블루프린트 투명 원장 hApex.shadow_ledger_json = buildShadowLedger_(orderBlueprint, dfMap); logHarnessSub_('[HARNESS_SUB] L3-B6: calcTrimPlanMinCash_'); // [PROPOSAL50] G2: TRIM_PLAN_MIN_CASH_V1 — 최소 현금 TRIM 계획 결정론적 산출 // h2는 sell_priority 결과 객체; sell_priority_table 배열만 전달 hApex.trim_plan_to_min_cash_json = calcTrimPlanMinCash_( asResult.holdings, dfMap, cashShortfallInfo, (h2 && h2.sell_priority_table) ? h2.sell_priority_table : (Array.isArray(h2) ? h2 : []) ); // [PROPOSAL51] P1-C: CRDL-V1 — 현금회복 3분리 표시 잠금 (trim_plan 확정 후) hApex.cash_recovery_display_json = calcCashRecoveryDisplayLock_( hApex.scrs_v2_json || {}, hApex.trim_plan_to_min_cash_json || [], cashShortfallInfo || {} ); logHarnessSub_('[HARNESS_SUB] L3-B7: calcLlmServingConstraint_'); // [PROPOSAL50] D2: LLM_SERVING_CONSTRAINT_V1 — 12가지 금지행동 체크 (보고서 조립 직전) hApex.llm_serving_constraint_json = calcLlmServingConstraint_(hApex); logHarnessSub_('[HARNESS_SUB] L3-B8: calcDeterministicServingLock_'); // [PROPOSAL50] P2-1: DSLE-V1 — 서빙 잠금 (파이프라인 최종 단계) var servingLock = calcDeterministicServingLock_(hApex, capturedAtIso, now); hApex.serving_lock_json = servingLock; return { hApex: hApex, dfgResult: apexCore.dfgResult, claExitJson: apexCore.claExitJson, slgRows: apexCore.slgRows, pcgResult: apexCore.pcgResult }; } function assembleHarnessApexCore_( ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso ) { logHarnessSub_('[HARNESS_SUB] L3-B1a: calcApexExecutionHarness_'); var hApex = calcApexExecutionHarness_( asResult.holdings, dfMap, sectorFlowRadar, kospiRet5d, h1, h2, h3, h4, orderBlueprint, cashShortfallInfo, marketRegime ); logHarnessSub_('[HARNESS_SUB] L3-B1b: applyApexPostProcessing_'); var apexPost = applyApexPostProcessing_( ss, now, capturedAtIso, asResult.holdings, dfMap, totalAsset, kospiRet5d, marketRegime, hApex ); return { hApex: apexPost.hApex, dfgResult: apexPost.dfgResult, claExitJson: apexPost.claExitJson, slgRows: apexPost.slgRows, pcgResult: apexPost.pcgResult }; } function prepareHarnessContextInputs_(ss) { // 공통 데이터 읽기와 H1 사전 게이트를 분리해 buildHarnessContext_()를 얇게 유지한다. var settings = readSettings_(ss); var performance = readPerformanceSheet_(); var totalAsset = toNumber_(settings['total_asset_krw']); var mrsScore = toNumber_(settings['mrs_score'] || settings['MRS'] || 5); var dfMap = buildDataFeedMap_(ss); var asResult = parseAccountSnapshot_(ss, totalAsset, dfMap); var kospiRet5d = readKospiRet5d_(ss); var kospiRet20d = readKospiRet20d_(ss); var sectorFlowRadar = readSectorFlowForRadar_(ss); if (totalAsset <= 0) totalAsset = asResult.derivedTotalAsset; var harnessState = calcHarnessPortfolioGuardState_( ss, asResult, settings, performance, totalAsset, mrsScore ); return { settings: settings, performance: performance, totalAsset: totalAsset, mrsScore: mrsScore, dfMap: dfMap, asResult: asResult, kospiRet5d: kospiRet5d, kospiRet20d: kospiRet20d, sectorFlowRadar: sectorFlowRadar, harnessState: harnessState }; } function finalizeHarnessContextRows_( ss, now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo, heatGate, heatThresholds, mrsScore, asResult, dfMap, settlementCashPct, totalHeatPct, buyPowerKrw, totalAsset, actions, performance, h2, h3, h4, h5, orderBlueprint, hAlpha, regimeTrimGuidance, cashShortfallInfo, hApex, sectorMomentumRows, drawdownGuard, portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows, regimeSizeScale, regimeCashMinPct, stopAdequacyRows, staleRows, singlePositionWeightCap, semiconductorClusterGate, portfolioDrawdownGate, winLossStreakGuard, positionCountLimit, stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert, portfolioHealthScore ) { var rows = buildHarnessRows_( now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo, heatGate, heatThresholds, mrsScore, asResult, dfMap, settlementCashPct, totalHeatPct, buyPowerKrw, totalAsset, actions, performance, h2, h3, h4, h5, orderBlueprint, hAlpha, regimeTrimGuidance, cashShortfallInfo, hApex, sectorMomentumRows, drawdownGuard, portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows, regimeSizeScale, regimeCashMinPct, stopAdequacyRows, staleRows, singlePositionWeightCap, semiconductorClusterGate, portfolioDrawdownGate, winLossStreakGuard, positionCountLimit, stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert, portfolioHealthScore ); assertHarnessRowsComplete_(rows); writeHarnessSheet_(ss, rows, now); return rows; } function calcHarnessPortfolioHealthScore_(gateMap) { return calcPortfolioHealthScore_(gateMap); } function calcHarnessPortfolioGuardState_(ss, asResult, settings, performance, totalAsset, mrsScore) { var settlementCashPct = totalAsset > 0 ? round2_(asResult.settlementCashD2Krw / totalAsset * 100) : 0; var totalHeatPct = totalAsset > 0 ? round2_(asResult.totalHeatKrw / totalAsset * 100) : 0; var buyPowerKrw = asResult.settlementCashD2Krw - asResult.openOrderAmountKrw; var intradayLock = calcIntradayLock_(asResult.capturedAt); var capturedAtIso = asResult.capturedAt ? formatIso_(asResult.capturedAt) : ''; var snapshotFreshness = checkAccountSnapshotFreshness_(); var snapshotGate = snapshotExecutionGate_(snapshotFreshness); var cashFloorInfo = calcCashFloor_(mrsScore, settlementCashPct); var cashShortfallInfo = calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore); var drawdownGuard = calcDrawdownGuard_(performance); var winLossStreakGuard = calcWinLossStreakGuard_(performance); var marketRegime = readMacroRegime_(ss); var regimeTrimGuidance = calcRegimeTrimGuidance_(marketRegime); var regimeTransitionAlert = calcRegimeTransitionAlert_(marketRegime, ss, settings); var regimeSizeScale = calcRegimeSizeScale_(marketRegime); var regimeCashMinPct = calcRegimeCashUplift_(marketRegime, cashFloorInfo.minPct); if (regimeCashMinPct > cashFloorInfo.minPct) { cashFloorInfo.minPct = regimeCashMinPct; cashFloorInfo.status = settlementCashPct >= regimeCashMinPct ? 'OK' : 'BELOW_FLOOR'; cashShortfallInfo = calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore); } var heatThresholds = calcHeatThresholdsByRegime_(marketRegime); var heatGate = totalHeatPct >= heatThresholds.hardBlock ? 'BLOCK_NEW_BUY' : totalHeatPct >= heatThresholds.halve ? 'HALVE_NEW_BUY_QUANTITY' : 'ALLOW_CONTINUE'; var actions = calcActions_(intradayLock, heatGate, cashFloorInfo.status); var h1 = { intradayLock: intradayLock, snapshotExecutionGate: snapshotGate.status, snapshotExecutionReason: snapshotGate.reason, accountSnapshotFreshness: snapshotFreshness, heatGate: heatGate, heatGateThresholdPct: heatThresholds.hardBlock, drawdownBuyScale: drawdownGuard.buy_scale, drawdownGuardState: drawdownGuard.state, regimeSizeScale: regimeSizeScale.scale, winLossStreakBuyScale: winLossStreakGuard.buy_scale, winLossStreakState: winLossStreakGuard.state, cashFloorStatus: cashFloorInfo.status, cashFloorMinPct: cashFloorInfo.minPct, totalAsset: totalAsset, buyPowerKrw: buyPowerKrw, performanceMultiplier: performance.bayesian_multiplier, performanceLabel: performance.bayesian_label, performanceBuyBias: calcPerformanceBuyBias_(performance), }; return { settlementCashPct: settlementCashPct, totalHeatPct: totalHeatPct, buyPowerKrw: buyPowerKrw, intradayLock: intradayLock, capturedAtIso: capturedAtIso, snapshotFreshness: snapshotFreshness, snapshotGate: snapshotGate, cashFloorInfo: cashFloorInfo, cashShortfallInfo: cashShortfallInfo, drawdownGuard: drawdownGuard, winLossStreakGuard: winLossStreakGuard, marketRegime: marketRegime, regimeTrimGuidance: regimeTrimGuidance, regimeTransitionAlert: regimeTransitionAlert, regimeSizeScale: regimeSizeScale, regimeCashMinPct: regimeCashMinPct, heatThresholds: heatThresholds, heatGate: heatGate, actions: actions, h1: h1, }; } function applyApexPostProcessing_(ss, now, capturedAtIso, holdings, dfMap, totalAsset, kospiRet5d, marketRegime, hApex) { // ── [2026-05-21_SPRINT_B] Sprint B 게이트 산출 ─────────────────────────────── logHarnessSub_('[HARNESS_SUB] L3-B1b-1: calcHarnessDataFreshnessGate_'); var dfgResult = calcHarnessDataFreshnessGate_(capturedAtIso, now); logHarnessSub_('[HARNESS_SUB] L3-B1b-2: calcClaRegimeExitCondition_'); var claExitJson = calcClaRegimeExitCondition_(dfMap, marketRegime); logHarnessSub_('[HARNESS_SUB] L3-B1b-3: calcSatelliteLifecycleGate_'); var slgRows = calcSatelliteLifecycleGate_( holdings, dfMap, hApex.alpha_evaluation_window_json || [] ); logHarnessSub_('[HARNESS_SUB] L3-B1b-4: calcPortfolioCorrelationGate_'); var pcgResult = calcPortfolioCorrelationGate_( holdings, dfMap, totalAsset, kospiRet5d ); // Direction DFG: STALE_WARN/STALE_BLOCK → SAQG ELIGIBLE 하향 if (dfgResult.data_freshness_status === 'STALE_WARN' || dfgResult.data_freshness_status === 'STALE_BLOCK') { (hApex.saqg_json || []).forEach(function(r) { if (r.saqg_v1 === 'ELIGIBLE') { r.saqg_v1 = 'WATCHLIST_ONLY'; r.hts_allowed = false; r.saqg_downgraded_by = 'DFG_' + dfgResult.data_freshness_status; } }); } hApex.data_freshness_json = dfgResult; hApex.cla_regime_exit_json = claExitJson; hApex.satellite_lifecycle_gate_json = slgRows; hApex.portfolio_correlation_gate_json = pcgResult; // [C-1] AFL: alpha history upsert (T+20/T+60 graduated holdings) try { appendAlphaHistory_(ss, hApex.alpha_evaluation_window_json || [], holdings, dfMap, marketRegime); } catch(e) { Logger.log("[AFL] appendAlphaHistory_ error: " + e.message); } try { hApex.alpha_feedback_json = runAlphaFeedbackLoop_(); } catch(e) { Logger.log("[AFL] runAlphaFeedbackLoop_ error: " + e.message); hApex.alpha_feedback_json = getAlphaFeedbackJson_(); } // Direction PCG: CORRELATION_BLOCK → 약한 위성 BUY 추가 차단 if (pcgResult.correlation_gate_status === 'CORRELATION_BLOCK') { (hApex.buy_permission_json || []).forEach(function(bp) { var h = holdings.find(function(x) { return x.ticker === bp.ticker; }); if (!h || h.position_type === 'core') return; var df = dfMap[bp.ticker] || {}; var slg = slgRows.find(function(r) { return r.ticker === bp.ticker; }); var weakSignal = df.rs_verdict === 'LAGGARD' || df.brt_verdict === 'BROKEN' || (slg && (slg.lifecycle_stage === 'REVIEW' || slg.lifecycle_stage === 'EXIT')); if (weakSignal && bp.buy_permission_state !== 'BLOCKED') { bp.buy_permission_state = 'BLOCKED'; bp.blocked_reason_codes = (bp.blocked_reason_codes || []) .concat(['CORRELATION_BLOCK_WEAK_SATELLITE']); } }); } return { hApex: hApex, dfgResult: dfgResult, claExitJson: claExitJson, slgRows: slgRows, pcgResult: pcgResult, }; } function applyApexProposal46Suite_(ss, holdings, dfMap, h2, h3, cashShortfallInfo, asResult, cashFloorInfo, capturedAtIso, now, hApex) { logHarnessSub_('[HARNESS_SUB] L3-B2a: applyApexMacroAlphaSuite_'); hApex = applyApexMacroAlphaSuite_(holdings, dfMap, hApex); logHarnessSub_('[HARNESS_SUB] L3-B2b: applyApexProtectionAndFeedbackSuite_'); hApex = applyApexProtectionAndFeedbackSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex); logHarnessSub_('[HARNESS_SUB] L3-B2c: applyApexConsistencySuite_'); hApex = applyApexConsistencySuite_(hApex, asResult, dfMap, cashFloorInfo, capturedAtIso, now); return hApex; } function applyApexMacroAlphaSuite_(holdings, dfMap, hApex) { return applyApexMacroAlphaSuiteImpl_(holdings, dfMap, hApex); } function applyApexMacroEventSuite_(hApex) { return applyApexMacroEventSuiteImpl_(hApex); } function applyApexPredictiveAlphaSuite_(holdings, dfMap, hApex) { return applyApexPredictiveAlphaSuiteImpl_(holdings, dfMap, hApex); } function applyApexProtectionAndFeedbackSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex) { logHarnessSub_('[HARNESS_SUB] L3-B2b-i: applyApexCashPreservationSuite_'); hApex = applyApexCashPreservationSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex); logHarnessSub_('[HARNESS_SUB] L3-B2b-ii: applyApexFeedbackSignalSuite_'); hApex = applyApexFeedbackSignalSuite_(holdings, dfMap, hApex); return hApex; } function applyApexCashPreservationSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex) { // THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/inject_computed_harness.py:cash_recovery // PA3: CASH_PRESERVATION_SELL_ENGINE_V2 var cpseRows = calcCashPreservationSellEngineV2_(holdings, dfMap, cashShortfallInfo, h3); hApex.cash_preservation_sell_json = cpseRows; // [PROPOSAL50] P1-2: SCRS-V2 — 주식가치 보호 + 반등 포착 통합 현금확보 엔진 var scrsResult = calcSmartCashRecoverySell_(holdings, dfMap, cashShortfallInfo, h2, hApex); hApex.scrs_v2_json = scrsResult; // [PROPOSAL51] P2-B: PROACTIVE_SELL_RADAR_V2 — 8신호 D-3일 사전 분배 감지 var ppMap = {}; ((hApex.profit_preservation_json) || []).forEach(function(pp) { var tk = (pp.ticker || pp.ticker_code || '').toString(); if (tk) ppMap[tk] = pp; }); hApex.proactive_sell_radar_json = calcProactiveSellRadarV2_(holdings, dfMap, ppMap); return hApex; } function applyApexFeedbackSignalSuite_(holdings, dfMap, hApex) { // THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_final_decision // anti_late_entry_json set first — watch_breakout uses ALE grade to filter grade-F chasers logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-0: anti_late_entry_json'); hApex.anti_late_entry_json = calcAntiLateEntryGateV2_(holdings, dfMap); logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-1: applyApexWatchBreakoutSuite_'); hApex = applyApexWatchBreakoutSuite_(holdings, dfMap, hApex); logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-2: applyApexAntiWhipsawSuite_'); hApex = applyApexAntiWhipsawSuite_(holdings, dfMap, hApex); logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-3: applyApexAlphaHistorySuite_'); hApex = applyApexAlphaHistorySuite_(hApex); return hApex; } function applyApexWatchBreakoutSuite_(holdings, dfMap, hApex) { return applyApexWatchBreakoutSuiteImpl_(holdings, dfMap, hApex); } function applyApexAntiWhipsawSuite_(holdings, dfMap, hApex) { // [PROPOSAL48_A3] ANTI_WHIPSAW_REENTRY_GATE_V1 var awrRows = calcAntiWhipsawReentryGateV1_( hApex.sell_candidates_json || [], dfMap, holdings ); hApex.anti_whipsaw_reentry_json = awrRows; return hApex; } function applyApexAlphaHistorySuite_(hApex) { // [PROPOSAL48_C7] alpha_history T20/T60 통계 집계 — T+5 피드백 루프 가시화 hApex.alpha_history_summary_json = getAlphaHistorySummary_(); return hApex; } function applyApexConsistencySuite_(hApex, asResult, dfMap, cashFloorInfo, capturedAtIso, now) { // PA5: CONSISTENCY_VALIDATOR_V2 var cvResult = calcConsistencyValidatorV2_(hApex, asResult, cashFloorInfo, capturedAtIso, now); hApex.consistency_report_json = cvResult; hApex.consistency_score = cvResult.consistency_score; hApex.cv_verdict = cvResult.cv_verdict; // [PROPOSAL51] P0-B: SPSV2 — 매도 주문 3중 가격 검증 (Export Gate 전에 실행) hApex.order_blueprint_json = calcSellPriceSanityV2_( hApex.order_blueprint_json || [], hApex.profit_preservation_json || [] ); // [PROPOSAL51] P2-D: SEQG-V1 — 매도 실행 품질 채점 (SPSV2 후, Export Gate 전) hApex.sell_execution_quality_json = calcSellExecutionQualityGate_( hApex.order_blueprint_json || [], [], // holdings은 hApex에 직접 포함되지 않아 PSR 데이터만으로 채점 hApex.proactive_sell_radar_json || [] ); // [PROPOSAL51] P0-C: SEMICONDUCTOR_CLUSTER_SYNC_V1 — cluster gate ↔ mandatory_reduction 정합성 hApex.cluster_sync_result_json = syncSemiconductorCluster_(hApex); // [PROPOSAL51] P0-D: PHL-V1 — 5계층 가격 단일화 잠금 (SPSV2 통과 후) hApex.price_hierarchy_json = applyPriceHierarchyLockAll_(hApex); // [PROPOSAL51] P1-B: DQG-V2 — 필드 충족률 데이터 완성도 게이트 hApex.data_quality_gate_v2_json = calcDataQualityGateV2_(hApex); // [PROPOSAL53] P0-A: FUNDAMENTAL_QUALITY_GATE_V1 hApex.fundamental_quality_json = calcFundamentalQualityGateV1_(asResult.holdings || [], dfMap || {}); // [PROPOSAL53] P0-B: HORIZON_ALLOCATION_LOCK_V1 hApex.horizon_allocation_json = calcHorizonAllocationLockV1_(asResult.holdings || [], hApex); // [PROPOSAL53] P0-C: SMART_MONEY_LIQUIDITY_GATE_V1 hApex.smart_money_liquidity_json = calcSmartMoneyLiquidityGateV1_(asResult.holdings || [], hApex); // [PROPOSAL54] P0.5 확장 하네스 hApex.fundamental_multifactor_json = calcFundamentalMultiFactorScoreV2_(asResult.holdings || [], dfMap || {}); hApex.earnings_growth_quality_json = calcEarningsGrowthQualityGateV1_(asResult.holdings || [], dfMap || {}); hApex.market_share_proxy_json = calcMarketShareMomentumProxyV1_(asResult.holdings || [], dfMap || {}, hApex); hApex.cashflow_stability_json = calcCashflowStabilityGateV1_(asResult.holdings || [], dfMap || {}); hApex.routing_explain_json = calcRoutingExplainLockV1_(hApex); hApex.gs_formula_mirror_json = buildGsFormulaMirrorV1_(); // [PROPOSAL54 P0.6] 신규 5개 하네스 실거래 BUY 차단 연동 hApex.order_blueprint_json = applyProposal54BuyBlockLocks_(hApex.order_blueprint_json || [], hApex); // [PROPOSAL51-FIX-ORDER] calcExportGate_ 호출 전 portfolio_health_score 숫자형 보장. // buildHarnessContext_()의 FIX(line 2272)보다 이 함수가 먼저 실행되므로 // 여기서 재확인하지 않으면 CHECK_7이 항상 undefined를 본다. if (typeof hApex.portfolio_health_score !== 'number' || isNaN(hApex.portfolio_health_score)) { var _phsJson = hApex.portfolio_health_json; var _phsRaw = _phsJson && _phsJson.score; hApex.portfolio_health_score = (typeof _phsRaw === 'number' && !isNaN(_phsRaw)) ? _phsRaw : 0; } // [PROPOSAL50] P0-1: EXPORT_GATE_V1 — PENDING_EXPORT 원인 자동 진단 var egResult = calcExportGate_(hApex, asResult, cashFloorInfo); hApex.export_gate_json = egResult; hApex.json_validation_status = egResult.json_validation_status; hApex.hts_entry_allowed = egResult.hts_entry_allowed; return hApex; } /** * GS Formula Mirror V1 * Python 보조 도구로 생성되는 공식들을 GAS 하네스 계층에서도 명시적으로 추적한다. * 목적: YAML↔GS 커버리지의 소스오브트루스를 GAS 쪽에 고정. */ function buildGsFormulaMirrorV1_() { var formulaIds = [ 'BLANK_CELL_AUDIT_V1', 'CASHFLOW_QUALITY_SIGNAL_V1', 'EARNINGS_QUALITY_SIGNAL_V1', 'EJCE_VIEW_RENDERER_V1', 'FUNDAMENTAL_MULTIFACTOR_V3', 'FUNDAMENTAL_RAW_INGEST_V1', 'GROWTH_RATE_SIGNAL_V1', 'HORIZON_CLASSIFICATION_V1', 'LIQUIDITY_FLOW_SIGNAL_V1', 'MARKET_SHARE_SIGNAL_V2', 'PORTFOLIO_ALPHA_CONFIDENCE_PER_TICKER_V1', 'RATCHET_TRAILING_GENERAL_V1', 'ROUTING_EXECUTION_LOG_TABLE_V1', 'SMART_CASH_RECOVERY_V3', 'SMART_MONEY_FLOW_SIGNAL_V2', 'VALUE_PRESERVATION_SCORER_V1' ]; var rows = []; for (var i = 0; i < formulaIds.length; i++) { rows.push({ formula_id: formulaIds[i], implementation_layer: 'GAS_MIRROR', mirror_state: 'DECLARED', formula_id_source: 'GS_FORMULA_MIRROR_V1' }); } return { formula_id: 'GS_FORMULA_MIRROR_V1', rows: rows }; } function applyProposal54BuyBlockLocks_(blueprint, hApex) { // THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/inject_computed_harness.py:main blueprint = Array.isArray(blueprint) ? blueprint : []; function toMap_(obj, key, condFn) { var m = {}; var rows = (obj && obj.rows) || []; if (!Array.isArray(rows)) return m; rows.forEach(function(r) { var tk = String((r || {})[key] || ''); if (!tk) return; m[tk] = condFn(r || {}); }); return m; } var fm = (hApex && hApex.fundamental_multifactor_json) || {}; var egq = (hApex && hApex.earnings_growth_quality_json) || {}; var msp = (hApex && hApex.market_share_proxy_json) || {}; var cfs = (hApex && hApex.cashflow_stability_json) || {}; var fmMap = toMap_(fm, 'ticker', function(r){ return Number(r.score_0_100 || 0) < 60; }); var egqMap = toMap_(egq, 'ticker', function(r){ return String(r.gate || '') === 'BLOCK_BUY'; }); var mspMap = toMap_(msp, 'ticker', function(r){ return String(r.proxy_state || '') === 'LOSING'; }); var cfsMap = toMap_(cfs, 'ticker', function(r){ return String(r.gate || '') === 'BLOCK_BUY'; }); return blueprint.map(function(row) { var r = Object.assign({}, row); var orderType = String(r.order_type || '').toUpperCase(); var isBuy = orderType === 'BUY' || orderType === 'ADD_ON' || orderType === 'STAGED_BUY'; if (!isBuy) return r; var tk = String(r.ticker || ''); var blocks = []; // 충돌 우선순위: Cashflow/Fundamental 계열 > Share/Earnings if (cfsMap[tk]) blocks.push('CASHFLOW_STABILITY_GATE_V1'); if (fmMap[tk]) blocks.push('FUNDAMENTAL_MULTI_FACTOR_SCORE_V2'); if (mspMap[tk]) blocks.push('MARKET_SHARE_MOMENTUM_PROXY_V1'); if (egqMap[tk]) blocks.push('EARNINGS_GROWTH_QUALITY_GATE_V1'); if (blocks.length > 0) { r.validation_status = 'BLOCKED'; r.blocked_by_gate = blocks.join('|'); r.rationale_code = (r.rationale_code ? String(r.rationale_code) + '|' : '') + 'P054_BUY_BLOCK:' + r.blocked_by_gate; if (typeof r.quantity === 'number' && r.quantity > 0) r.quantity = 0; } return r; }); } function calcFundamentalMultiFactorScoreV2_(holdings, dfMap) { holdings = Array.isArray(holdings) ? holdings : []; dfMap = dfMap || {}; var rows = holdings.map(function(h) { var tk = String(h.ticker || ''); var df = dfMap[tk] || {}; var m = { roe: toNumber_(df.roe_pct), opm: toNumber_(df.opm_pct), rev: toNumber_(df.revenue_growth_pct), opg: toNumber_(df.op_income_growth_pct), share: toNumber_(df.market_share_proxy_pct), ocf: toNumber_(df.operating_cf_krw), fcf: toNumber_(df.free_cf_krw), debt: toNumber_(df.debt_ratio_pct) }; var score = 0; var fail = []; if (m.roe !== null && m.roe >= 8) score += 15; else fail.push('ROE'); if (m.opm !== null && m.opm >= 8) score += 15; else fail.push('OPM'); if (m.rev !== null && m.rev >= 0) score += 15; else fail.push('REV_GROWTH'); if (m.opg !== null && m.opg >= 0) score += 15; else fail.push('OP_GROWTH'); if (m.share !== null && m.share >= 0) score += 10; else fail.push('SHARE_PROXY'); if (m.ocf !== null && m.ocf > 0) score += 15; else fail.push('OCF'); if (m.fcf !== null && m.fcf > 0) score += 10; else fail.push('FCF'); if (m.debt !== null && m.debt <= 200) score += 5; else fail.push('DEBT'); var grade = score >= 80 ? 'A' : score >= 65 ? 'B' : score >= 50 ? 'C' : 'D'; return { ticker: tk, name: h.name || '', score_0_100: score, grade: grade, buy_allowed: score >= 60 && fail.length <= 4, fail_reasons: fail }; }); return { formula_id: 'FUNDAMENTAL_MULTI_FACTOR_SCORE_V2', rows: rows }; } function calcEarningsGrowthQualityGateV1_(holdings, dfMap) { holdings = Array.isArray(holdings) ? holdings : []; dfMap = dfMap || {}; var rows = holdings.map(function(h) { var tk = String(h.ticker || ''); var df = dfMap[tk] || {}; var q1 = toNumber_(df.eps_growth_qoq_pct); var y1 = toNumber_(df.eps_growth_yoy_pct); var trend = (q1 !== null && y1 !== null && q1 >= 0 && y1 >= 0) ? 'ACCELERATING' : (q1 !== null && y1 !== null && q1 < 0 && y1 < 0) ? 'DECELERATING' : 'MIXED'; var gate = trend === 'DECELERATING' ? 'BLOCK_BUY' : 'PASS_OR_WATCH'; return { ticker: tk, name: h.name || '', trend: trend, consistency: (trend === 'MIXED' ? 'LOW' : 'HIGH'), gate: gate }; }); return { formula_id: 'EARNINGS_GROWTH_QUALITY_GATE_V1', rows: rows }; } function calcMarketShareMomentumProxyV1_(holdings, dfMap, hApex) { holdings = Array.isArray(holdings) ? holdings : []; dfMap = dfMap || {}; var alphaMap = {}; ((hApex && hApex.alpha_lead_json) || []).forEach(function(r){ alphaMap[String(r.ticker || '')] = r; }); var rows = holdings.map(function(h) { var tk = String(h.ticker || ''); var df = dfMap[tk] || {}; var alpha = alphaMap[tk] || {}; var rev = toNumber_(df.revenue_growth_pct); var rs = toNumber_(alpha.alpha_lead_score); var state = (rev !== null && rev < 0) || (rs !== null && rs < 50) ? 'LOSING' : (rev !== null && rev > 5 && rs !== null && rs >= 70) ? 'GAINING' : 'NEUTRAL'; return { ticker: tk, name: h.name || '', proxy_state: state, confidence_band: state === 'NEUTRAL' ? 'MEDIUM' : 'HIGH' }; }); return { formula_id: 'MARKET_SHARE_MOMENTUM_PROXY_V1', rows: rows }; } function calcCashflowStabilityGateV1_(holdings, dfMap) { holdings = Array.isArray(holdings) ? holdings : []; dfMap = dfMap || {}; var rows = holdings.map(function(h) { var tk = String(h.ticker || ''); var df = dfMap[tk] || {}; var ocf = toNumber_(df.operating_cf_krw); var fcf = toNumber_(df.free_cf_krw); var accrual = toNumber_(df.accrual_ratio_pct); var unstable = (ocf !== null && ocf <= 0) || (fcf !== null && fcf <= 0); var accrRisk = (accrual !== null && accrual > 10); return { ticker: tk, name: h.name || '', stability_state: unstable ? 'UNSTABLE' : 'STABLE', accrual_risk_flag: !!accrRisk, gate: (unstable && accrRisk) ? 'BLOCK_BUY' : 'PASS_OR_WATCH' }; }); return { formula_id: 'CASHFLOW_STABILITY_GATE_V1', rows: rows }; } function calcRoutingExplainLockV1_(hApex) { var eg = (hApex && hApex.export_gate_json) || {}; return { formula_id: 'ROUTING_DECISION_EXPLAIN_LOCK_V1', gate_path: ['FUNDAMENTAL_MULTI_FACTOR_SCORE_V2','EARNINGS_GROWTH_QUALITY_GATE_V1','MARKET_SHARE_MOMENTUM_PROXY_V1','CASHFLOW_STABILITY_GATE_V1','EXPORT_GATE_V2'], blocked_by: eg.hts_entry_allowed ? null : String(eg.json_validation_status || 'REVIEW_ONLY'), override_allowed: false }; } // ═══════════════════════════════════════════════════════════════════════ // [PROPOSAL53] 신규 P0 하네스 4종 // ═══════════════════════════════════════════════════════════════════════ function calcFundamentalQualityGateV1_(holdings, dfMap) { holdings = Array.isArray(holdings) ? holdings : []; dfMap = dfMap || {}; var rows = holdings.map(function(h) { var tk = String(h.ticker || ''); var df = dfMap[tk] || {}; var roe = toNumber_(df.roe_pct); var opGrowth = toNumber_(df.op_income_growth_pct); var debt = toNumber_(df.debt_ratio_pct); var ocf = toNumber_(df.operating_cf_krw); var pe = toNumber_(df.pe_ttm); var pass = 0; var fail = []; if (roe !== null && roe >= 8) pass++; else fail.push('ROE_WEAK_OR_MISSING'); if (opGrowth !== null && opGrowth >= 0) pass++; else fail.push('OP_GROWTH_WEAK_OR_MISSING'); if (debt !== null && debt <= 200) pass++; else fail.push('DEBT_RATIO_HIGH_OR_MISSING'); if (ocf !== null && ocf > 0) pass++; else fail.push('OCF_WEAK_OR_MISSING'); if (pe !== null && pe > 0 && pe <= 35) pass++; else fail.push('PE_BAND_OUT_OR_MISSING'); var grade = pass >= 4 ? 'A' : pass >= 3 ? 'B' : pass >= 2 ? 'C' : 'D'; return { ticker: tk, name: h.name || '', grade: grade, score: pass, buy_allowed: pass >= 3, fail_reasons: fail, formula_id: 'FUNDAMENTAL_QUALITY_GATE_V1' }; }); return { formula_id: 'FUNDAMENTAL_QUALITY_GATE_V1', rows: rows }; } function calcHorizonAllocationLockV1_(holdings, hApex) { holdings = Array.isArray(holdings) ? holdings : []; var totalAsset = toNumber_((hApex && hApex.total_asset_krw) || 0) || 0; var cap = { SHORT: 25, MID: 45, LONG: 70, UNKNOWN: 0 }; var bucketSum = { SHORT: 0, MID: 0, LONG: 0, UNKNOWN: 0 }; var rows = holdings.map(function(h) { var bucket = String(h.invest_horizon || h.horizon_bucket || 'UNKNOWN').toUpperCase(); if (!cap.hasOwnProperty(bucket)) bucket = 'UNKNOWN'; var v = toNumber_(h.marketValue) || toNumber_(h.market_value_krw) || toNumber_(h.close) * (toNumber_(h.holdingQty) || 0) || 0; bucketSum[bucket] += v; return { ticker: String(h.ticker || ''), name: h.name || '', bucket: bucket, market_value_krw: v }; }); var byBucket = Object.keys(bucketSum).map(function(k) { var pct = totalAsset > 0 ? (bucketSum[k] / totalAsset * 100) : 0; return { bucket: k, cap_pct: cap[k], current_pct: Math.round(pct * 100) / 100, violation: pct > cap[k] }; }); return { formula_id: 'HORIZON_ALLOCATION_LOCK_V1', bucket_summary: byBucket, rows: rows }; } function calcSmartMoneyLiquidityGateV1_(holdings, hApex) { holdings = Array.isArray(holdings) ? holdings : []; var radarMap = {}; ((hApex && hApex.proactive_sell_radar_json) || []).forEach(function(r) { radarMap[String(r.ticker || '')] = r; }); var rows = holdings.map(function(h) { var tk = String(h.ticker || ''); var r = radarMap[tk] || {}; var flowState = Number(r.score || 0) >= 6 ? 'OUTFLOW_RISK' : 'NEUTRAL'; var liqState = Number(r.liquidity_5d_bn || 0) > 0 && Number(r.liquidity_5d_bn) < 80 ? 'LOW' : 'NORMAL'; var mode = (flowState === 'OUTFLOW_RISK' && liqState === 'LOW') ? 'SELL_SPLIT_ONLY' : 'NORMAL'; return { ticker: tk, name: h.name || '', flow_state: flowState, liquidity_state: liqState, execution_mode: mode, buy_allowed: mode === 'NORMAL', formula_id: 'SMART_MONEY_LIQUIDITY_GATE_V1' }; }); return { formula_id: 'SMART_MONEY_LIQUIDITY_GATE_V1', rows: rows }; } function buildRoutingServingTraceV2_(routingTrace, hApex) { var rt = routingTrace || {}; var eg = (hApex && hApex.export_gate_json) || {}; return { trace_version: 'V2', llm_serving_budget: 0, request_route: rt.request_route || 'PIPELINE_EOD_BATCH', bundle_selected: rt.bundle_selected || 'retirement_portfolio_compact', prompt_entrypoint: rt.prompt_entrypoint || 'prompts/analysis_prompt.md', gate_path: ['DATA_QUALITY_GATE_V2', 'SELL_PRICE_SANITY_V2', 'EXPORT_GATE_V2'], final_block_reason: eg.hts_entry_allowed ? null : String(eg.json_validation_status || 'REVIEW_ONLY'), json_validation_status: eg.json_validation_status || rt.json_validation_status || 'PENDING_EXPORT', formula_id: 'ROUTING_SERVING_DECISION_TRACE_V2' }; } // ── H1 헬퍼 ────────────────────────────────────────────────────────────────── /** * readMacroRegime_ * macro 시트의 REGIME_PRELIM 행에서 시장 국면 값 읽기 * buildHarnessContext_()에서 국면별 감축 가이던스 산출에 사용 */ function readMacroRegime_(ss) { try { var sh = ss.getSheetByName('macro'); if (!sh) return 'UNKNOWN'; var data = sh.getDataRange().getValues(); for (var i = 0; i < data.length; i++) { if (String(data[i][0] || '') === 'REGIME_PRELIM' || String(data[i][1] || '') === 'Market_Regime_Prelim') { return String(data[i][3] || 'UNKNOWN'); } } return 'UNKNOWN'; } catch(e) { Logger.log('[HARNESS] readMacroRegime_ error: ' + e.message); return 'UNKNOWN'; } } /** * calcRegimeTrimGuidance_ * REGIME_TRIM_WEIGHT_V1: 시장 국면별 위성/주도주 감축 비율 결정론적 산출 * LLM이 "조정기엔 5~10%" 같은 주관적 판단을 내리는 것을 하네스에서 선점 * spec/13_formula_registry.yaml:REGIME_TRIM_WEIGHT_V1 참조 */ function calcRegimeTrimGuidance_(regime) { switch (regime) { case 'SECULAR_LEADER_RISK_ON': case 'RISK_ON': return { phase: 'ADVANCE', satellite_trim_pct_min: 0, satellite_trim_pct_max: 5, leader_trim_pct_min: 0, leader_trim_pct_max: 0, priority_order: 'HOLD_ALL > 약한위성_5%이하 > 중복ETF', new_buy_gate: 'ALLOWED_IF_HEAT_PASS', description: '상승기: 주도주 보유 극대화. 감축 최소화.' }; case 'LEADER_CONCENTRATION': case 'NEUTRAL': return { phase: 'PULLBACK_IN_UPTREND', satellite_trim_pct_min: 5, satellite_trim_pct_max: 10, leader_trim_pct_min: 0, leader_trim_pct_max: 5, priority_order: '약한위성 > 중복ETF > 주도주_소량헤지', new_buy_gate: 'BLOCKED', description: '조정/횡보기: 위성 부분 감축. 주도주 소량 헤지 가능.' }; case 'RISK_OFF_CANDIDATE': return { phase: 'DISTRIBUTION', satellite_trim_pct_min: 10, satellite_trim_pct_max: 25, leader_trim_pct_min: 5, leader_trim_pct_max: 10, priority_order: '중복ETF > 약한위성 > 주도주_이익잠금', new_buy_gate: 'BLOCKED', description: '분배장 경고: 위성 우선 감축. 현금 목표 12% 이상.' }; case 'RISK_OFF': case 'EVENT_SHOCK': return { phase: 'BREAKDOWN', satellite_trim_pct_min: 25, satellite_trim_pct_max: 50, leader_trim_pct_min: 10, leader_trim_pct_max: 25, priority_order: '코어보호해제 > 전종목감축검토', new_buy_gate: 'HARD_BLOCKED', description: '추세붕괴/이벤트쇼크: 전면 감축. 코어 예외 없음.' }; default: return { phase: 'UNKNOWN', satellite_trim_pct_min: 0, satellite_trim_pct_max: 0, leader_trim_pct_min: 0, leader_trim_pct_max: 0, priority_order: 'DATA_MISSING_REGIME — 국면 미확인', new_buy_gate: 'BLOCKED', description: '국면 미확인: 신규매수 보류. macro 재실행 후 재판정.' }; } } function calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore) { var targetCashPct = Math.max(5 + (mrsScore / 10) * 15, cashFloorInfo.minPct); var d2Krw = asResult.settlementCashD2Krw || 0; var asset = Number.isFinite(totalAsset) ? totalAsset : 0; return { cash_current_pct_d2: asset > 0 ? Math.round(d2Krw / asset * 10000) / 100 : 0, cash_target_pct: targetCashPct, cash_shortfall_min_krw: Math.max(0, Math.round(asset * cashFloorInfo.minPct / 100 - d2Krw)), cash_shortfall_target_krw: Math.max(0, Math.round(asset * targetCashPct / 100 - d2Krw)) }; } /** * SECULAR_LEADER_REGIME_GATE_V1 * 삼성전자(005930)·SK하이닉스(000660) secular_leader_profit_lock 결정론적 발동 게이트. * spec/exit/take_profit.yaml:secular_leader_profit_lock.activation_required_all 완전 구현. * 반환: { active, status, reasons } */ function calcSecularLeaderGate_(ticker, marketRegime, df, holdingQty) { var SECULAR_TICKERS = ['005930', '000660']; var reasons = []; if (SECULAR_TICKERS.indexOf(ticker) < 0) { return { active: false, status: 'NOT_APPLICABLE', reasons: ['not_secular_leader_ticker'] }; } // ── 비활성 조건 검사 (any one → 즉시 비활성) ──────────────────────────── var close = df.close || 0; var ma20 = df.ma20 || 0; var frg5d = typeof df.frg5d === 'number' ? df.frg5d : null; var inst5d = typeof df.inst5d === 'number' ? df.inst5d : null; var acTotal = typeof df.acTotal === 'number' ? df.acTotal : 0; var deactivationReasons = []; if (marketRegime !== 'SECULAR_LEADER_RISK_ON') { deactivationReasons.push('regime_not_secular(' + marketRegime + ')'); } if (close > 0 && ma20 > 0 && close <= ma20) { deactivationReasons.push('close(' + close + ')<=MA20(' + ma20 + ')'); } if (acTotal >= 3) { deactivationReasons.push('anti_climax_gate>=' + acTotal); } if (frg5d !== null && inst5d !== null && frg5d < 0 && inst5d < 0) { deactivationReasons.push('dual_outflow:frg5d(' + frg5d + ')_inst5d(' + inst5d + ')'); } if (deactivationReasons.length > 0) { return { active: false, status: 'DEACTIVATED', reasons: deactivationReasons }; } // ── 활성화 조건 검사 (all must pass) ──────────────────────────────────── var activationFails = []; if (!(holdingQty > 0)) { activationFails.push('no_holding_quantity'); } if (close <= 0 || ma20 <= 0) { activationFails.push('close_or_ma20_missing'); } else if (close <= ma20) { activationFails.push('close_below_ma20'); } var flowOk = df.flowOk === 'Y'; var flowPos = (frg5d !== null && frg5d > 0) || (inst5d !== null && inst5d > 0); if (!flowOk || !flowPos) { activationFails.push('flow_condition_fail(flowOk=' + df.flowOk + ',frg5d=' + frg5d + ',inst5d=' + inst5d + ')'); } if (activationFails.length > 0) { return { active: false, status: 'ACTIVATION_FAIL', reasons: activationFails }; } return { active: true, status: 'ACTIVE', reasons: ['regime=SECULAR_LEADER_RISK_ON', 'close>MA20', 'flow_ok', 'holding_confirmed'] }; } function calcIntradayLock_(capturedAt) { if (!capturedAt) return false; var d = capturedAt instanceof Date ? capturedAt : new Date(capturedAt); if (isNaN(d.getTime())) return false; var kstMin = ((d.getUTCHours() + 9) % 24) * 60 + d.getUTCMinutes(); return kstMin < INTRADAY_CUTOFF_MINUTES; } /** * N1: POSITION_SIZE_REGIME_SCALE_V1 * 국면에 따라 atrQty 기반 매수 수량의 스케일 배수를 반환한다. * M1(DrawdownGuard) 이후에 추가로 적용되는 독립적 국면 방어층. * @param {string} regime * @return {{ scale, regime_applied }} */ function calcRegimeSizeScale_(regime) { var r = String(regime || '').toUpperCase(); if (r.indexOf('EVENT_SHOCK') >= 0) return { scale: 0.25, regime_applied: regime }; if (r.indexOf('RISK_OFF') >= 0) return { scale: 0.50, regime_applied: regime }; if (r.indexOf('SECULAR_LEADER') >= 0 && r.indexOf('RISK_ON') >= 0) return { scale: 1.2, regime_applied: regime }; if (r.indexOf('RISK_ON') >= 0) return { scale: 1.1, regime_applied: regime }; return { scale: 1.0, regime_applied: regime }; // NEUTRAL } /** * N5: REGIME_CASH_UPLIFT_V1 * 국면에 따라 현금 최소 비율을 상향하는 오버라이드를 반환한다. * MRS 기반 calcCashFloor_ 결과보다 높을 때만 적용된다. * @param {string} regime * @param {number} mrsCashMinPct — 현재 MRS 기반 최소 현금 % * @return {number} effectiveMinPct */ function calcRegimeCashUplift_(regime, mrsCashMinPct) { var r = String(regime || '').toUpperCase(); var regimeMin = 0; if (r.indexOf('EVENT_SHOCK') >= 0) regimeMin = 20; else if (r.indexOf('RISK_OFF') >= 0) regimeMin = 15; else if (r.indexOf('RISK_ON') >= 0) regimeMin = 5; // 완화 // NEUTRAL: regimeMin=0 → MRS값 그대로 return Math.max(mrsCashMinPct, regimeMin); } /** * N3: STOP_PRICE_ADEQUACY_V1 * 보유 종목의 수동 손절가가 ATR 기반 권고 손절가 대비 적정한지 검증한다. * manual_stop < recommended_stop × 0.85 → STOP_WIDE (너무 넓어 Heat 과소 반영) * manual_stop < recommended_stop × 0.60 → STOP_CRITICAL (손절 의지 없음 수준) * @param {Array} holdings * @param {Object} dfMap * @return {Array} stop_adequacy rows */ function calcStopAdequacyRows_(holdings, dfMap) { return holdings.map(function(h) { var df = dfMap[h.ticker] || {}; var atr20 = typeof df.atr20 === 'number' && df.atr20 > 0 ? df.atr20 : null; var close = df.close || h.close || 0; var avgCost = h.avgCost || 0; var recommendedStop = null; if (atr20 && close > 0 && avgCost > 0) { var atrMul = (atr20 / avgCost * 100 >= 8) ? 2.0 : 1.5; recommendedStop = Math.max(avgCost * 0.92, avgCost - atr20 * atrMul); recommendedStop = tickNormalize_(recommendedStop); } var status = 'PASS'; var stopGap = null; if (recommendedStop !== null && h.stopPrice > 0) { stopGap = round2_((recommendedStop - h.stopPrice) / recommendedStop * 100); if (h.stopPrice < recommendedStop * 0.60) status = 'STOP_CRITICAL'; else if (h.stopPrice < recommendedStop * 0.85) status = 'STOP_WIDE'; } else if (!atr20) { status = 'INSUFFICIENT_DATA'; } return { ticker: h.ticker, name: h.name || '', manual_stop: h.stopPrice || null, recommended_stop: recommendedStop, stop_gap_pct: stopGap, adequacy_status: status, stop_price_src: h.stopPriceSrc || 'UNKNOWN', formula_id: 'STOP_PRICE_ADEQUACY_V1' }; }); } /** * N4: HOLDING_STALE_REVIEW_V1 * 보유 기간이 60일을 초과한 종목에 STALE_POSITION 플래그를 표시한다. * account_snapshot의 entry_date 컬럼 기반. 없으면 ENTRY_DATE_MISSING. * @param {Array} holdings — entryDate 필드 포함 * @return {Array} holding_stale rows */ function calcHoldingStaleReview_(holdings) { var nowMs = Date.now(); var STALE_DAYS = 60; var REVIEW_DAYS = 30; return holdings.map(function(h) { var entryDateStr = h.entryDate || null; var holdingDays = null; var status = 'ENTRY_DATE_MISSING'; if (entryDateStr) { var entryMs = new Date(entryDateStr).getTime(); if (Number.isFinite(entryMs) && entryMs > 0) { holdingDays = Math.floor((nowMs - entryMs) / 86400000); if (holdingDays > STALE_DAYS) status = 'STALE_POSITION'; else if (holdingDays > REVIEW_DAYS) status = 'REVIEW_SOON'; else status = 'FRESH'; } } return { ticker: h.ticker, name: h.name || '', entry_date: entryDateStr, holding_days: holdingDays, stale_status: status, formula_id: 'HOLDING_STALE_REVIEW_V1' }; }); } /** * P1: STOP_BREACH_ALERT_V1 * 보유 종목 중 close <= stop_price인 종목을 즉시 경보한다. * close <= stop_price → BREACH_IMMEDIATE_EXIT * close <= stop_price × 1.03 → STOP_APPROACHING * @param {Array} holdings * @param {Object} dfMap * @return {{ gate, alerts }} */ function calcStopBreachAlert_(holdings, dfMap) { // THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/inject_computed_harness.py:calc_stop_breach_alerts var gate = 'PASS'; var alerts = holdings.map(function(h) { var df = dfMap[h.ticker] || {}; var close = h.close || df.close || 0; var stopPrc = h.stopPrice || 0; var status = 'PASS'; var gapPct = null; if (close > 0 && stopPrc > 0) { gapPct = round2_((close - stopPrc) / stopPrc * 100); if (close <= stopPrc) { status = 'BREACH_IMMEDIATE_EXIT'; gate = 'BREACH'; } else if (close <= stopPrc * 1.03) { status = 'STOP_APPROACHING'; if (gate === 'PASS') gate = 'APPROACHING'; } } else { status = 'INSUFFICIENT_DATA'; } return { ticker: h.ticker, name: h.name || '', close: close, stop_price: stopPrc, stop_src: h.stopPriceSrc || 'UNKNOWN', gap_pct: gapPct, status: status, formula_id: 'STOP_BREACH_ALERT_V1' }; }); return { gate: gate, alerts: alerts }; } /** * P1-BIS: RELATIVE_STOP_SIGNAL_V1 * 시장 베타 보정 후 초과수익(20D) 기반 상대 손절 신호. * k=2.0 → threshold = -k × σ_proxy; ABS_FLOOR=-20%; TIME_STOP=60일+음수 초과수익 * @param {Array} holdings * @param {Object} dfMap * @param {number} kospiRet20d — KOSPI 20D 수익률 (%) * @return {{ gate, signals }} */ function calcRelativeStopSignal_(holdings, dfMap, kospiRet20d) { var K = 2.0; var ABS_FLOOR = -20.0; var gate = 'PASS'; var signals = holdings.map(function(h) { var df = dfMap[h.ticker] || {}; var ret20d = typeof df.ret20d === 'number' ? df.ret20d : parseFloat(df.ret20d); var atr20 = typeof df.atr20 === 'number' ? df.atr20 : parseFloat(df.atr20); var close = h.close || df.close || 0; var profitPct = typeof h.profitPct === 'number' ? h.profitPct : parseFloat(h.profitPct); var holdDays = typeof h.holdingDays === 'number' ? h.holdingDays : parseInt(h.holdingDays) || 0; if (!Number.isFinite(ret20d) || !Number.isFinite(atr20) || close <= 0) { return { ticker: h.ticker, name: h.name || '', signal: false, signal_type: 'INSUFFICIENT_DATA', details: {}, formula_id: 'RELATIVE_STOP_SIGNAL_V1' }; } var betaProxy = 1.0; if (typeof kospiRet20d === 'number' && Math.abs(kospiRet20d) >= 0.5) { betaProxy = Math.min(3.0, Math.max(0.3, ret20d / kospiRet20d)); } var excessRet = ret20d - betaProxy * kospiRet20d; var sigmaProxy = (atr20 / close * 100) * Math.sqrt(20); var threshold = -K * sigmaProxy; var relBreach = excessRet < threshold; var absBreach = Number.isFinite(profitPct) && profitPct < ABS_FLOOR; var timeBreach = holdDays >= 60 && excessRet < 0; var triggered = relBreach || absBreach || timeBreach; var signalType = absBreach ? 'ABS_FLOOR' : (relBreach ? 'REL_EXCESS' : (timeBreach ? 'TIME_STOP' : 'PASS')); if (triggered && gate === 'PASS') gate = 'TRIGGERED'; return { ticker: h.ticker, name: h.name || '', signal: triggered, signal_type: signalType, details: { beta_proxy: round2_(betaProxy), excess_ret20d: round2_(excessRet), sigma_proxy: round2_(sigmaProxy), threshold: round2_(threshold), profit_pct: Number.isFinite(profitPct) ? round2_(profitPct) : null, hold_days: holdDays }, formula_id: 'RELATIVE_STOP_SIGNAL_V1' }; }); return { gate: gate, signals: signals }; } /** * P3: ABSOLUTE_RISK_STOP_V1 * stop adequacy rows를 절대 리스크 손절 taxonomy에 맞춰 표준화한다. * @param {Array} holdings * @param {Object} dfMap * @return {{ gate, rows }} */ function calcAbsoluteRiskStopV1_(holdings, dfMap) { // THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_stop_price_core var rows = calcStopAdequacyRows_(holdings, dfMap).map(function(r) { var stopPrice = Number.isFinite(r.manual_stop) && r.manual_stop > 0 ? r.manual_stop : r.recommended_stop; return { ticker: r.ticker, name: r.name || '', stop_price: Number.isFinite(stopPrice) ? round2_(stopPrice) : null, stop_quantity: null, adequacy_status: r.adequacy_status, stop_gap_pct: r.stop_gap_pct, formula_id: 'ABSOLUTE_RISK_STOP_V1' }; }); var gate = rows.some(function(r) { return r.adequacy_status === 'STOP_CRITICAL'; }) ? 'BLOCK' : 'PASS'; return { gate: gate, rows: rows }; } /** * P3: RELATIVE_UNDERPERF_ALERT_V1 * 상대약세 경보를 표준 taxonomy로 감싼다. * @param {Array} holdings * @param {Object} dfMap * @param {number} kospiRet20d * @return {{ gate, rows }} */ function calcRelativeUnderperfAlertV1_(holdings, dfMap, kospiRet20d) { var result = calcRelativeStopSignal_(holdings, dfMap, kospiRet20d); return { gate: result.gate, rows: result.signals.map(function(r) { return { ticker: r.ticker, name: r.name || '', signal: !!r.signal, signal_type: r.signal_type, details: r.details || {}, formula_id: 'RELATIVE_UNDERPERF_ALERT_V1' }; }) }; } /** * P3: STOP_ACTION_LADDER_V1 * exit sell action 결과를 손절/익절/시간손절 taxonomy로 표준화한다. * @param {Object} ctx * @return {{ formula_id, action, ratio_pct, limit_price, price_basis, reason, validation }} */ var calcStopActionLadderV1_ = function(ctx) { var d = calcExitSellAction_(ctx || {}); return { formula_id: 'STOP_ACTION_LADDER_V1', action: d.action, ratio_pct: d.ratio_pct, limit_price: d.limit_price, price_basis: d.price_basis, reason: d.reason, validation: d.validation, order_type: d.order_type, price_source: d.price_source }; } /** * P2: TP_TRIGGER_ALERT_V1 * close >= tp1_price / tp2_price인 종목을 감지하고 tp_quantity_ladder_json과 연계한다. * 익절 가격 도달 시 즉각 수량을 확정론적으로 제공한다. * @param {Array} holdings * @param {Object} dfMap * @param {Object} h4 — calcPrices_() 반환값 (h4.prices 배열) * @param {Array} tpLadderRows — calcTpQuantityLadder_() 반환값 * @return {{ gate, triggered }} */ function calcTpTriggerAlert_(holdings, dfMap, h4, tpLadderRows) { // THIN_ADAPTER: [take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_tp_validity var priceMap = {}; (h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; }); var ladderMap = {}; (tpLadderRows || []).forEach(function(r) { ladderMap[r.ticker] = r; }); var gate = 'PASS'; var triggered = []; holdings.forEach(function(h) { var df = dfMap[h.ticker] || {}; var close = h.close || df.close || 0; var pr = priceMap[h.ticker] || {}; var lr = ladderMap[h.ticker] || {}; var tp1 = typeof pr.tp1_price === 'number' ? pr.tp1_price : null; var tp2 = typeof pr.tp2_price === 'number' ? pr.tp2_price : null; var tp1Hit = tp1 !== null && close > 0 && close >= tp1; var tp2Hit = tp2 !== null && close > 0 && close >= tp2; if (!tp1Hit && !tp2Hit) return; if (gate === 'PASS') gate = 'TRIGGERED'; triggered.push({ ticker: h.ticker, name: h.name || '', close: close, tp1_price: tp1, tp2_price: tp2, tp1_triggered: tp1Hit, tp2_triggered: tp2Hit, tp1_qty: lr.tp1_qty !== undefined ? lr.tp1_qty : null, tp2_qty: lr.tp2_qty !== undefined ? lr.tp2_qty : null, qty_source: lr.qty_source || 'NO_LADDER', formula_id: 'TP_TRIGGER_ALERT_V1' }); }); return { gate: gate, triggered: triggered }; } /** * P3: HEAT_CONCENTRATION_ALERT_V1 * 단일 종목이 전체 Total Heat의 50% 이상을 차지하면 HEAT_CONCENTRATED 경보. * 해당 종목 급락 시 total_heat_pct가 급변해 게이트가 무력화되는 리스크 차단. * @param {Array} holdings — avgCost, stopPrice, holdingQty 포함 * @param {number} totalHeatKrw * @return {{ gate, by_holding }} */ function calcHeatConcentrationAlert_(holdings, totalHeatKrw) { if (!totalHeatKrw || totalHeatKrw <= 0) { return { gate: 'INSUFFICIENT_DATA', by_holding: [], formula_id: 'HEAT_CONCENTRATION_ALERT_V1' }; } var gate = 'PASS'; var rows = holdings.map(function(h) { var heatI = (h.avgCost > 0 && h.stopPrice > 0 && h.holdingQty > 0) ? (h.avgCost - h.stopPrice) * h.holdingQty : 0; var sharePct = round2_(heatI / totalHeatKrw * 100); var status = sharePct >= 50 ? 'HEAT_CONCENTRATED' : 'PASS'; if (status === 'HEAT_CONCENTRATED') gate = 'HEAT_CONCENTRATED'; return { ticker: h.ticker, name: h.name || '', heat_krw: Math.round(heatI), heat_share_pct: sharePct, status: status, formula_id: 'HEAT_CONCENTRATION_ALERT_V1' }; }); return { gate: gate, by_holding: rows }; } /** * P4: REGIME_TRANSITION_ALERT_V1 * settings.prev_market_regime와 현재 국면을 비교해 전환 유형을 산출한다. * UPGRADE(완화) / DOWNGRADE(긴축) / LATERAL_SHIFT / NO_CHANGE * 실행 후 current regime을 settings에 자동 기록. * @param {string} marketRegime * @param {Object} ss * @param {Object} settings * @return {{ transition_type, prev_regime, current_regime, affected_gates }} */ function calcRegimeTransitionAlert_(marketRegime, ss, settings) { var prevRegime = String(settings['prev_market_regime'] || '').trim(); var curr = String(marketRegime || '').toUpperCase(); var prev = prevRegime.toUpperCase(); writeSettingValue_(ss, 'prev_market_regime', marketRegime); if (!prevRegime || prev === curr) { return { transition_type: 'NO_CHANGE', prev_regime: prevRegime || null, current_regime: marketRegime, affected_gates: [], formula_id: 'REGIME_TRANSITION_ALERT_V1' }; } var RANK = { 'EVENT_SHOCK': 0, 'RISK_OFF': 1, 'NEUTRAL': 2, 'RISK_ON': 3, 'SECULAR_LEADER': 4 }; var getRank = function(r) { if (r.indexOf('SECULAR_LEADER') >= 0) return 4; if (r.indexOf('RISK_ON') >= 0) return 3; if (r.indexOf('NEUTRAL') >= 0) return 2; if (r.indexOf('RISK_OFF') >= 0) return 1; if (r.indexOf('EVENT_SHOCK') >= 0) return 0; return 2; }; var transitionType = getRank(curr) > getRank(prev) ? 'UPGRADE' : getRank(curr) < getRank(prev) ? 'DOWNGRADE' : 'LATERAL_SHIFT'; var AFFECTED = [ 'DYNAMIC_HEAT_GATE_V1', 'POSITION_SIZE_REGIME_SCALE_V1', 'REGIME_CASH_UPLIFT_V1', 'PORTFOLIO_BETA_GATE_V1', 'SECTOR_CONCENTRATION_LIMIT_V1', 'SEMICONDUCTOR_CLUSTER_GATE_V1', 'SINGLE_POSITION_WEIGHT_CAP_V1', 'POSITION_COUNT_LIMIT_V1' ]; return { transition_type: transitionType, prev_regime: prevRegime, current_regime: marketRegime, affected_gates: AFFECTED, formula_id: 'REGIME_TRANSITION_ALERT_V1' }; } /** * P5: PORTFOLIO_HEALTH_SCORE_V1 * 모든 게이트 상태를 집계해 HEALTHY/CAUTION/CRITICAL 단일 레이블을 산출한다. * CRITICAL 게이트 1개 이상, 또는 CAUTION 게이트 3개 이상 → CRITICAL * CAUTION 게이트 1~2개 → CAUTION, 0개 → HEALTHY * score = max(0, 100 - critical×30 - caution×10) * @param {Object} gateMap — { gate_id: gate_status_string } * @return {{ label, score, critical_count, caution_count, blocked_gates }} */ function calcPortfolioHealthScore_(gateMap) { var CRITICAL = ['BLOCK_NEW_BUY', 'HARD_BLOCK', 'NO_BUY', 'DRAWDOWN_FORCE_RISK_OFF', 'POSITION_COUNT_BLOCK', 'CLUSTER_BLOCK', 'BREACH', 'OVER_BETA', 'BLOCK_SECTOR', 'STOP_CRITICAL']; var CAUTION = ['HALVE_NEW_BUY_QUANTITY', 'TRIM_REQUIRED', 'REDUCE_BUY', 'CAUTION_BUY', 'DRAWDOWN_CAUTION', 'WARN_BETA', 'WARN_TOP2', 'OVERWEIGHT_TRIM', 'EDGE_DEGRADED', 'EDGE_WEAK', 'EDGE_CRITICAL', 'APPROACHING', 'TRIGGERED', 'HEAT_CONCENTRATED', 'DOWNGRADE']; var critCount = 0, warnCount = 0, blocked = []; Object.keys(gateMap).forEach(function(name) { var val = String(gateMap[name] || '').trim(); if (CRITICAL.indexOf(val) >= 0) { critCount++; blocked.push({ gate: name, status: val, severity: 'CRITICAL' }); } else if (CAUTION.indexOf(val) >= 0) { warnCount++; blocked.push({ gate: name, status: val, severity: 'CAUTION' }); } }); var label = (critCount >= 1 || warnCount >= 3) ? 'CRITICAL' : warnCount >= 1 ? 'CAUTION' : 'HEALTHY'; return { label: label, score: Math.max(0, 100 - critCount * 30 - warnCount * 10), critical_count: critCount, caution_count: warnCount, blocked_gates: blocked, gate_input_count: Object.keys(gateMap).length, formula_id: 'PORTFOLIO_HEALTH_SCORE_V1' }; } /** * O1: SINGLE_POSITION_WEIGHT_CAP_V1 * 개별 종목 비중이 국면별 상한(NEUTRAL:20%, RISK_OFF:15%)을 초과하면 OVERWEIGHT_TRIM. * M5(섹터 편중)와 독립적인 종목 단위 비중 하드 캡. * @param {Array} holdings — weightPct 포함 * @param {string} marketRegime * @return {{ gate_status, cap_pct, by_position }} */ /** * LEADER_POSITION_WEIGHT_CAP_V1 * 삼성전자(005930), SK하이닉스(000660)에 대해 KOSPI 시총 비중 기반 차등 한도 적용. * spec/strategy/semiconductor_concentration_policy.yaml 기준. * * 배경: 삼성전자 KOSPI 비중 ~23%. 기존 고정 20% 한도는 시장 비중보다 낮아 * 주도주를 사실상 과소보유 강제. 국면별로 시장 비중 × 배수를 허용한다. * * @param {Array} holdings * @param {string} marketRegime * @param {number} kospiSamsungWeightPct — settings.kospi_samsung_weight_pct (기본 23) * @param {number} kospiHynixWeightPct — settings.kospi_hynix_weight_pct (기본 12) */ function calcSinglePositionWeightCap_(holdings, marketRegime, kospiSamsungWeightPct, kospiHynixWeightPct) { var r = String(marketRegime || '').toUpperCase(); var isEventShock = r.indexOf('EVENT_SHOCK') >= 0; var isRiskOff = isEventShock || r.indexOf('RISK_OFF') >= 0; var isRiskOn = r.indexOf('RISK_ON') >= 0 && !isRiskOff; var isSecularLeader = r.indexOf('SECULAR_LEADER') >= 0; // settings에서 KOSPI 개별 종목 비중 읽기 (KRX/FnGuide 시총 데이터 기반 수동 입력) // 미입력(0) 시 mktWtProvided=false → 정책 기반 고정 한도만 적용 var smWt = (Number.isFinite(kospiSamsungWeightPct) && kospiSamsungWeightPct > 0) ? kospiSamsungWeightPct : 0; var hxWt = (Number.isFinite(kospiHynixWeightPct) && kospiHynixWeightPct > 0) ? kospiHynixWeightPct : 0; var smWtProvided = smWt > 0; var hxWtProvided = hxWt > 0; // 일반 종목 한도 (기존 유지) var defaultCap = isRiskOff ? 15.0 : (isRiskOn ? 22.0 : 20.0); var gate = 'PASS'; var rows = holdings.map(function(h) { var wPct = typeof h.weightPct === 'number' ? h.weightPct : 0; var tickerCap; if (h.ticker === '005930') { // 삼성전자 — 국면별 정책 한도 (EXPERT_PRIOR, calibration_registry 등록) // KOSPI 비중 제공 시: 비중×배수 vs 정책 한도 중 큰 값 // KOSPI 비중 미제공 시: 정책 한도만 (추측값 삽입 금지) if (isEventShock) tickerCap = 15.0; else if (isRiskOff) tickerCap = 18.0; else if (isSecularLeader) tickerCap = smWtProvided ? Math.max(50.0, smWt * 2.20) : 50.0; else if (isRiskOn) tickerCap = smWtProvided ? Math.max(40.0, smWt * 1.70) : 40.0; else // NEUTRAL tickerCap = smWtProvided ? Math.max(28.0, smWt * 1.20) : 28.0; } else if (h.ticker === '000660') { // SK하이닉스 — 국면별 정책 한도 if (isEventShock) tickerCap = 10.0; else if (isRiskOff) tickerCap = 12.0; else if (isSecularLeader) tickerCap = hxWtProvided ? Math.max(28.0, hxWt * 2.50) : 28.0; else if (isRiskOn) tickerCap = hxWtProvided ? Math.max(22.0, hxWt * 1.80) : 22.0; else // NEUTRAL tickerCap = hxWtProvided ? Math.max(15.0, hxWt * 1.20) : 15.0; } else { tickerCap = defaultCap; } tickerCap = round2_(tickerCap); var status = wPct > tickerCap ? 'OVERWEIGHT_TRIM' : 'PASS'; if (status === 'OVERWEIGHT_TRIM') gate = 'OVERWEIGHT_TRIM'; return { ticker: h.ticker, name: h.name || '', weight_pct: wPct, cap_pct: tickerCap, status: status, is_leader: (h.ticker === '005930' || h.ticker === '000660'), formula_id: 'LEADER_POSITION_WEIGHT_CAP_V1' }; }); return { gate_status: gate, cap_pct: defaultCap, kospi_samsung_weight: smWtProvided ? round2_(smWt) : 'DATA_MISSING_SET_IN_SETTINGS', kospi_hynix_weight: hxWtProvided ? round2_(hxWt) : 'DATA_MISSING_SET_IN_SETTINGS', by_position: rows, formula_id: 'LEADER_POSITION_WEIGHT_CAP_V1' }; } /** * O2: SEMICONDUCTOR_CLUSTER_GATE_V1 * 005930(삼성전자) + 000660(SK하이닉스) 합산 비중이 상한을 초과하면 CLUSTER_BLOCK. * 두 종목이 같은 사이클에서 동반 하락하는 상관 리스크 통제. * @param {Array} holdings * @param {string} marketRegime * @return {{ gate_status, combined_pct, cap_pct, holdings }} */ /** * MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1 * 반도체 클러스터 한도를 KOSPI 시총 비중 기반으로 동적 산출한다. * spec/strategy/semiconductor_concentration_policy.yaml 기준. * * 배경: 삼성+하이닉스 KOSPI 비중 ~35%. 기존 고정 25% 한도는 주도장에서 * 시장 대비 필연적 언더퍼폼을 강제. 시장 비중은 최소 허용해야 한다. * * @param {Array} holdings * @param {string} marketRegime * @param {number} kospiSemiWeightPct — settings.kospi_semi_weight_pct (기본 35) */ function calcSemiconductorClusterGate_(holdings, marketRegime, kospiSemiWeightPct) { var r = String(marketRegime || '').toUpperCase(); var isEventShock = r.indexOf('EVENT_SHOCK') >= 0; var isRiskOff = isEventShock || r.indexOf('RISK_OFF') >= 0; var isRiskOn = r.indexOf('RISK_ON') >= 0 && !isRiskOff; var isSecularLeader = r.indexOf('SECULAR_LEADER') >= 0; var isCLA = r.indexOf('CONCENTRATED_LEADER_ADVANCE') >= 0 || r === 'CLA'; // settings에서 KOSPI 반도체 시총 비중 읽기 (사용자가 KRX 데이터 기반으로 직접 입력) // 0 또는 미입력이면 DATA_MISSING — 아래 정책 기반 한도만 적용 var mktWt = (Number.isFinite(kospiSemiWeightPct) && kospiSemiWeightPct > 0) ? kospiSemiWeightPct : 0; var mktWtProvided = mktWt > 0; // 국면별 정책 한도 (EXPERT_PRIOR — calibration_registry.yaml 등록값) // 주의: KOSPI 비중은 KRX/FnGuide 시총 데이터 기준으로 settings에서만 입력. // 하드코딩 추정치 사용 금지. settings 미입력 시 정책 한도만 적용. var capPct, gateMode; if (isEventShock) { capPct = mktWtProvided ? Math.max(20.0, mktWt * 0.60) : 20.0; gateMode = 'DEFENSIVE_STRICT'; } else if (isRiskOff) { capPct = mktWtProvided ? Math.max(25.0, mktWt * 0.80) : 25.0; gateMode = 'DEFENSIVE'; } else if (isSecularLeader || isCLA) { capPct = 65.0; gateMode = 'SECULAR_LEADER'; } else if (isRiskOn) { capPct = mktWtProvided ? Math.max(45.0, mktWt * 1.30) : 45.0; gateMode = 'RISK_ON_OVERWEIGHT'; } else { capPct = mktWtProvided ? Math.max(35.0, mktWt * 1.00) : 35.0; gateMode = 'MARKET_NEUTRAL'; } // CLA 상태에서는 KODEX 반도체(229200)도 클러스터에 포함 var SEMI_BASE = ['005930', '000660']; var SEMI_CLA = ['005930', '000660', '229200']; var clusterTickers = isCLA ? SEMI_CLA : SEMI_BASE; var total = 0; var clusterRows = []; holdings.forEach(function(h) { if (clusterTickers.indexOf(h.ticker) >= 0) { var wPct = typeof h.weightPct === 'number' ? h.weightPct : 0; total += wPct; clusterRows.push({ ticker: h.ticker, name: h.name || '', weight_pct: wPct }); } }); // 게이트 판정 // WARN 경계: mktWt 제공 시 mktWt × 0.90, 미제공 시 capPct × 0.80 var warnThreshold = mktWtProvided ? mktWt * 0.90 : capPct * 0.80; var gate, clusterState; if (total >= capPct) { if (isRiskOff) { gate = 'CLUSTER_BLOCK'; clusterState = 'CLUSTER_HOLD_ONLY'; } else { gate = 'CLUSTER_OVERWEIGHT_TRIM'; clusterState = 'CLUSTER_HOLD_ONLY'; } } else if (total >= warnThreshold) { if (isSecularLeader || isCLA) { gate = 'CLUSTER_HOLD_ONLY'; clusterState = 'CLUSTER_HOLD_ONLY'; } else { gate = 'CLUSTER_OVERWEIGHT_WARN'; clusterState = 'CLUSTER_OPEN'; } } else { gate = 'PASS'; clusterState = 'CLUSTER_OPEN'; } return { gate_status: gate, cluster_state: clusterState, cluster_id: 'SEMICONDUCTOR_KR', cluster_tickers: clusterTickers, combined_pct: round2_(total), cap_pct: round2_(capPct), kospi_semi_weight: mktWtProvided ? round2_(mktWt) : 'DATA_MISSING_SET_IN_SETTINGS', kospi_weight_provided: mktWtProvided, gate_mode: gateMode, holdings: clusterRows, formula_id: 'MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1' }; } /** * SATELLITE_FAILURE_GATE_V1 * 위성 집단 실패 추적 — spec/13_formula_registry.yaml:SATELLITE_FAILURE_GATE_V1 * @param {Array} satelliteRows — { composite_verdict, rs_verdict, ret20d, excess_ret_10d } * @return {{ sfg_v1, sfg_reason, sfg_broken_count, sfg_failure_rate }} */ function calcSatelliteFailureGate_(satelliteRows) { if (!satelliteRows || satelliteRows.length === 0) { return { sfg_v1: 'CLEAR', sfg_reason: 'no_satellite_data', sfg_broken_count: 0, sfg_failure_rate: 0, formula_id: 'SATELLITE_FAILURE_GATE_V1' }; } var brokenCount = 0, failureCount = 0; var totalRet20d = 0, totalExcess = 0, retCount = 0; satelliteRows.forEach(function(row) { var cv = row.composite_verdict || ''; var rv = row.rs_verdict || ''; if (cv === 'CLOSE_POSITION' || rv === 'BROKEN') brokenCount++; if (cv === 'REDUCE_CANDIDATE' || cv === 'EXIT_REVIEW' || cv === 'CLOSE_POSITION') failureCount++; if (typeof row.ret20d === 'number') { totalRet20d += row.ret20d; retCount++; } if (typeof row.excess_ret_10d === 'number') totalExcess += row.excess_ret_10d; }); var n = satelliteRows.length; var failureRate = n > 0 ? failureCount / n : 0; var avgRet20d = retCount > 0 ? totalRet20d / retCount : 0; var avgExcess = n > 0 ? totalExcess / n : 0; var condA = brokenCount >= 3; var condB = failureRate >= 0.60; var condC = avgRet20d <= -10 && avgExcess <= -8; // ret20d는 % 단위 (e.g. -10.5) var triggered = condA || condB || condC; return { sfg_v1: triggered ? 'TRIGGERED' : 'CLEAR', sfg_reason: condA ? ('broken_count_' + brokenCount) : condB ? ('failure_rate_' + Math.round(failureRate * 100) + 'pct') : condC ? 'avg_excess_drawdown_breach' : 'clear', sfg_broken_count: brokenCount, sfg_failure_rate: parseFloat(failureRate.toFixed(3)), formula_id: 'SATELLITE_FAILURE_GATE_V1' }; } /** * SATELLITE_AGGREGATE_PNL_GATE_V1 * 위성 합산 손익이 코어 수익을 얼마나 잠식하는지 결정론적으로 산출한다. */ function calcSatelliteAggregatePnlGate_(holdings) { var corePnl = 0, satellitePnl = 0, coreCount = 0, satelliteCount = 0; (holdings || []).forEach(function(h) { var pnl = typeof h.profit_loss === 'number' ? h.profit_loss : typeof h.unrealizedPnl === 'number' ? h.unrealizedPnl : typeof h.unrealized_pnl_krw === 'number' ? h.unrealized_pnl_krw : 0; if (h.position_type === 'core') { corePnl += pnl; coreCount++; } else { satellitePnl += pnl; satelliteCount++; } }); var ratio = corePnl > 0 ? Math.abs(Math.min(0, satellitePnl)) / corePnl : null; var status = ratio === null ? 'INSUFFICIENT_DATA' : ratio >= 0.50 ? 'SAPG_CRITICAL' : ratio >= 0.25 ? 'SAPG_ALERT' : 'PASS'; return { sapg_status: status, core_total_pnl_krw: Math.round(corePnl), satellite_total_pnl_krw: Math.round(satellitePnl), satellite_loss_to_core_gain_ratio: ratio === null ? null : round2_(ratio), core_count: coreCount, satellite_count: satelliteCount, formula_id: 'SATELLITE_AGGREGATE_PNL_GATE_V1' }; } function calcCashCreationPurposeLockRow_(h, df, sfgResult) { var cv = df.composite_verdict || null; var rv = df.rs_verdict || null; var brt = df.brt_verdict || null; var excessDrawdown = typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null; var rec20 = typeof df.recovery_ratio_20d === 'number' ? df.recovery_ratio_20d : null; var valid = false; var reasons = []; if (['REDUCE_CANDIDATE', 'EXIT_REVIEW', 'CLOSE_POSITION'].includes(cv)) { valid = true; reasons.push('composite_verdict_' + cv); } if (rv === 'BROKEN' || brt === 'BROKEN') { valid = true; reasons.push('relative_broken'); } if (excessDrawdown !== null && excessDrawdown >= 10 && rec20 !== null && rec20 < 0.50) { valid = true; reasons.push('excess_drawdown_no_recovery'); } if (sfgResult && sfgResult.sfg_v1 === 'TRIGGERED' && h.position_type !== 'core') { valid = true; reasons.push('sfg_v1_TRIGGERED'); } return { ticker: h.ticker, name: h.name || df.name || '', position_type: h.position_type || 'unknown', sell_reason_validity: valid ? 'VALID_SELL_REASON' : 'INVALID_SELL_REASON', valid_reason_codes: reasons, reinvestment_allowed: false, formula_id: 'CASH_CREATION_PURPOSE_LOCK_V1' }; } // ── [2026-05-21_AEW_V1] ALPHA_EVALUATION_WINDOW_V1 ────────────────────────── // 위성 보유 종목의 진입 이후 경과 영업일을 판단하여 T+20/T+60 알파 게이트를 산출한다. // 벤치마크: 삼성전자(005930) + SK하이닉스(000660) 평균 ret20D/ret60D (프록시). // position_type=core 종목은 EXEMPT 처리하여 게이트 판정에서 제외한다. function calcAlphaEvaluationWindow_(holdings, dfMap) { var samsung = dfMap['005930'] || {}; var hynix = dfMap['000660'] || {}; // 코어 벤치마크 수익률 프록시 var coreRet20Vals = []; if (Number.isFinite(samsung.ret20D)) coreRet20Vals.push(samsung.ret20D); if (Number.isFinite(hynix.ret20D)) coreRet20Vals.push(hynix.ret20D); var coreRet20d = coreRet20Vals.length > 0 ? coreRet20Vals.reduce(function(s,v){return s+v;},0) / coreRet20Vals.length : null; var coreRet60Vals = []; if (Number.isFinite(samsung.ret60D)) coreRet60Vals.push(samsung.ret60D); if (Number.isFinite(hynix.ret60D)) coreRet60Vals.push(hynix.ret60D); var coreRet60d = coreRet60Vals.length > 0 ? coreRet60Vals.reduce(function(s,v){return s+v;},0) / coreRet60Vals.length : null; var aewRows = []; holdings.forEach(function(h) { if (!h.ticker) return; // core 종목 — 알파 게이트 평가 대상 아님 if (h.position_type === 'core') { aewRows.push({ ticker: h.ticker, name: h.name || '', position_type: 'core', entry_date: h.entry_date || '', days_since_entry: null, satellite_return_pct: null, core_benchmark_ret20d: coreRet20d, core_benchmark_ret60d: coreRet60d, t20_reached: false, t20_vs_core_pctp: null, t20_alpha_gate: 'EXEMPT', t60_reached: false, t60_vs_core_pctp: null, t60_alpha_gate: 'EXEMPT', evaluation_method: 'EXEMPT_CORE', formula_id: 'ALPHA_EVALUATION_WINDOW_V1' }); return; } var daysSinceEntry = h.entry_date ? calcKrxBizDaysDiff_(h.entry_date) : null; var satRetPct = typeof h.return_pct === 'number' && Number.isFinite(h.return_pct) ? h.return_pct : null; // entry_date 없거나 미래 날짜 — 데이터 누락 var validEntry = daysSinceEntry !== null && daysSinceEntry >= 0; var t20Reached = validEntry && daysSinceEntry >= 20; var t60Reached = validEntry && daysSinceEntry >= 60; var t20VsCorePctp = null; var t20AlphaGate = validEntry ? (t20Reached ? 'DATA_MISSING' : 'NOT_YET') : 'DATA_MISSING'; var t60VsCorePctp = null; var t60AlphaGate = validEntry ? (t60Reached ? 'DATA_MISSING' : 'NOT_YET') : 'DATA_MISSING'; // T+20 평가 — 위성 총수익률 vs 코어 20D 수익률 (프록시) if (t20Reached && satRetPct !== null && coreRet20d !== null) { t20VsCorePctp = round2_(satRetPct - coreRet20d); t20AlphaGate = t20VsCorePctp < -3 ? 'T20_ALPHA_FAIL' : t20VsCorePctp >= 0 ? 'PASS' : 'NEUTRAL'; } // T+60 평가 — 위성 총수익률 vs 코어 60D 수익률 (프록시) if (t60Reached && satRetPct !== null && coreRet60d !== null) { t60VsCorePctp = round2_(satRetPct - coreRet60d); t60AlphaGate = t60VsCorePctp < -5 ? 'T60_ALPHA_FAIL' : t60VsCorePctp >= 0 ? 'PASS' : 'NEUTRAL'; } aewRows.push({ ticker: h.ticker, name: h.name || '', position_type: h.position_type || 'satellite', entry_date: h.entry_date || '', days_since_entry: daysSinceEntry, satellite_return_pct: satRetPct, core_benchmark_ret20d: coreRet20d, core_benchmark_ret60d: coreRet60d, t20_reached: t20Reached, t20_vs_core_pctp: t20VsCorePctp, t20_alpha_gate: t20AlphaGate, t60_reached: t60Reached, t60_vs_core_pctp: t60VsCorePctp, t60_alpha_gate: t60AlphaGate, // PROXY 경고: satRetPct는 진입~현재 총수익률; 코어 벤치마크는 20D/60D rolling // 동일 기간 비교가 아니므로 진입 시점이 20~60일 이내인 경우 오차 있음 evaluation_method: 'PROXY_FROM_RETURN_PCT_VS_CORE_ROLLING', formula_id: 'ALPHA_EVALUATION_WINDOW_V1' }); }); return aewRows; } // ───────────────────────────────────────────────────────────────────────────── // [2026-05-21_SPRINT_B] Sprint B — 4개 하네스 게이트 // ───────────────────────────────────────────────────────────────────────────── // ── B-1: HARNESS_DATA_FRESHNESS_GATE_V1 ───────────────────────────────────── // account_snapshot capturedAt 기준으로 영업일 신선도를 판정한다. // STALE_BLOCK(5일+) → 주문표 생성 차단. STALE_WARN(3-4일) → SAQG ELIGIBLE 하향. function calcHarnessDataFreshnessGate_(capturedAtIso, now) { // capturedAtIso: "yyyy-MM-dd HH:mm:ss" or "yyyy-MM-dd" — 날짜만 추출 var marketDateStr = capturedAtIso ? String(capturedAtIso).substring(0, 10) : null; if (!marketDateStr || !/^\d{4}-\d{2}-\d{2}$/.test(marketDateStr)) { return { data_freshness_status: 'UNKNOWN', data_age_business_days: null, market_date: null, freshness_degraded_gates: ['ALL_GATES_UNCERTAIN'], formula_id: 'HARNESS_DATA_FRESHNESS_GATE_V1' }; } var ageDays = calcKrxBizDaysDiff_(marketDateStr); var status = ageDays <= 1 ? 'FRESH' : ageDays === 2 ? 'STALE_1D' : ageDays <= 4 ? 'STALE_WARN' : 'STALE_BLOCK'; var degraded = []; if (status === 'STALE_WARN') degraded = ['BRT_RELIABILITY_LOW', 'SAQG_ELIGIBLE_DOWNGRADE']; if (status === 'STALE_BLOCK') degraded = ['BRT_BLOCKED', 'SAQG_BLOCKED', 'ORDER_GENERATION_BLOCKED']; return { data_freshness_status: status, data_age_business_days: ageDays, market_date: marketDateStr, freshness_degraded_gates: degraded, formula_id: 'HARNESS_DATA_FRESHNESS_GATE_V1' }; } // ── B-2: SATELLITE_LIFECYCLE_GATE_V1 ──────────────────────────────────────── // 위성 종목에 WATCH/PILOT/CONFIRMED/REVIEW/EXIT 5단계 라이프사이클을 부여한다. // brt_verdict, composite_verdict, excess_drawdown_pctp, AEW t20_alpha_gate를 조합해 // 현재 상태에서 가장 적절한 단계를 결정론적으로 산출한다. function calcSatelliteLifecycleGate_(holdings, dfMap, aewRows) { var aewMap = {}; (aewRows || []).forEach(function(r) { if (r.ticker) aewMap[r.ticker] = r; }); return holdings.map(function(h) { if (h.position_type === 'core') { return { ticker: h.ticker, name: h.name || '', position_type: 'core', lifecycle_stage: 'CORE_EXEMPT', lifecycle_transition_reason: 'core_position', lifecycle_days_in_stage: null, review_warning: null, formula_id: 'SATELLITE_LIFECYCLE_GATE_V1' }; } var df = dfMap[h.ticker] || {}; var aew = aewMap[h.ticker] || {}; var cv = df.composite_verdict || 'UNKNOWN'; var brt = df.brt_verdict || 'UNKNOWN'; var exDd = typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null; var t20g = aew.t20_alpha_gate || 'NOT_YET'; var t20v = typeof aew.t20_vs_core_pctp === 'number' ? aew.t20_vs_core_pctp : null; var daysEntry = h.entry_date ? calcKrxBizDaysDiff_(h.entry_date) : null; var stage = 'PILOT'; var reason = 'default_pilot'; // ── EXIT 조건 (최우선) ───────────────────────────────────────────────── if (brt === 'BROKEN') { stage = 'EXIT'; reason = 'brt_BROKEN'; } else if (cv === 'CLOSE_POSITION') { stage = 'EXIT'; reason = 'composite_CLOSE_POSITION'; } else if (exDd !== null && exDd >= 15) { stage = 'EXIT'; reason = 'excess_drawdown_15pct'; } else if (t20g === 'T20_ALPHA_FAIL' && t20v !== null && t20v < -10) { stage = 'EXIT'; reason = 'T20_ALPHA_FAIL_severe'; // ── REVIEW 조건 ────────────────────────────────────────────────────── } else if (brt === 'LAGGARD') { stage = 'REVIEW'; reason = 'brt_LAGGARD'; } else if (cv === 'REDUCE_CANDIDATE') { stage = 'REVIEW'; reason = 'composite_REDUCE'; } else if (t20g === 'T20_ALPHA_FAIL') { stage = 'REVIEW'; reason = 'T20_ALPHA_FAIL'; } else if (exDd !== null && exDd >= 8) { stage = 'REVIEW'; reason = 'excess_drawdown_8pct'; // ── CONFIRMED 조건 ───────────────────────────────────────────────── } else if (daysEntry !== null && daysEntry >= 20 && t20g === 'PASS' && (cv === 'PRIME_CANDIDATE' || cv === 'WATCH_CANDIDATE') && (brt === 'LEADER' || brt === 'MARKET')) { stage = 'CONFIRMED'; reason = 't20_pass_market_or_leader'; // ── PILOT 조건 (기본) ─────────────────────────────────────────────── } else if (daysEntry !== null && daysEntry < 20) { stage = 'PILOT'; reason = 'within_20d_of_entry'; } else { stage = 'PILOT'; reason = 'pending_t20_evaluation'; } // 4주 REVIEW 경보 (Direction SLG) var reviewWarn = (stage === 'REVIEW' && daysEntry !== null && daysEntry >= 20) ? '4주_REVIEW_비중50%_감축검토' : null; return { ticker: h.ticker, name: h.name || df.name || '', position_type: h.position_type || 'satellite', lifecycle_stage: stage, lifecycle_transition_reason: reason, lifecycle_days_in_stage: daysEntry, review_warning: reviewWarn, composite_verdict: cv, brt_verdict: brt, excess_drawdown_pctp: exDd, t20_alpha_gate: t20g, formula_id: 'SATELLITE_LIFECYCLE_GATE_V1' }; }); } // ── B-3: CLA_REGIME_EXIT_CONDITION_V1 ─────────────────────────────────────── // CONCENTRATED_LEADER_ADVANCE 국면의 종료 조건을 탐지한다. // 삼성전자(005930) + SK하이닉스(000660)를 대상으로 5개 신호를 평가하고 // 가중치 합산으로 CLA_ACTIVE / CLA_EXIT_WARNING / CLA_EXIT_CONFIRMED를 결정한다. /** * SECULAR_LEADER_AUTO_DETECT_V1 * spec/strategy/semiconductor_concentration_policy.yaml 조건 기반 * 반도체 주도주 자동 감지 → SECULAR_LEADER_RISK_ON 국면 진입 권고. * * 감지 조건 (가중치 합산 ≥ 6 → is_secular_leader=true): * SL1 (w=3): 삼성 또는 하이닉스 RS_Ratio ≥ 1.5 (5일 연속) * SL2 (w=2): 외인+기관 동반순매수 3일 이상 * SL3 (w=2): 반도체 섹터 5일 수익률 KOSPI 대비 +5%p 이상 초과 * SL4 (w=1): 반도체 섹터 5D 거래대금 > 20D 거래대금 × 1.3 * * @param {Object} dfMap — buildDataFeedMap_() 반환값 * @param {string} marketRegime * @param {number} kospiRet5d — KOSPI 5일 수익률 * @return {{ is_secular_leader, score, signals, recommendation, formula_id }} */ function calcSecularLeaderAutoDetect_(dfMap, marketRegime, kospiRet5d) { var SECULAR_TICKERS = ['005930', '000660']; var THRESHOLD = 6; var score = 0; var signals = []; var kospiRet = typeof kospiRet5d === 'number' ? kospiRet5d : 0; // SL1: RS_Ratio ≥ 1.5 — 삼성 또는 하이닉스 var sl1Hit = SECULAR_TICKERS.some(function(tk) { var df = dfMap[tk] || {}; var rsRatio = typeof df.rsRatio === 'number' ? df.rsRatio : (typeof df.rs_ratio === 'number' ? df.rs_ratio : null); return rsRatio !== null && rsRatio >= 1.5; }); if (sl1Hit) { score += 3; signals.push('SL1_RS_RATIO_GTE_1.5(w=3)'); } // SL2: 외인+기관 동반순매수 3일 이상 — 양 종목 중 하나 var sl2Hit = SECULAR_TICKERS.some(function(tk) { var df = dfMap[tk] || {}; var frg = typeof df.frg5d === 'number' ? df.frg5d : -1; var ins = typeof df.inst5d === 'number' ? df.inst5d : -1; return frg > 0 && ins > 0; // 5일 누적 동반순매수 = 3일 이상 추정 }); if (sl2Hit) { score += 2; signals.push('SL2_FRG_INST_CO_BUY(w=2)'); } // SL3: 반도체 섹터 5일 수익률 KOSPI 대비 +5%p 초과 (대표 종목 프록시) var semiRet5d = null; SECULAR_TICKERS.forEach(function(tk) { var df = dfMap[tk] || {}; if (typeof df.ret5d === 'number' && (semiRet5d === null || df.ret5d > semiRet5d)) { semiRet5d = df.ret5d; } }); if (semiRet5d !== null && semiRet5d - kospiRet >= 5.0) { score += 2; signals.push('SL3_SECTOR_OUTPERFORM_5PCT(w=2)'); } // SL4: 반도체 섹터 거래대금 급증 (대표 종목 avgTradeVal5d/20d 프록시) var sl4Hit = SECULAR_TICKERS.some(function(tk) { var df = dfMap[tk] || {}; var val5 = toNumber_(df.avg_trade_val_5d || df.avgTradeVal5d) || 0; var val20 = toNumber_(df.avg_trade_val_20d || df.avgTradeVal20d) || 0; return val5 > 0 && val20 > 0 && val5 > val20 * 1.3; }); if (sl4Hit) { score += 1; signals.push('SL4_TRADE_VALUE_SURGE(w=1)'); } var isSecularLeader = score >= THRESHOLD; var currentRegime = String(marketRegime || '').toUpperCase(); var alreadyActive = currentRegime.indexOf('SECULAR_LEADER') >= 0; // 종료 조건: RS_Ratio < 1.0 3일 or 외인+기관 동반순매도 5일 var exitSignals = []; SECULAR_TICKERS.forEach(function(tk) { var df = dfMap[tk] || {}; var rsRatio = typeof df.rsRatio === 'number' ? df.rsRatio : (typeof df.rs_ratio === 'number' ? df.rs_ratio : null); if (rsRatio !== null && rsRatio < 1.0) exitSignals.push(tk + '_RS_BELOW_1.0'); var frg = typeof df.frg5d === 'number' ? df.frg5d : 0; var ins = typeof df.inst5d === 'number' ? df.inst5d : 0; if (frg < 0 && ins < 0) exitSignals.push(tk + '_CO_SELL'); }); return { is_secular_leader: isSecularLeader, score: score, threshold: THRESHOLD, signals: signals, exit_signals: exitSignals, already_active: alreadyActive, recommendation: isSecularLeader && !alreadyActive ? 'UPGRADE_TO_SECULAR_LEADER_RISK_ON' : (alreadyActive && exitSignals.length >= 2 ? 'EXIT_SECULAR_LEADER' : 'MAINTAIN'), formula_id: 'SECULAR_LEADER_AUTO_DETECT_V1' }; } // --- Source: src/gas_adapter_parts/gdf_03_portfolio_gates.gs --- function calcClaRegimeExitCondition_(dfMap, marketRegime) { var regime = String(marketRegime || '').toUpperCase(); if (regime.indexOf('CONCENTRATED_LEADER') < 0 && regime.indexOf('CLA') < 0) { return { cla_exit_status: 'NOT_APPLICABLE', cla_exit_signals_triggered: [], cla_exit_total_weight: 0, note: 'marketRegime not CLA', formula_id: 'CLA_REGIME_EXIT_CONDITION_V1' }; } var sam = dfMap['005930'] || {}; var hyn = dfMap['000660'] || {}; var signals = []; var w = 0; // S1: RS 약화 — 삼성 또는 하이닉스 rs_verdict = LAGGARD (weight 3) if (sam.rs_verdict === 'LAGGARD' || sam.rs_verdict === 'BROKEN' || hyn.rs_verdict === 'LAGGARD' || hyn.rs_verdict === 'BROKEN') { signals.push('S1_rs_degradation'); w += 3; } // S2: KOSPI 기여도 하락 프록시 — 두 종목 모두 LEADER 아님 (weight 2) if (sam.brt_verdict !== 'LEADER' && hyn.brt_verdict !== 'LEADER' && sam.brt_verdict !== 'UNKNOWN' && hyn.brt_verdict !== 'UNKNOWN') { signals.push('S2_kospi_contribution_drop_proxy'); w += 2; } // S3: 외국인 동반 순매도 — frg5d < 0 두 종목 (weight 2) var samFrgNeg = Number.isFinite(sam.frg5d) && sam.frg5d < 0; var hynFrgNeg = Number.isFinite(hyn.frg5d) && hyn.frg5d < 0; if (samFrgNeg && hynFrgNeg) { signals.push('S3_foreign_flow_reversal'); w += 2; } // S4: 거래 에너지 소진 — volume < avgVolume5d*0.6 두 종목 (weight 1) var samVolLow = Number.isFinite(sam.volume) && Number.isFinite(sam.avgVolume5d) && sam.avgVolume5d > 0 && sam.volume < sam.avgVolume5d * 0.6; var hynVolLow = Number.isFinite(hyn.volume) && Number.isFinite(hyn.avgVolume5d) && hyn.avgVolume5d > 0 && hyn.volume < hyn.avgVolume5d * 0.6; if (samVolLow && hynVolLow) { signals.push('S4_volume_exhaustion'); w += 1; } // S5: BRT 약화 — 두 종목 모두 brt_verdict = MARKET (LEADER에서 하락) (weight 2) if (sam.brt_verdict === 'MARKET' && hyn.brt_verdict === 'MARKET') { signals.push('S5_brt_degradation_from_leader'); w += 2; } var status = w >= 5 ? 'CLA_EXIT_CONFIRMED' : w >= 3 ? 'CLA_EXIT_WARNING' : 'CLA_ACTIVE'; return { cla_exit_status: status, cla_exit_signals_triggered: signals, cla_exit_total_weight: w, samsung_rs: sam.rs_verdict || 'UNKNOWN', samsung_brt: sam.brt_verdict || 'UNKNOWN', hynix_rs: hyn.rs_verdict || 'UNKNOWN', hynix_brt: hyn.brt_verdict || 'UNKNOWN', formula_id: 'CLA_REGIME_EXIT_CONDITION_V1' }; } // ── B-4: PORTFOLIO_CORRELATION_GATE_V1 ────────────────────────────────────── // 위성 포지션 간 ret20d 기반 프록시 상관관계를 산출하고, // 상관관계 조정 실질 포트폴리오 베타(satellite_cluster_beta)를 계산한다. // 20일 수익률 배열이 없으므로 방향 일치도로 상관관계를 추정(PROXY). function calcPortfolioCorrelationGate_(holdings, dfMap, totalAsset, kospiRet5d) { var satHoldings = holdings.filter(function(h) { return h.position_type !== 'core'; }); if (satHoldings.length === 0) { return { satellite_cluster_beta: 0, effective_portfolio_beta: 0, high_corr_pairs: [], correlation_gate_status: 'CORRELATION_PASS', note: 'no_satellite_holdings', formula_id: 'PORTFOLIO_CORRELATION_GATE_V1' }; } // 각 위성의 beta_proxy 및 weight_pct 계산 var satItems = satHoldings.map(function(h) { var df = dfMap[h.ticker] || {}; var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null; var ret20d = typeof df.ret20d === 'number' ? df.ret20d : null; // beta_proxy: ret5d / kospiRet5d if both available, else 1.0 var beta = 1.0; if (ret5d !== null && typeof kospiRet5d === 'number' && Math.abs(kospiRet5d) > 0.3) { beta = Math.max(0, Math.min(3.0, ret5d / kospiRet5d)); } // weight_pct: from h.weightPct (set by calcPortfolioBetaGate pipeline) or derived var mv = typeof h.market_value === 'number' ? h.market_value : 0; var wPct = (totalAsset > 0 && mv > 0) ? mv / totalAsset * 100 : 0; if (typeof h.weightPct === 'number' && h.weightPct > 0) wPct = h.weightPct; return { ticker: h.ticker, name: h.name || df.name || '', beta: round2_(beta), wPct: round2_(wPct), w: wPct / 100, // fraction ret20d: ret20d, rs: df.rs_verdict || 'UNKNOWN', brt: df.brt_verdict || 'UNKNOWN' }; }); // 프록시 상관관계: ret20d 방향 일치 + BRT 동방향 기반 function proxyCorrPair(a, b) { if (a.ret20d !== null && b.ret20d !== null) { var sameDir = (a.ret20d >= 0) === (b.ret20d >= 0); var bothNeg = a.ret20d < 0 && b.ret20d < 0; if (bothNeg) return 0.80; // 동반 하락 — 가장 강한 동조 신호 if (sameDir) return 0.65; // 같은 방향 수익 return 0.15; // 반대 방향 — 분산 효과 } // 데이터 없으면 동업종 같은 BRT 상태이면 보수적으로 중간값 if (a.brt === b.brt && a.brt !== 'UNKNOWN') return 0.60; return 0.35; } var highCorrPairs = []; var totalSatW = satItems.reduce(function(s, x) { return s + x.w; }, 0); if (totalSatW <= 0) totalSatW = 1; // 정규화된 위성 비중 (위성 합산=1) var satNorm = satItems.map(function(x) { return Object.assign({}, x, { wn: x.w / totalSatW }); }); // 상관관계 행렬 및 satellite_cluster_beta (quadratic form → sqrt) var quadForm = 0; for (var i = 0; i < satNorm.length; i++) { for (var j = 0; j < satNorm.length; j++) { var corr = i === j ? 1.0 : proxyCorrPair(satNorm[i], satNorm[j]); quadForm += satNorm[i].wn * satNorm[j].wn * satNorm[i].beta * satNorm[j].beta * corr; if (i < j && corr >= 0.70) { highCorrPairs.push({ ticker1: satNorm[i].ticker, ticker2: satNorm[j].ticker, corr_proxy: round2_(corr), both_negative: satNorm[i].ret20d !== null && satNorm[j].ret20d !== null && satNorm[i].ret20d < 0 && satNorm[j].ret20d < 0 }); } } } var satClusterBeta = round2_(Math.sqrt(Math.max(0, quadForm))); // 코어 단순 가중 베타 var coreHoldings = holdings.filter(function(h) { return h.position_type === 'core'; }); var coreWBetaSum = 0, coreWSum = 0; coreHoldings.forEach(function(h) { var df = dfMap[h.ticker] || {}; var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null; var beta = 1.0; if (ret5d !== null && typeof kospiRet5d === 'number' && Math.abs(kospiRet5d) > 0.3) { beta = Math.max(0, Math.min(3.0, ret5d / kospiRet5d)); } var mv = typeof h.market_value === 'number' ? h.market_value : 0; var w = (totalAsset > 0 && mv > 0) ? mv / totalAsset : 0; if (typeof h.weightPct === 'number') w = h.weightPct / 100; coreWBetaSum += w * beta; coreWSum += w; }); var coreBeta = coreWSum > 0 ? round2_(coreWBetaSum / coreWSum * (coreWSum / 1.0)) : 0; // effective = core_weighted_contribution + satellite_cluster_beta * sat_weight_fraction var effectiveBeta = round2_(coreBeta + satClusterBeta * totalSatW); // 게이트 판정 var gateStatus = (satClusterBeta > 1.5 && highCorrPairs.length >= 2) ? 'CORRELATION_BLOCK' : (satClusterBeta > 1.2 || highCorrPairs.length >= 1) ? 'CORRELATION_WARN' : 'CORRELATION_PASS'; return { satellite_cluster_beta: satClusterBeta, effective_portfolio_beta: effectiveBeta, high_corr_pairs: highCorrPairs, correlation_gate_status: gateStatus, satellite_count: satHoldings.length, evaluation_method: 'PROXY_FROM_RET20D_DIRECTION', formula_id: 'PORTFOLIO_CORRELATION_GATE_V1' }; } function pickReferenceBenchmarkRet5d_(df, fallbackKospiRet5d) { var keys = [ ['nasdaq_ret5d', 'NASDAQ'], ['nasdaqRet5d', 'NASDAQ'], ['kosdaq_ret5d', 'KOSDAQ'], ['kosdaqRet5d', 'KOSDAQ'], ['benchmark_ret5d', 'BENCHMARK'], ['benchmarkRet5d', 'BENCHMARK'], ['kospi_ret5d', 'KOSPI'], ['kospiRet5d', 'KOSPI'] ]; for (var i = 0; i < keys.length; i++) { var key = keys[i][0]; if (typeof (df || {})[key] === 'number') { return { benchmark_ret5d: df[key], benchmark_used: keys[i][1] }; } } if (typeof fallbackKospiRet5d === 'number') { return { benchmark_ret5d: fallbackKospiRet5d, benchmark_used: 'KOSPI' }; } return { benchmark_ret5d: null, benchmark_used: 'UNKNOWN' }; } function calcIndexRelativeHealthGate_(h, df, kospiRet5d) { var stockRet5d = typeof df.ret5d === 'number' ? df.ret5d : null; var bench = pickReferenceBenchmarkRet5d_(df, kospiRet5d); var benchmarkRet5d = bench.benchmark_ret5d; var benchmarkUsed = bench.benchmark_used; var reasons = []; var state = 'INSUFFICIENT_DATA'; var directionMatch = null; var retGapPctp = null; var magnitudeExcessPctp = null; if (stockRet5d !== null && benchmarkRet5d !== null) { directionMatch = (stockRet5d >= 0) === (benchmarkRet5d >= 0); retGapPctp = round2_(stockRet5d - benchmarkRet5d); magnitudeExcessPctp = round2_(Math.max(0, Math.abs(stockRet5d) - Math.abs(benchmarkRet5d) - 2)); var benchmarkAbs = Math.abs(benchmarkRet5d); var stockAbs = Math.abs(stockRet5d); if (!directionMatch && benchmarkAbs >= 1) { state = 'DECOUPLED'; reasons.push('direction_mismatch'); } else if (stockRet5d < benchmarkRet5d - 3) { state = 'UNDERPERFORMING'; reasons.push('underperform_vs_benchmark'); } else if (magnitudeExcessPctp >= 3 || (stockAbs >= benchmarkAbs + 4 && benchmarkAbs >= 1)) { state = 'OVER_EXTENDED'; reasons.push('magnitude_excess'); } else { state = 'HEALTHY'; } } else { reasons.push('insufficient_benchmark_data'); } return { ticker: h.ticker, name: h.name || df.name || '', benchmark_used: benchmarkUsed, stock_ret5d: stockRet5d, benchmark_ret5d: benchmarkRet5d, ret_gap_pctp: retGapPctp, magnitude_excess_pctp: magnitudeExcessPctp, direction_match: directionMatch, relative_health_state: state, reason_codes: reasons, formula_id: 'INDEX_RELATIVE_HEALTH_GATE_V1' }; } /** * O3: PORTFOLIO_DRAWDOWN_GATE_V1 * 총자산 역대 고점(settings.portfolio_peak_krw) 대비 낙폭을 산출한다. * -15% → DRAWDOWN_CAUTION, -20% → DRAWDOWN_FORCE_RISK_OFF. * 현재 자산이 고점 초과 시 settings에 새 고점을 자동 기록. * @param {number} totalAsset * @param {Object} ss — Spreadsheet * @param {Object} settings — readSettings_() 반환값 * @return {{ gate, drawdown_pct, peak_krw, current_krw }} */ function calcPortfolioDrawdownGate_(totalAsset, ss, settings) { var peakKrw = toNumber_(settings['portfolio_peak_krw'] || 0); if (totalAsset > 0 && totalAsset > peakKrw) { peakKrw = totalAsset; writeSettingValue_(ss, 'portfolio_peak_krw', totalAsset); } if (peakKrw <= 0 || totalAsset <= 0) { return { gate: 'INSUFFICIENT_DATA', drawdown_pct: null, peak_krw: peakKrw || null, current_krw: Math.round(totalAsset || 0), formula_id: 'PORTFOLIO_DRAWDOWN_GATE_V1' }; } var drawdownPct = round2_((peakKrw - totalAsset) / peakKrw * 100); drawdownPct = Math.max(0, drawdownPct); var gate = drawdownPct >= 20 ? 'DRAWDOWN_FORCE_RISK_OFF' : drawdownPct >= 15 ? 'DRAWDOWN_CAUTION' : 'PASS'; return { gate: gate, drawdown_pct: drawdownPct, peak_krw: Math.round(peakKrw), current_krw: Math.round(totalAsset), formula_id: 'PORTFOLIO_DRAWDOWN_GATE_V1' }; } /** * O4: WIN_LOSS_STREAK_GUARD_V1 * 최근 30거래 승률이 임계값 이하로 하락하면 신규 매수 비중을 축소한다. * M1(연속 손절 횟수)과 독립적인 전체 승률 축 방어층. * EDGE_CRITICAL(<30%): scale=0.25, EDGE_DEGRADED(<40%): scale=0.50, * EDGE_WEAK(<45%): scale=0.75, EDGE_OK(>=45%): scale=1.0 * @param {Object} performance — readPerformanceSheet_() 반환값 * @return {{ state, win_rate_pct, trades_used, buy_scale }} */ function calcWinLossStreakGuard_(performance) { var winRate = (performance && Number.isFinite(performance.win_rate_30)) ? performance.win_rate_30 : null; var tradesUsed = (performance && Number.isFinite(performance.trades_used)) ? performance.trades_used : 0; if (winRate === null || tradesUsed < 10) { return { state: 'INSUFFICIENT_HISTORY', win_rate_pct: winRate !== null ? round2_(winRate * 100) : null, trades_used: tradesUsed, buy_scale: 1.0, formula_id: 'WIN_LOSS_STREAK_GUARD_V1' }; } var state, scale; if (winRate < 0.30) { state = 'EDGE_CRITICAL'; scale = 0.25; } else if (winRate < 0.40) { state = 'EDGE_DEGRADED'; scale = 0.50; } else if (winRate < 0.45) { state = 'EDGE_WEAK'; scale = 0.75; } else { state = 'EDGE_OK'; scale = 1.0; } return { state: state, win_rate_pct: round2_(winRate * 100), trades_used: tradesUsed, buy_scale: scale, formula_id: 'WIN_LOSS_STREAK_GUARD_V1' }; } /** * O5: POSITION_COUNT_LIMIT_V1 * 동시 보유 종목 수가 국면별 상한(NEUTRAL:8, RISK_OFF:6)을 초과하면 POSITION_COUNT_BLOCK. * 과다 분산으로 인한 집중 모니터링 불가 및 Total Heat 과소 추정 방지. * @param {Array} holdings * @param {string} marketRegime * @return {{ gate_status, position_count, max_count, excess_count }} */ function calcPositionCountLimit_(holdings, marketRegime) { var r = String(marketRegime || '').toUpperCase(); var isRiskOff = r.indexOf('EVENT_SHOCK') >= 0 || r.indexOf('RISK_OFF') >= 0; var maxCount = isRiskOff ? 6 : 8; var count = holdings.length; return { gate_status: count > maxCount ? 'POSITION_COUNT_BLOCK' : 'PASS', position_count: count, max_count: maxCount, excess_count: Math.max(0, count - maxCount), formula_id: 'POSITION_COUNT_LIMIT_V1' }; } /** * M1: DRAWDOWN_GUARD_V1 * 연속 손절 횟수에 따라 신규 매수 비중을 자동 축소한다. * bayesian_multiplier=0(>=5회 연속 손실) 위에 추가 방어층으로 작동. * @param {Object} performance — readPerformanceSheet_() 반환값 * @return {{ state, buy_scale, consecutive_losses, reason }} */ function calcDrawdownGuard_(performance) { var consLoss = (performance && Number.isFinite(performance.consecutive_losses)) ? performance.consecutive_losses : 0; var state, scale, reason; if (consLoss >= 5) { state = 'NO_BUY'; scale = 0.0; reason = 'consecutive_losses>=5_no_bet'; } else if (consLoss >= 3) { state = 'REDUCE_BUY'; scale = 0.5; reason = 'consecutive_losses>=3_reduce_50pct'; } else if (consLoss >= 2) { state = 'CAUTION_BUY'; scale = 0.75; reason = 'consecutive_losses>=2_reduce_25pct'; } else { state = 'NORMAL'; scale = 1.0; reason = 'no_drawdown'; } return { state: state, buy_scale: scale, consecutive_losses: consLoss, reason: reason }; } /** * M2: PORTFOLIO_BETA_GATE_V1 * 보유 종목 가중평균 베타를 산출하고 국면별 상한과 비교한다. * beta_proxy = ret5d / kospiRet5d (단, kospiRet5d <= 0이면 1.0 사용) * @param {Array} holdings — parseAccountSnapshot_ 반환 holdings 배열 * @param {Object} dfMap — buildDataFeedMap_() 반환값 * @param {number} kospiRet5d * @param {string} marketRegime * @return {{ portfolio_beta, gate_status, beta_limit, per_holding_betas }} */ function calcPortfolioBetaGate_(holdings, dfMap, kospiRet5d, marketRegime) { var BETA_LIMITS = (function(r) { var rU = String(r || '').toUpperCase(); if (rU.indexOf('EVENT_SHOCK') >= 0) return { over: 0.7, warn: 0.5 }; if (rU.indexOf('RISK_OFF') >= 0) return { over: 0.8, warn: 0.6 }; if (rU.indexOf('SECULAR_LEADER') >= 0 && rU.indexOf('RISK_ON') >= 0) return { over: 1.5, warn: 1.2 }; if (rU.indexOf('RISK_ON') >= 0) return { over: 1.3, warn: 1.0 }; return { over: 1.0, warn: 0.8 }; // NEUTRAL })(marketRegime); var totalWeight = 0; var weightedBetaSum = 0; var perHolding = []; holdings.forEach(function(h) { var df = dfMap[h.ticker] || {}; var w = (typeof h.weightPct === 'number' && h.weightPct > 0) ? h.weightPct : 0; var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null; var betaProxy = 1.0; if (ret5d !== null && typeof kospiRet5d === 'number' && kospiRet5d > 0.5) { betaProxy = Math.max(0, Math.min(3.0, ret5d / kospiRet5d)); } else if (ret5d !== null && typeof kospiRet5d === 'number' && kospiRet5d < -0.5) { betaProxy = Math.max(0, Math.min(3.0, ret5d / kospiRet5d)); } totalWeight += w; weightedBetaSum += w * betaProxy; perHolding.push({ ticker: h.ticker, name: h.name || '', weight_pct: w, beta_proxy: round2_(betaProxy), ret5d: ret5d }); }); var portfolioBeta = totalWeight > 0 ? round2_(weightedBetaSum / totalWeight) : null; var gateStatus = portfolioBeta === null ? 'INSUFFICIENT_DATA' : portfolioBeta > BETA_LIMITS.over ? 'OVER_BETA' : portfolioBeta > BETA_LIMITS.warn ? 'WARN_BETA' : 'PASS'; return { portfolio_beta: portfolioBeta, gate_status: gateStatus, beta_limit_over: BETA_LIMITS.over, beta_limit_warn: BETA_LIMITS.warn, regime_applied: marketRegime || 'UNKNOWN', per_holding_betas: perHolding }; } /** * M5: SECTOR_CONCENTRATION_LIMIT_V1 * 단일 섹터 ≥40% 시 BLOCK_SECTOR, 상위2 합산 ≥65% 시 HALVE_SECTOR. * @param {Array} holdings * @param {string} marketRegime * @return {{ gate_status, by_sector, sector_concentration_json }} */ function calcSectorConcentrationGate_(holdings, marketRegime) { var sectorWeight = {}; holdings.forEach(function(h) { var sec = TICKER_SECTOR_MAP[h.ticker] || 'UNKNOWN'; var w = (typeof h.weightPct === 'number' && h.weightPct > 0) ? h.weightPct : 0; sectorWeight[sec] = (sectorWeight[sec] || 0) + w; }); var sectors = Object.keys(sectorWeight).map(function(s) { return { sector: s, weight_pct: round2_(sectorWeight[s]) }; }); sectors.sort(function(a, b) { return b.weight_pct - a.weight_pct; }); // 임계값 — RISK_OFF/EVENT_SHOCK에서는 더 엄격 var rU = String(marketRegime || '').toUpperCase(); var blockThresh = (rU.indexOf('EVENT_SHOCK') >= 0 || rU.indexOf('RISK_OFF') >= 0) ? 35 : 40; var halveThresh = (rU.indexOf('EVENT_SHOCK') >= 0 || rU.indexOf('RISK_OFF') >= 0) ? 55 : 65; var top2Sum = sectors.slice(0, 2).reduce(function(s, r) { return s + r.weight_pct; }, 0); var overallGate = 'PASS'; sectors.forEach(function(r) { if (r.weight_pct >= blockThresh) r.gate = 'BLOCK_NEW_BUY_THIS_SECTOR'; else if (r.weight_pct >= halveThresh * 0.6) r.gate = 'WARN_CONCENTRATION'; else r.gate = 'PASS'; if (r.gate === 'BLOCK_NEW_BUY_THIS_SECTOR') overallGate = 'BLOCK_SECTOR'; }); if (overallGate === 'PASS' && top2Sum >= halveThresh) overallGate = 'WARN_TOP2'; return { gate_status: overallGate, top2_weight_sum: round2_(top2Sum), block_threshold: blockThresh, by_sector: sectors }; } /** * M4: EVENT_RISK_HOLD_GATE_V1 * DART 리스크 및 이벤트 홀드 기간 중인 종목에 신규 매수 홀드 게이트 적용. * df.eventHoldDays (Event_Hold_Days 컬럼) <= 5이면 EVENT_HOLD. * 컬럼 없으면 df.dartRiskStatus !== 'OK' 를 대체 기준으로 사용. * @param {Array} holdings * @param {Object} dfMap * @return {Array} event_risk rows */ function calcEventRiskHoldGate_(holdings, dfMap) { return holdings.map(function(h) { var df = dfMap[h.ticker] || {}; var holdDays = typeof df.eventHoldDays === 'number' ? df.eventHoldDays : null; var dartRisk = (typeof df.dartRiskStatus === 'string' && df.dartRiskStatus !== 'OK') || String(df.dartRisk || '').toUpperCase() === 'Y'; var gateStatus, reason; if (holdDays !== null && holdDays >= 0 && holdDays <= 5) { gateStatus = 'EVENT_HOLD'; reason = 'event_hold_days_le5:' + holdDays; } else if (dartRisk) { gateStatus = 'EVENT_HOLD'; reason = 'dart_risk'; } else { gateStatus = 'PASS'; reason = 'no_event_risk'; } return { ticker: h.ticker, name: h.name || '', event_hold_gate: gateStatus, event_hold_days: holdDays, dart_risk: dartRisk, reason: reason }; }); } /** * M3: TP_QUANTITY_LADDER_V1 * prices_json의 TP1/TP2/TP3 가격 유효성 기반으로 분할 익절 수량을 자동 산출. * 계좌 snapshot에 수동 입력(tp1_qty>0)이 있으면 우선 사용. * @param {Array} holdings * @param {Object} h4 — calcPrices_() 반환값 (.prices 배열) * @return {Array} tp_quantity_ladder rows */ function calcTpQuantityLadder_(holdings, h4) { // THIN_ADAPTER: [sizing/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_position_size var priceMap = {}; (h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; }); return holdings.map(function(h) { var priceRow = priceMap[h.ticker] || {}; var qty = h.holdingQty || 0; // 수동 입력 tp_qty 있으면 우선 사용 var tp1Manual = typeof priceRow.tp1_qty === 'number' && priceRow.tp1_qty > 0 ? priceRow.tp1_qty : 0; var tp2Manual = typeof priceRow.tp2_qty === 'number' && priceRow.tp2_qty > 0 ? priceRow.tp2_qty : 0; var tp3Manual = typeof priceRow.tp3_qty === 'number' && priceRow.tp3_qty > 0 ? priceRow.tp3_qty : 0; var tp1Q, tp2Q, tp3Q, source; if (tp1Manual > 0 && tp2Manual > 0) { tp1Q = tp1Manual; tp2Q = tp2Manual; tp3Q = tp3Manual > 0 ? tp3Manual : Math.max(0, qty - tp1Q - tp2Q); source = 'MANUAL'; } else if (qty > 0) { tp1Q = Math.floor(qty * 0.33); tp2Q = Math.floor(qty * 0.33); tp3Q = Math.max(0, qty - tp1Q - tp2Q); source = 'AUTO_33PCT'; } else { tp1Q = tp2Q = tp3Q = 0; source = 'NO_HOLDING'; } return { ticker: h.ticker, name: h.name || '', holding_qty: qty, tp1_price: priceRow.tp1_price || null, tp1_state: priceRow.tp1_state || null, tp1_qty: tp1Q, tp2_price: priceRow.tp2_price || null, tp2_state: priceRow.tp2_state || null, tp2_qty: tp2Q, tp3_qty: tp3Q, qty_source: source, formula_id: 'TP_QUANTITY_LADDER_V1' }; }); } function calcCashFloor_(mrsScore, settlementCashPct) { var minPct = 10; var regime = 'overheated_or_event_week'; for (var k = 0; k < CASH_FLOOR_BY_MRS.length; k++) { if (mrsScore <= CASH_FLOOR_BY_MRS[k].maxMrs) { minPct = CASH_FLOOR_BY_MRS[k].minPct; regime = CASH_FLOOR_BY_MRS[k].label; break; } } var status = settlementCashPct >= minPct ? 'PASS' : settlementCashPct >= minPct * 0.7 ? 'TRIM_REQUIRED' : 'HARD_BLOCK'; return { minPct: minPct, regime: regime, status: status }; } function calcActions_(intradayLock, heatGate, cashFloorStatus) { var blocked = []; var allowed = ['TRIM_25', 'TRIM_33', 'TRIM_50', 'HOLD', 'WATCH']; if (intradayLock) { blocked.push('EXIT_100', 'SELL_FULL', 'EXIT_FULL', 'BUY', 'STAGED_BUY'); } else { allowed.push('EXIT_100', 'SELL_FULL'); if (heatGate === 'BLOCK_NEW_BUY' || cashFloorStatus !== 'PASS') { blocked.push('BUY', 'STAGED_BUY'); } else { allowed.push('BUY', 'STAGED_BUY'); } } return { allowed: allowed, blocked: blocked }; } // ── H2: 매도후보 순위 하네스 ───────────────────────────────────────────────── /** * calcSellPriority_ * 보유 종목별 Sell_Priority_Score(0~100 clamp) + tier 배정, tier ASC / score DESC 정렬 * spec/risk/portfolio_exposure.yaml:candidate_scoring */ function calcSellPriority_(holdings, dfMap, h1) { var candidates = []; holdings.forEach(function(h) { var df = dfMap[h.ticker] || {}; var raw = scoreSellCandidate_(h, df, h1); // 코어 주도주 tier=9 고정 var isCoreLeader = indexOfArr_(CORE_TICKERS, h.ticker) >= 0; var tier = isCoreLeader ? 9 : raw.tier; var clamped = Math.min(Math.max(raw.rawScore, 0), 100); candidates.push({ rank: 0, // 정렬 후 부여 ticker: h.ticker, name: h.name || df.name || '', account: h.account || '', tier: tier, score: clamped, raw_score: raw.rawScore, rebound_holdback_score: raw.reboundHoldback || 0, rebound_holdback_reason: raw.reboundReason || '', cash_preserve_style: raw.cashPreserveStyle || '', cash_preserve_ratio: raw.cashPreserveRatio || 0, cash_preserve_reason: raw.cashPreserveReason || '', trim_style: isCoreLeader && df.close > 0 && df.ma20 > 0 && df.close >= df.ma20 ? 'CORE_LAST' : (raw.reboundHoldback || 0) >= 18 ? 'STEP_25' : (raw.reboundHoldback || 0) >= 10 ? 'STEP_33' : 'STEP_50', clamp_applied: raw.rawScore !== clamped, clamp_label: raw.rawScore !== clamped ? ('[CLAMP 발동: raw=' + raw.rawScore + ' → ' + clamped + ']') : '', reason: isCoreLeader ? '코어주도주보호(tier=9 고정)' : raw.reason, stop_breach: h.stopBreach, weight_pct: h.weightPct, final_action: df.finalAction || '' }); }); // tier ASC, score DESC 정렬 candidates.sort(function(a, b) { if (a.tier !== b.tier) return a.tier - b.tier; return b.score - a.score; }); candidates.forEach(function(c, idx) { c.rank = idx + 1; }); return { candidates: candidates, lock: true }; } /** * scoreSellCandidate_ * 단일 종목 원시점수 + tier 계산 * spec/risk/portfolio_exposure.yaml:candidate_scoring.components */ function scoreSellCandidate_(h, df, h1) { // THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/inject_computed_harness.py:check_sanity var pts = 0; var reasons = []; var tier = 7; // 기본: 단순 수익실현 var reboundHoldback = calcReboundHoldbackScore_({ close: h.close, ma20: df.ma20, ma60: df.ma60, ma20Slope: df.ma20Slope, rsi14: df.rsi14, bbPosition: df.bbPosition, flowCredit: df.flowCredit, leaderTotal: df.leaderTotal, leaderGate: df.leaderGate, bandStatus: df.bandStatus, profitPct: df.profitPct, isCoreLeader: indexOfArr_(CORE_TICKERS, h.ticker) >= 0, }); // ── 1. hard_precedence ──────────────────────────────────────────────────── if (h.stopBreach) { pts += SP.HARD_STOP_BREACH; tier = Math.min(tier, 2); reasons.push('stop_breach(' + SP.HARD_STOP_BREACH + ')'); } else if (h1.cashFloorStatus === 'TRIM_REQUIRED' || h1.cashFloorStatus === 'HARD_BLOCK') { pts += SP.CASH_FLOOR_TRIM; tier = Math.min(tier, 3); reasons.push('cash_floor_trim(' + SP.CASH_FLOOR_TRIM + ')'); } else if (df.isDuplicateEtf) { pts += SP.DUPLICATE_ETF; tier = Math.min(tier, 4); reasons.push('duplicate_etf(' + SP.DUPLICATE_ETF + ')'); } else { var fa = (df.finalAction || '').toUpperCase(); if (fa.indexOf('TRIM') >= 0 || fa.indexOf('ROTATE') >= 0 || fa.indexOf('SELL') >= 0 || fa.indexOf('EXIT') >= 0) { pts += SP.HOLDING_TRIM_ROTATE; tier = Math.min(tier, 5); reasons.push('holding_trim(' + SP.HOLDING_TRIM_ROTATE + ')'); } else { var profitLockBase = SP["TAKE_PROFIT_BASE"]; pts += profitLockBase; tier = Math.min(tier, 6); reasons.push('profit_lock_base(' + profitLockBase + ')'); } } // ── 2. duplicate_exposure_points ───────────────────────────────────────── if (df.isDuplicateEtf) { pts += SP.DUP_SAME_SECTOR; reasons.push('dup_sector_etf(' + SP.DUP_SAME_SECTOR + ')'); } // ── 3. cash_relief_points ───────────────────────────────────────────────── if (h1.totalAsset > 0 && h.marketValue > 0) { var reliefPct = h.marketValue / h1.totalAsset * 100; if (reliefPct >= 3) { pts += SP.CASH_RELIEF_GE3; reasons.push('cash_relief>=3%(' + SP.CASH_RELIEF_GE3 + ')'); } else if (reliefPct >= 1) { pts += SP.CASH_RELIEF_1_3; reasons.push('cash_relief1~3%(' + SP.CASH_RELIEF_1_3 + ')'); } else { pts += SP.CASH_RELIEF_LT1; reasons.push('cash_relief<1%(' + SP.CASH_RELIEF_LT1 + ')'); } } // ── 4. weakness_points ──────────────────────────────────────────────────── var rw = df.rwPartial || 0; if (rw >= 4) { pts += SP.RW_GE4; reasons.push('RW>=' + rw + '(' + SP.RW_GE4 + ')'); } else if (rw === 3) { pts += SP.RW_3; reasons.push('RW=3(' + SP.RW_3 + ')'); } else if (rw === 2) { pts += SP.RW_2; reasons.push('RW=2(' + SP.RW_2 + ')'); } var flowOk = (df.flowOk || '').toUpperCase(); var flowCr = df.flowCredit; if ((typeof flowCr === 'number' && flowCr < 0.5) || flowOk === 'N' || flowOk === 'FALSE' || flowOk === '0') { pts += SP.FLOW_NEGATIVE; reasons.push('flow_neg(' + SP.FLOW_NEGATIVE + ')'); } if (h.close > 0 && df.ma20 > 0 && h.close < df.ma20) { pts += SP.BELOW_MA20; reasons.push('below_MA20(' + SP.BELOW_MA20 + ')'); } // ── 5. overweight_points ────────────────────────────────────────────────── if (df.weightTargetPct > 0 && h.weightPct > 0) { var overPct = h.weightPct - df.weightTargetPct; if (overPct >= 5) { pts += SP.OVERWEIGHT_5P; reasons.push('overweight>5p(' + SP.OVERWEIGHT_5P + ')'); } else if (overPct >= 2) { pts += SP.OVERWEIGHT_2P; reasons.push('overweight>2p(' + SP.OVERWEIGHT_2P + ')'); } } // ── 6. liquidity_points ─────────────────────────────────────────────────── var atv = df.avgTradeVal5d || 0; if (atv >= 1000) { // 10억원 이상 (단위: 백만원) pts += SP.LIQUIDITY_OK; reasons.push('liq_ok(' + SP.LIQUIDITY_OK + ')'); } else if (atv > 0 && atv < 100) { // 1억원 미만 pts += SP.LIQUIDITY_LOW; reasons.push('liq_low(' + SP.LIQUIDITY_LOW + ')'); } // ── 7. tax_penalty_points (미확인 기본) ──────────────────────────────────── pts -= SP.TAX_UNKNOWN; reasons.push('tax_unknown(-' + SP.TAX_UNKNOWN + ')'); // ── 8. core_quality_protection_points (감점) ────────────────────────────── if (indexOfArr_(CORE_TICKERS, h.ticker) >= 0) { pts -= SP.CORE_LEADER; reasons.push('core_leader(-' + SP.CORE_LEADER + ')'); } else if ((df.grade || '').toUpperCase() === 'A') { pts -= SP.A_GRADE_CORE; reasons.push('A_grade(-' + SP.A_GRADE_CORE + ')'); } if (reboundHoldback.score > 0) { pts -= reboundHoldback.score; reasons.push('rebound_holdback(-' + reboundHoldback.score + (reboundHoldback.reasons ? ' [' + reboundHoldback.reasons + ']' : '') + ')'); } var cashPreservePlan = calcCashPreservationPlan_({ sellAction: h.finalAction || df.finalAction || '', cashFloorStatus: h1.cashFloorStatus || '', regime: h1.regime || df.regime || '', isCoreLeader: indexOfArr_(CORE_TICKERS, h.ticker) >= 0, isEtf: !!df.isDuplicateEtf, liquidityStatus: String(df.liquidityStatus || df.Liquidity_Status || ''), spreadStatus: String(df.spreadStatus || df.Spread_Status || ''), accountType: String(h.account_type || h.accountType || ''), profitPct: h.profitPct, rwPartial: rw, reboundHoldbackScore: reboundHoldback.score, }); if (cashPreservePlan.protection_bonus > 0) { pts -= cashPreservePlan.protection_bonus; reasons.push('cash_preserve(-' + cashPreservePlan.protection_bonus + (cashPreservePlan.reasons ? ' [' + cashPreservePlan.reasons + ']' : '') + ')'); } return { rawScore: Math.round(Math.min(100, Math.max(0, pts))), tier: tier, reason: reasons.join(', '), reboundHoldback: reboundHoldback.score, reboundReason: reboundHoldback.reasons, cashPreserveStyle: cashPreservePlan.style, cashPreserveRatio: cashPreservePlan.recommended_ratio, cashPreserveReason: cashPreservePlan.reasons, }; } // ── H3: 수량 하네스 ────────────────────────────────────────────────────────── /** * calcQuantities_ * Sell_Qty = floor(Sell_Ratio_Pct/100 × holding_quantity) — CAPTURE_READ_OK만 * Buy_Qty: POSITION_SIZE_V1 atr_qty·cash_limit_qty 중간값 산출 */ function calcQuantities_(holdings, dfMap, totalAsset, buyPowerKrw, h1) { var sellQty = []; var buyQtyInputs = []; var perfMult = Number.isFinite(h1.performanceMultiplier) ? h1.performanceMultiplier : 0.5; var perfBias = h1.performanceBuyBias || calcPerformanceBuyBias_({ bayesian_multiplier: perfMult }); holdings.forEach(function(h) { var df = dfMap[h.ticker] || {}; var sellRatio = df.sellRatioPct || 0; var fa = (df.finalAction || '').toUpperCase(); var hasSellSignal = fa === 'SELL_READY' || fa === 'EXIT_100' || fa === 'EXIT_SIGNAL' || fa === 'EXIT_REVIEW' || fa.indexOf('TRIM') >= 0 || fa === 'TRAILING_STOP_BREACH'; var sellQtyValue; // ── Sell_Qty (M3: 선행 계산된 Sell_Qty 컬럼 우선 사용) ─────────────── if (h.holdingQty > 0 && hasSellSignal) { if (df.sellQty > 0) { // 데이터 피드에 이미 계산된 정수 수량 사용 (CAPTURE_READ_OK 기반) sellQtyValue = Math.floor(df.sellQty); } else if (sellRatio > 0) { sellQtyValue = Math.floor(sellRatio / 100 * h.holdingQty); } else { sellQtyValue = 'CAPTURE_REQUIRED'; // 매도신호 있으나 수량 산출 불가 } } else if (h.holdingQty > 0) { sellQtyValue = null; // 매도신호 없음 — CAPTURE_REQUIRED 오남용 방지 } else { sellQtyValue = 'NO_HOLDING'; } sellQty.push({ ticker: h.ticker, account: h.account || '', name: h.name || df.name || '', holding_qty: h.holdingQty || 0, sell_ratio_pct: sellRatio || 0, sell_qty: sellQtyValue }); // ── Buy_Qty (BUY 후보이고 gates 통과 시만 산출) ─────────────────────── var fa = (df.finalAction || '').toUpperCase(); var isBuyCandidate = fa.indexOf('BUY') >= 0 || fa.indexOf('STAGED') >= 0; var buyBlocked = h1.heatGate === 'BLOCK_NEW_BUY' || h1.cashFloorStatus !== 'PASS' || h1.intradayLock || perfBias.entry_block; if (!isBuyCandidate || buyBlocked) return; var atr20 = df.atr20 || 0; var close = h.close || df.close || 0; if (atr20 > 0 && close > 0 && totalAsset > 0) { var riskKrw = totalAsset * BASE_RISK_BUDGET * perfMult; riskKrw = riskKrw * perfBias.quantity_multiplier; var atrQty = Math.floor(riskKrw / (atr20 * 1.5)); var cashQty = Math.floor(buyPowerKrw / close); var halve = h1.heatGate === 'HALVE_NEW_BUY_QUANTITY'; if (halve) atrQty = Math.floor(atrQty / 2); // M1: DRAWDOWN_GUARD_V1 추가 축소 var dgScale = (h1.drawdownBuyScale !== undefined && h1.drawdownBuyScale < 1.0) ? h1.drawdownBuyScale : 1.0; if (dgScale < 1.0) atrQty = Math.floor(atrQty * dgScale); // N1: POSITION_SIZE_REGIME_SCALE_V1 국면 스케일 var rssScale = (typeof h1.regimeSizeScale === 'number') ? h1.regimeSizeScale : 1.0; if (rssScale !== 1.0) atrQty = Math.floor(atrQty * rssScale); // O4: WIN_LOSS_STREAK_GUARD_V1 승률 하락 시 추가 축소 var wlScale = (typeof h1.winLossStreakBuyScale === 'number') ? h1.winLossStreakBuyScale : 1.0; if (wlScale < 1.0) atrQty = Math.floor(atrQty * wlScale); buyQtyInputs.push({ ticker: h.ticker, account: h.account || '', name: h.name || df.name || '', atr_qty: atrQty, cash_limit_qty: cashQty, final_qty: Math.min(atrQty, cashQty), atr20: atr20, close: close, halve_applied: halve, perf_bias_reason: perfBias.reason, perf_bias_mult: perfBias.quantity_multiplier, missing: [] }); } else { var missing = []; if (!atr20) missing.push('ATR20'); if (!close) missing.push('Close_Price'); if (!totalAsset) missing.push('total_asset'); buyQtyInputs.push({ ticker: h.ticker, account: h.account || '', name: h.name || df.name || '', final_qty: 'NO_BUY_QUANTITY', missing: missing }); } }); return { sellQty: sellQty, buyQtyInputs: buyQtyInputs }; } // ── H4: 가격 하네스 ────────────────────────────────────────────────────────── /** * calcPrices_ * 보유 종목별: * STOP_PRICE_CORE_V1 → TICK_NORMALIZER_V1 * TAKE_PROFIT_LADDER_V2 (tier1/tier2) → TICK_NORMALIZER_V1 */ function calcPrices_(holdings, dfMap, marketRegime) { // THIN_ADAPTER: [stop_loss/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_stop_price_core var prices = []; holdings.forEach(function(h) { var df = dfMap[h.ticker] || {}; var atr20 = df.atr20 || 0; var close = h.close || df.close || 0; var avgCost = h.avgCost || 0; var qty = h.holdingQty || 0; if (avgCost <= 0) { prices.push({ ticker: h.ticker, account: h.account || '', name: h.name || df.name || '', error: 'NO_AVG_COST' }); return; } var posClass = (df.positionClass || '').toLowerCase(); var isCore = posClass === 'core' || posClass === 'core_leader' || indexOfArr_(CORE_TICKERS, h.ticker) >= 0; // ── STOP_PRICE_CORE_V1 ──────────────────────────────────────────────── // max(avgCost * 0.92, avgCost - ATR20 * atr_multiplier) // atr_multiplier = 2.0 if atr20/close*100 >= 8, else 1.5 var atrMul = 1.5; var stopRaw; if (atr20 > 0 && close > 0) { atrMul = (atr20 / close * 100) >= 8 ? 2.0 : 1.5; stopRaw = Math.max(avgCost * 0.92, avgCost - atr20 * atrMul); } else { stopRaw = avgCost * 0.92; } var stopTick = tickNormalize_(stopRaw); // ── X4: ATR Ratchet (atr_early_ratchet + atr_trailing_universal) ───────── // highest_price_since_entry 우선 사용 (account_snapshot 컬럼). // 미입력 시 close 로 폴백 (일일 마감 기준 보수적 처리). var maxPriceRef = (h.highestPriceSinceEntry && h.highestPriceSinceEntry > close) ? h.highestPriceSinceEntry : close; var ratchetApplied = 'NONE'; var ratchetNote = ''; var ratchetSrc = h.highestPriceSinceEntry ? 'highest_price_since_entry' : 'close_fallback'; if (atr20 > 0 && maxPriceRef > 0 && avgCost > 0) { var earlyTrigger = avgCost + atr20 * 1.0; var trailingStopRaw = Math.max(maxPriceRef - atr20 * 2.0, 0); var trailingStopTick = tickNormalize_(trailingStopRaw); var breakevenTick = tickNormalize_(avgCost); if (maxPriceRef >= earlyTrigger) { // 조기 본절 발동: stop >= breakeven var ratchetedStop = Math.max(stopTick, trailingStopTick, breakevenTick); if (ratchetedStop > stopTick) { ratchetNote = 'early_ratchet[' + ratchetSrc + ']: max(' + maxPriceRef + ')>=avgCost+ATR(' + Math.round(earlyTrigger) + ') → stop_floor=breakeven(' + breakevenTick + ')' + ' | trailing=' + trailingStopTick; stopTick = ratchetedStop; ratchetApplied = 'EARLY_RATCHET+TRAILING'; } else { ratchetNote = 'early_ratchet_inactive: stop already>=' + stopTick; ratchetApplied = 'EARLY_RATCHET_INACTIVE'; } } else if (trailingStopTick > stopTick) { // 조기 본절 미발동, 트레일링만 적용 ratchetNote = 'trailing_only[' + ratchetSrc + ']: max-ATR*2=' + trailingStopTick + '>stop_core=' + stopTick; stopTick = trailingStopTick; ratchetApplied = 'TRAILING_ONLY'; } else { ratchetApplied = 'PASS (stop_core >= trailing)'; ratchetNote = 'trailing=' + trailingStopTick + ' <= stop_core=' + stopTick; } } else { ratchetApplied = 'SKIP (atr/close/avgCost 부재)'; } // ── TAKE_PROFIT_LADDER_V2 ───────────────────────────────────────────── // tier_1: max(avgCost * pct1, avgCost + ATR20 * 1.5) // tier_2: max(avgCost * pct2, avgCost + ATR20 * 3.0) var pct1 = isCore ? 1.15 : 1.10; var pct2 = isCore ? 1.25 : 1.20; var tp1Raw, tp2Raw, ladderVer; if (atr20 > 0) { tp1Raw = Math.max(avgCost * pct1, avgCost + atr20 * 1.5); tp2Raw = Math.max(avgCost * pct2, avgCost + atr20 * 3.0); ladderVer = 'V2_ATR'; } else { tp1Raw = avgCost * pct1; tp2Raw = avgCost * pct2; ladderVer = 'V1_FALLBACK'; } var tp1Tick = tickNormalize_(tp1Raw); var tp2Tick = tickNormalize_(tp2Raw); // ── PROFIT_LOCK_STAGE_CLASSIFIER_V1 ────────────────────────────────────── // spec/exit/take_profit.yaml:profit_lock_ratchet.ratchet_table 기준 // 수익률 구간별 단계 분류 — LLM이 임의 판정하는 것을 하네스에서 선점 (Direction Q 준수) var profitPct = (close > 0 && avgCost > 0) ? (close - avgCost) / avgCost * 100 : 0; // spec/13_formula_registry.yaml:PROFIT_LOCK_STAGE_V1 단계명 기준 (B06 정정 2026-05-30) var profitLockStage, ratchetStopOverride, ratchetPartialQty; if (profitPct >= 60) { profitLockStage = 'APEX_SUPER'; ratchetStopOverride = tickNormalize_( Math.max(avgCost * 1.40, atr20 > 0 ? close - atr20 * 1.2 : avgCost * 1.40) ); ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.50) : 0; } else if (profitPct >= 40) { profitLockStage = 'APEX_TRAILING'; ratchetStopOverride = tickNormalize_( Math.max(avgCost * 1.35, atr20 > 0 ? close - atr20 * 1.5 : avgCost * 1.35) ); ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.40) : 0; } else if (profitPct >= 30) { profitLockStage = 'PROFIT_LOCK_30'; ratchetStopOverride = tickNormalize_(avgCost * 1.20); ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.35) : 0; } else if (profitPct >= 20) { profitLockStage = 'PROFIT_LOCK_20'; ratchetStopOverride = tickNormalize_(avgCost * 1.10); ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.25) : 0; } else if (profitPct >= 10) { profitLockStage = 'PROFIT_LOCK_10'; ratchetStopOverride = tickNormalize_(avgCost * 1.00); ratchetPartialQty = 0; } else if (profitPct >= 0) { profitLockStage = 'BREAKEVEN_RATCHET'; ratchetStopOverride = tickNormalize_(avgCost); ratchetPartialQty = 0; } else { profitLockStage = 'NORMAL'; ratchetStopOverride = null; ratchetPartialQty = 0; } // profit_lock_ratchet 손절선이 기존 손절선보다 높으면 적용 (PROFIT_LOCK_RATCHET_V1) if (ratchetStopOverride && ratchetStopOverride > stopTick) { stopTick = ratchetStopOverride; if (ratchetApplied === 'NONE' || ratchetApplied === 'SKIP (atr/close/avgCost 부재)') { ratchetApplied = 'PROFIT_LOCK_RATCHET'; ratchetNote = 'profit_lock_stage=' + profitLockStage + ' → stop→' + stopTick; } } // ── TP_VALIDITY_CHECK_V1: 현재가 이하 TP는 무효화 (HS009) ───────────────── // tp_price <= close 이면 INVALID_TP_STALE — LLM에 null 전달하여 오표기 원천 차단 var tp1State, tp2State; if (close > 0) { tp1State = tp1Tick > close ? 'PENDING' : 'TP1_ALREADY_TRIGGERED'; tp2State = tp2Tick > close ? 'PENDING' : 'TP2_ALREADY_TRIGGERED'; if (tp1State !== 'PENDING') tp1Tick = null; if (tp2State !== 'PENDING') tp2Tick = null; } else { tp1State = 'UNKNOWN_NO_CLOSE'; tp2State = 'UNKNOWN_NO_CLOSE'; } // ── SECULAR_LEADER_REGIME_GATE_V1 (H3) ─────────────────────────────────── // 삼성전자·SK하이닉스의 secular_leader_profit_lock 발동 여부를 결정론적 판정 var slGate = calcSecularLeaderGate_(h.ticker, marketRegime || 'UNKNOWN', df, qty); // secular_leader_gate 활성 시 tp1 표시 조정 (profit_lock 구간별 차등) if (slGate.active) { if (profitLockStage === 'PROFIT_LOCK_10') { // +10%: tier_1 부분익절 보류 — trailing_stop(본절) 상향만 tp1State = 'DEFERRED_SECULAR_LEADER'; tp1Tick = null; } else if (profitLockStage === 'PROFIT_LOCK_20') { // +20%: 과열신호 2개 미만이면 부분익절 보류 var overheatSignals = 0; if (typeof df.acTotal === 'number' && df.acTotal >= 2) overheatSignals++; if (typeof df.frg5d === 'number' && df.frg5d < 0 && typeof df.inst5d === 'number' && df.inst5d < 0) overheatSignals++; if (typeof df.rsi14 === 'number' && df.rsi14 >= 80) overheatSignals++; // H6: 거래대금 급증 과열신호 — AVG_TRADE_VALUE_SIGNAL_V1 var atvSig = calcAvgTradeValueSignal_(h.ticker, df); if (atvSig.overheat_triggered) overheatSignals++; df._avg_trade_val_signal = atvSig; if (overheatSignals < 2) { tp1State = 'DEFERRED_SECULAR_LEADER_OVERHEAT_PENDING'; tp1Tick = null; } } else if (profitLockStage === 'PROFIT_LOCK_30' || profitLockStage === 'APEX_TRAILING' || profitLockStage === 'APEX_SUPER') { // +30%/APEX: trailing_stop 기반 관리로 전환 — 래칫 stop 우선 (TP는 참고용만) tp1State = 'TRAILING_STOP_PRIORITY_SECULAR_LEADER'; // tp1Tick 유지 — 참고용 유지하되 HTS 주문 표기는 별도 주석으로 처리 } } // TP 무효화 시 수량도 0 (무효 TP에 수량 기재 금지) var tp1Q = (tp1Tick && qty > 0) ? Math.floor(qty * (isCore ? 0.25 : 0.33)) : 0; var tp2Q = (tp2Tick && qty > 0) ? Math.floor((qty - tp1Q) * (isCore ? 0.40 : 0.50)) : 0; var tp3Q = qty - tp1Q - tp2Q; prices.push({ ticker: h.ticker, account: h.account || '', name: h.name || df.name || '', position_class: isCore ? 'core' : 'satellite', atr_mul_used: atrMul, tick_size: getTickSize_(stopRaw), ladder_version: ladderVer, stop_price_raw: Math.round(stopRaw), stop_price: stopTick, tp1_price_raw: Math.round(tp1Raw), tp1_price: tp1Tick, // null = TP1 이미 통과 (TP_VALIDITY_CHECK_V1) tp1_state: tp1State, tp1_qty: tp1Q, tp2_price_raw: Math.round(tp2Raw), tp2_price: tp2Tick, // null = TP2 이미 통과 tp2_state: tp2State, tp2_qty: tp2Q, tp3_qty: tp3Q, profit_pct: Math.round(profitPct * 10) / 10, profit_lock_stage: profitLockStage, ratchet_partial_qty: ratchetPartialQty, atr20: atr20, avg_cost: avgCost, ratchet_applied: ratchetApplied, ratchet_note: ratchetNote, ratchet_price_src: ratchetSrc, highest_price_since_entry: h.highestPriceSinceEntry || null, secular_leader_gate_active: slGate.active, secular_leader_gate_status: slGate.status, secular_leader_gate_reasons: slGate.reasons }); }); return { prices: prices }; } // ── H5: 결정 상태머신 게이팅 ───────────────────────────────────────────────── /** * runRouteFlow_ * data_feed.Final_Action → H1 게이트 적용 → 확정 final_action + gate_trace * 구현 게이트: STOP_BREACH → INTRADAY_LOCK → HEAT_GATE → CASH_FLOOR → EXIT_POLICY * spec/09_decision_flow.yaml 핵심 경로 GAS 구현 */ function runRouteFlow_(holdings, dfMap, h1) { // THIN_ADAPTER: [stop_loss] delegated to Python — tools/gas_thin_adapter_stubs_v1.py:stub_run_route_flow var routes = []; var traces = []; holdings.forEach(function(h) { var df = dfMap[h.ticker] || {}; var baseFa = (df.finalAction || 'INSUFFICIENT_DATA').toUpperCase(); var trace = []; var finalFa = baseFa; // ── Gate 1a: Stop_Price Breach 감지 ────────────────────────────────── if (h.stopBreach) { if (h1.intradayLock) { finalFa = 'TRIM_50'; // P4: 장중은 EXIT_100 금지 → TRIM_50 완화 trace.push({ gate: 'STOP_BREACH', result: 'DOWNGRADE_P4', reason: '장중(P4): stop_breach→TRIM_50 완화' }); } else { finalFa = 'EXIT_100'; trace.push({ gate: 'STOP_BREACH', result: 'FORCE_EXIT', reason: 'close(' + h.close + ')<=stop(' + h.stopPrice + ')' }); } } else { trace.push({ gate: 'STOP_BREACH', result: 'PASS', reason: 'no_breach' }); } // ── Gate 1a-bis: Relative Stop — 시장 베타 보정 손절 (TRIM_50) ───────── if (finalFa !== 'EXIT_100') { var rsDf = df; var rsRet20d = typeof rsDf.ret20d === 'number' ? rsDf.ret20d : parseFloat(rsDf.ret20d); var rsAtr20 = typeof rsDf.atr20 === 'number' ? rsDf.atr20 : parseFloat(rsDf.atr20); var rsClose = h.close || rsDf.close || 0; var rsPft = typeof h.profitPct === 'number' ? h.profitPct : parseFloat(h.profitPct); var rsHdays = typeof h.holdingDays === 'number' ? h.holdingDays : parseInt(h.holdingDays) || 0; var rsKospi = typeof h1.kospiRet20d === 'number' ? h1.kospiRet20d : 0; if (Number.isFinite(rsRet20d) && Number.isFinite(rsAtr20) && rsClose > 0) { var rsBeta = (Math.abs(rsKospi) >= 0.5) ? Math.min(3.0, Math.max(0.3, rsRet20d / rsKospi)) : 1.0; var rsExcess = rsRet20d - rsBeta * rsKospi; var rsSigma = (rsAtr20 / rsClose * 100) * Math.sqrt(20); var rsThresh = -2.0 * rsSigma; var rsAbsFl = Number.isFinite(rsPft) && rsPft < -20.0; var rsTimeSt = rsHdays >= 60 && rsExcess < 0; var rsRelBr = rsExcess < rsThresh; if (rsAbsFl || rsRelBr || rsTimeSt) { var rsType = rsAbsFl ? 'ABS_FLOOR' : (rsRelBr ? 'REL_EXCESS' : 'TIME_STOP'); trace.push({ gate: 'RELATIVE_STOP', result: 'TRIM_50', reason: rsType + ': excess=' + round2_(rsExcess) + ' thr=' + round2_(rsThresh) }); if (finalFa === 'HOLD' || finalFa.indexOf('BUY') >= 0) finalFa = 'TRIM_50'; } else { trace.push({ gate: 'RELATIVE_STOP', result: 'PASS', reason: 'excess=' + round2_(rsExcess) + ' thr=' + round2_(rsThresh) }); } } else { trace.push({ gate: 'RELATIVE_STOP', result: 'SKIP', reason: 'insufficient_data' }); } } else { trace.push({ gate: 'RELATIVE_STOP', result: 'INACTIVE', reason: 'stop_breach_exit_100' }); } // ── Gate 1b: Intraday_Lock — 차단목록 다운그레이드 + 허용목록 이중검증 ── if (h1.intradayLock) { // 1단계: 차단 키워드 다운그레이드 if (indexOfArr_(INTRADAY_BLOCKED_KEYWORDS, finalFa) >= 0) { var downgraded = finalFa.indexOf('BUY') >= 0 ? 'WATCH' : 'TRIM_50'; trace.push({ gate: 'INTRADAY_LOCK', result: 'DOWNGRADE', reason: 'P4: ' + finalFa + '→' + downgraded }); finalFa = downgraded; } // 2단계: 허용목록 이중검증 — 다운그레이드 후에도 허용 목록 외 액션 강제 WATCH if (indexOfArr_(INTRADAY_ALLOWED_ACTIONS, finalFa) < 0) { trace.push({ gate: 'INTRADAY_LOCK', result: 'FORCE_WATCH', reason: 'P4_ALLOWLIST: ' + finalFa + ' not in allowed list→WATCH' }); finalFa = 'WATCH'; } else { trace.push({ gate: 'INTRADAY_LOCK', result: 'PASS', reason: 'action_in_allowlist' }); } } else { trace.push({ gate: 'INTRADAY_LOCK', result: 'INACTIVE', reason: 'post_market' }); } // ── Gate 1c: Heat Gate — BUY 차단/감량 ──────────────────────────────── if (h1.heatGate === 'BLOCK_NEW_BUY' && finalFa.indexOf('BUY') >= 0) { trace.push({ gate: 'HEAT_GATE', result: 'BLOCK_BUY', reason: 'total_heat>=10%: BUY→WATCH' }); finalFa = 'WATCH'; } else if (h1.heatGate === 'HALVE_NEW_BUY_QUANTITY' && finalFa.indexOf('BUY') >= 0) { trace.push({ gate: 'HEAT_GATE', result: 'HALVE_QTY', reason: 'total_heat>=7%: 수량 50% 감량 적용' }); } else { trace.push({ gate: 'HEAT_GATE', result: 'PASS', reason: h1.heatGate }); } // ── Gate 1d: Mean Reversion Gate — 이격 과대 BUY 차단 (MRG001) ────────── if (finalFa.indexOf('BUY') >= 0) { var mrgClose = df.close || 0; var mrgMa20 = df.ma20 || 0; if (mrgClose > 0 && mrgMa20 > 0) { var devRatio = round2_(mrgClose / mrgMa20); if (devRatio >= 1.15) { trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'BUY_HARD_BLOCK', reason: 'MRG001: deviation_ratio(' + devRatio + ')>=1.15→BUY_HARD_BLOCK' }); finalFa = 'WATCH'; } else { trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'PASS', reason: 'deviation_ratio=' + devRatio + '<1.15' }); } } else { trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'SKIP', reason: 'close/ma20 missing' }); } } else { trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'INACTIVE', reason: 'action_not_BUY' }); } // ── Gate 2: Cash Floor — BUY 차단, HOLD → TRIM 넛지 ─────────────────── if (h1.cashFloorStatus === 'HARD_BLOCK' && finalFa.indexOf('BUY') >= 0) { trace.push({ gate: 'CASH_FLOOR', result: 'HARD_BLOCK', reason: 'immediate_cash= 0) { trace.push({ gate: 'CASH_FLOOR', result: 'BUY_BLOCKED', reason: 'TRIM_REQUIRED: BUY→WATCH' }); finalFa = 'WATCH'; } else if (h1.cashFloorStatus === 'TRIM_REQUIRED' && finalFa === 'HOLD') { trace.push({ gate: 'CASH_FLOOR', result: 'NUDGE_TRIM', reason: 'TRIM_REQUIRED: HOLD→TRIM_33 권고' }); finalFa = 'TRIM_33'; } else { trace.push({ gate: 'CASH_FLOOR', result: 'PASS', reason: h1.cashFloorStatus }); } // ── Gate 3: Exit Policy — Sell_Signal 확인 ──────────────────────────── var ss = (df.sellSignal || '').toUpperCase(); if (ss === 'SIGNAL_CONFIRMED' || ss.indexOf('STOP') >= 0 || ss.indexOf('EXIT') >= 0) { trace.push({ gate: 'EXIT_POLICY', result: 'SELL_SIGNAL', reason: 'data_feed.Sell_Signal=' + df.sellSignal }); } else { trace.push({ gate: 'EXIT_POLICY', result: 'PASS', reason: 'no_exit_signal' }); } routes.push({ ticker: h.ticker, account: h.account || '', name: h.name || df.name || '', base_action: baseFa, final_action: finalFa, gate_changed: baseFa !== finalFa, gate_trace: trace, rs_verdict: df.rs_verdict || null }); for (var t = 0; t < trace.length; t++) { traces.push({ ticker: h.ticker, account: h.account || '', state: trace[t].gate, check_id: 'H5_' + trace[t].gate, rule_ref: 'gas_data_feed.gs:' + trace[t].gate, inputs_used: { base_action: baseFa, close: h.close, stop_price: h.stopPrice, intraday_lock: h1.intradayLock, heat_gate_status: h1.heatGate, cash_floor_status: h1.cashFloorStatus }, result: trace[t].result, selected_action: finalFa, blocked_actions: h1.blockedActions || [], missing_inputs: [], tie_breaker_applied: null, reason: trace[t].reason }); } }); return { ["decisions"]: routes, traces: traces, lock: true }; } function findPriceRow_(priceRows, ticker) { for (var i = 0; i < priceRows.length; i++) { if (priceRows[i].ticker === ticker) return priceRows[i]; } return null; } function findSellQtyRow_(sellRows, ticker) { for (var i = 0; i < sellRows.length; i++) { if (sellRows[i].ticker === ticker) return sellRows[i]; } return null; } function findBuyQtyRow_(buyRows, ticker) { for (var i = 0; i < buyRows.length; i++) { if (buyRows[i].ticker === ticker) return buyRows[i]; } return null; } function classifyOrderType_(signalCode, holding) { if (holding && holding.stopBreach) return 'STOP_LOSS'; if (signalCode.indexOf('BUY') >= 0) return 'BUY'; if (signalCode.indexOf('EXIT') >= 0 || signalCode.indexOf('SELL') >= 0 || signalCode.indexOf('TRIM') >= 0 || signalCode.indexOf('ROTATE') >= 0) { return 'SELL'; } if (signalCode === 'HOLD') return 'HOLD'; return 'WATCH'; } function computeTrimQuantity_(finalAction, holdingQty, sellQtyValue) { if (finalAction === 'TRIM_25') return Math.floor(holdingQty * 0.25); if (finalAction === 'TRIM_33') return Math.floor(holdingQty * 0.33); if (finalAction === 'TRIM_50') return Math.floor(holdingQty * 0.50); if (typeof sellQtyValue === 'number') return sellQtyValue; return null; } function buildOrderBlueprint_(holdings, dfMap, h1, h3, h4, h5) { // THIN_ADAPTER: [stop_loss/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:main (order_blueprint_json) var blueprint = []; var h5RouteRows_ = (h5 && h5["decisions"]) ? h5["decisions"] : []; for (var i = 0; i < h5RouteRows_.length; i++) { var routeRow = h5RouteRows_[i]; var ticker = routeRow.ticker; var finalAction = (routeRow.final_action || '').toUpperCase(); var holding = null; for (var j = 0; j < holdings.length; j++) { if (holdings[j].ticker === ticker) { holding = holdings[j]; break; } } if (!holding) continue; var df = dfMap[ticker] || {}; var priceRow = findPriceRow_(h4.prices, ticker) || {}; var sellRow = findSellQtyRow_(h3.sellQty, ticker) || {}; var buyRow = findBuyQtyRow_(h3.buyQtyInputs, ticker) || {}; var orderType = classifyOrderType_(finalAction, holding); var limitPrice = null; var quantity = null; var validation = 'MANUAL_CHECK_REQUIRED'; var rationaleCode = 'FINAL_ACTION:' + finalAction; // [Phase 1] NO_MERCY_JUDGMENT_GATE_V2: 손절가 이탈 시 절대 매도 강제 (LLM 개입 원천 차단) var _closePrice = holding.close || df.close || 0; var _stopPrice = priceRow.stop_price || holding.stopPrice || 0; if (_closePrice > 0 && _stopPrice > 0 && _closePrice < _stopPrice) { orderType = 'SELL'; finalAction = 'EXIT_100'; quantity = holding.holdingQty || 0; limitPrice = tickNormalize_(_closePrice); validation = (quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA'; rationaleCode = 'EMERGENCY_SELL:NO_MERCY_JUDGMENT_GATE_V2'; } else if (orderType === 'BUY') { if (indexOfArr_(h1.blockedActions || [], 'BUY') >= 0 || indexOfArr_(h1.blockedActions || [], 'STAGED_BUY') >= 0) { validation = 'BLOCKED'; rationaleCode = 'BLOCKED_ACTION:' + finalAction; } else if (typeof buyRow.final_qty === 'number' && buyRow.final_qty > 0) { limitPrice = tickNormalize_(holding.close || df.close || 0); quantity = buyRow.final_qty; validation = limitPrice > 0 ? 'PASS' : 'INSUFFICIENT_DATA'; rationaleCode = 'POSITION_SIZE_V1:' + quantity; } else { validation = 'INSUFFICIENT_DATA'; rationaleCode = 'NO_BUY_QUANTITY'; } } else if (indexOfArr_(h1.blockedActions || [], orderType) >= 0) { validation = 'BLOCKED'; rationaleCode = 'BLOCKED_ACTION:' + orderType; } else if (orderType === 'STOP_LOSS') { limitPrice = priceRow.stop_price || tickNormalize_(holding.stopPrice || 0); quantity = holding.holdingQty || null; validation = (limitPrice > 0 && quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA'; rationaleCode = 'STOP_PRICE_CORE_V1:' + limitPrice; } else if (orderType === 'SELL') { if (finalAction === 'EXIT_100' || finalAction === 'SELL_FULL' || finalAction === 'EXIT_FULL') { quantity = holding.holdingQty || null; } else { quantity = computeTrimQuantity_(finalAction, holding.holdingQty || 0, sellRow.sell_qty); } limitPrice = df.sellLimitPrice > 0 ? tickNormalize_(df.sellLimitPrice) : tickNormalize_(holding.close || df.close || 0); validation = (limitPrice > 0 && quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA'; rationaleCode = 'SELL_RULE:' + finalAction; } else { validation = 'BLOCKED'; rationaleCode = 'NO_EXECUTION:' + finalAction; } blueprint.push({ account: holding.account || '일반계좌', ticker: ticker, name: holding.name || df.name || '', current_holding_quantity: holding.holdingQty || 0, average_cost_krw: holding.avgCost ? Math.round(holding.avgCost) : null, current_price_krw: holding.close ? Math.round(holding.close) : null, order_type: orderType, mode: orderType === 'BUY' ? 'lead' : 'none', limit_price_krw: limitPrice > 0 ? Math.round(limitPrice) : null, quantity: typeof quantity === 'number' ? quantity : null, // HS010: WATCH/BLOCKED/INSUFFICIENT_DATA 상태에서 가격·수량 null 강제 // 사용자가 감시값을 HTS 주문으로 오인 입력하는 것을 원천 차단 stop_price_krw: validation === 'PASS' ? (priceRow.stop_price || null) : null, stop_quantity: validation === 'PASS' && orderType === 'BUY' && typeof quantity === 'number' ? quantity : null, ["take_profit_price_krw"]: validation === 'PASS' ? (priceRow.tp1_price || null) : null, ["take_profit_quantity"]: validation === 'PASS' ? (priceRow.tp1_qty || null) : null, order_amount_krw: (limitPrice > 0 && typeof quantity === 'number') ? Math.round(limitPrice * quantity) : null, validation_status: validation, rationale_code: rationaleCode }); } return blueprint; } /** * SELL_PRICE_SANITY_V2 (SPSV2) — 매도 주문 3중 가격 검증 * CHECK_1: limit_price < final_stop → INVALID_PRICE_INVERSION * CHECK_2: stop_price < auto_trailing_stop → INVALID_TRAILING_STOP_BREACH * CHECK_3: limit_price == 0 → INVALID_ZERO_PRICE * validation_status를 인라인 재기록해 EXPORT_GATE가 자동 차단 * @param {Array} blueprint — buildOrderBlueprint_ 반환값 * @param {Array} profitPreservJson — profit_preservation_json (auto_trailing_stop 포함) * @return {Array} blueprint with spsv2_verdict 필드 추가 */ function calcSellPriceSanityV2_(blueprint, profitPreservJson) { var ppMap = {}; (profitPreservJson || []).forEach(function(pp) { var tk = (pp.ticker || pp.ticker_code || '').toString(); if (tk) ppMap[tk] = pp; }); return (blueprint || []).map(function(row) { var ot = (row.order_type || '').toString().toUpperCase(); if (ot !== 'SELL' && ot !== 'STOP_LOSS') { return Object.assign({}, row, { spsv2_verdict: 'NOT_SELL_SKIP' }); } if ((row.validation_status || '').toString() !== 'PASS') { return Object.assign({}, row, { spsv2_verdict: 'SPSV2_SKIP_NOT_PASS' }); } var limitPrice = Number(row.limit_price_krw || 0); var stopPrice = Number(row.stop_price_krw || 0); var pp = ppMap[(row.ticker || row.ticker_code || '').toString()] || {}; var autoTrailing = Number(pp.auto_trailing_stop || 0); var finalStop = (autoTrailing > 0 && autoTrailing > stopPrice) ? autoTrailing : stopPrice; var check1 = (limitPrice > 0 && finalStop > 0 && limitPrice < finalStop) ? 'INVALID_PRICE_INVERSION' : 'PASS'; var check2 = (autoTrailing > 0 && stopPrice > 0 && stopPrice < autoTrailing) ? 'INVALID_TRAILING_STOP_BREACH' : 'PASS'; var check3 = (limitPrice > 0) ? 'PASS' : 'INVALID_ZERO_PRICE'; var verdict; if (check1 !== 'PASS') verdict = check1; else if (check2 !== 'PASS') verdict = check2; else if (check3 !== 'PASS') verdict = check3; else verdict = 'SPSV2_PASS'; var newValidation = (verdict === 'SPSV2_PASS') ? row.validation_status : verdict; return Object.assign({}, row, { spsv2_verdict: verdict, final_stop_price: finalStop || stopPrice || null, auto_trailing_stop_ref: autoTrailing || null, validation_status: newValidation }); }); } // ═══════════════════════════════════════════════════════════════════════ // [PROPOSAL51] P0-D: PRICE_HIERARCHY_LOCK_V1 (PHL-V1) // 5계층 가격 단일화 잠금 — 표간 가격 혼재 완전 차단 // LAYER_1(주문가) / LAYER_2(손절/익절) / LAYER_3(트레일링보정) / // LAYER_4(반등트리거) / LAYER_5(참고방어가) // ═══════════════════════════════════════════════════════════════════════ /** * calcPriceHierarchyLock_ * 단일 종목의 5계층 가격 분리 잠금. LAYER_5가 LAYER_1 위치에 나타나면 INVALID_LAYER_VIOLATION. */ function calcPriceHierarchyLock_(ticker, blueprintRow, priceRow, ppRow, scrsRow, propRefRow) { var bp = blueprintRow || {}; var pr = priceRow || {}; var pp = ppRow || {}; var sc = scrsRow || {}; var ref = propRefRow || {}; var layer1 = toNumber_(bp.limit_price_krw) || null; var layer2Stop = toNumber_(pr.stop_price) || null; var layer2Tp1 = toNumber_(pr.tp1_price) || null; var layer2Tp2 = toNumber_(pr.tp2_price) || null; var layer3Trailing = toNumber_(pp.auto_trailing_stop) || null; var layer4Rebound = toNumber_(sc.rebound_trigger_price) || null; var layer5RefDef = toNumber_(ref.reference_defense_price) || null; var finalStop = (layer3Trailing !== null && layer2Stop !== null) ? Math.max(layer2Stop, layer3Trailing) : (layer2Stop || layer3Trailing || null); var violations = []; if (layer5RefDef !== null && layer1 !== null && layer5RefDef === layer1) { violations.push({ ticker: ticker, type: 'INVALID_LAYER_VIOLATION', detail: 'LAYER_5(ref=' + layer5RefDef + ')==LAYER_1(order=' + layer1 + ') — 참고방어가가 주문가로 오인됨' }); } if (layer4Rebound !== null && layer2Stop !== null && layer4Rebound === layer2Stop) { violations.push({ ticker: ticker, type: 'INVALID_LAYER_VIOLATION', detail: 'LAYER_4(rebound=' + layer4Rebound + ')==LAYER_2(stop=' + layer2Stop + ') — 반등트리거가 손절가로 오인됨' }); } if (layer5RefDef !== null && layer1 !== null) { var diffPct = Math.abs(layer5RefDef - layer1) / layer1 * 100; if (diffPct < 5) { violations.push({ ticker: ticker, type: 'LAYER_PROXIMITY_WARNING', detail: 'LAYER_5(ref=' + layer5RefDef + ')과 LAYER_1(order=' + layer1 + ') ' + diffPct.toFixed(1) + '% 근접 — 혼동 위험' }); } } return { formula_id: 'PRICE_HIERARCHY_LOCK_V1', ticker: ticker, layer1_limit_price: layer1, layer2_stop_price: layer2Stop, layer2_tp1_price: layer2Tp1, layer2_tp2_price: layer2Tp2, layer3_auto_trailing: layer3Trailing, layer4_rebound_trigger: layer4Rebound, layer5_reference_defense: layer5RefDef, final_stop_price: finalStop, layer_violations: violations, violation_count: violations.filter(function(v) { return v.type === 'INVALID_LAYER_VIOLATION'; }).length }; } /** * applyPriceHierarchyLockAll_ * 전 종목 PHL-V1 일괄 실행 — hApex 내 모든 소스 참조 */ function applyPriceHierarchyLockAll_(hApex) { var blueprints = (hApex && hApex.order_blueprint_json) || []; var pricesJson = (hApex && hApex.prices_json) || []; var ppJson = (hApex && hApex.profit_preservation_json) || []; var scrsCombo = ((hApex && hApex.scrs_v2_json) || {}).selected_combo || []; var propRef = (hApex && hApex.proposal_reference_json) || []; var priceMap = {}; pricesJson.forEach(function(r) { priceMap[(r.ticker||r.ticker_code||'').toString()] = r; }); var ppMap = {}; ppJson.forEach(function(r) { ppMap[(r.ticker||r.ticker_code||'').toString()] = r; }); var scrsMap = {}; scrsCombo.forEach(function(r) { scrsMap[(r.ticker||'').toString()] = r; }); var refMap = {}; propRef.forEach(function(r) { refMap[(r.ticker||'').toString()] = r; }); var tickers = {}; blueprints.forEach(function(bp) { tickers[(bp.ticker||bp.ticker_code||'')] = 1; }); return Object.keys(tickers).filter(Boolean).map(function(tk) { var bp = blueprints.find(function(r) { return (r.ticker||r.ticker_code||'').toString() === tk; }) || {}; return calcPriceHierarchyLock_(tk, bp, priceMap[tk], ppMap[tk], scrsMap[tk], refMap[tk]); }); } // ═══════════════════════════════════════════════════════════════════════ // [PROPOSAL51] P2-D: SELL_EXECUTION_QUALITY_GATE_V1 (SEQG-V1) // 매도 실행 품질 채점 — 가격/수량/타이밍 3축 평가 // ═══════════════════════════════════════════════════════════════════════ /** * calcSellExecutionQualityGate_ * 매도 주문의 실행 품질을 3축(가격/수량/타이밍)으로 채점. * - 가격축: limit_price vs stop_price 간격 충분성 * - 수량축: 보유수량 대비 매도비율 적정성 (5~70% 범위) * - 타이밍축: PSR-V2 신호 없을 때 매도 = 불필요 매도 위험 * @param {Array} blueprint — order_blueprint_json (SPSV2 적용 후) * @param {Array} holdings * @param {Array} psrRows — proactive_sell_radar_json * @return {Array} SEQG-V1 rows */ function calcSellExecutionQualityGate_(blueprint, holdings, psrRows) { var holdMap = {}; (holdings || []).forEach(function(h) { holdMap[h.ticker] = h; }); var psrMap = {}; (psrRows || []).forEach(function(p) { psrMap[p.ticker] = p; }); return (blueprint || []).filter(function(row) { return (row.order_type || '').toString().toUpperCase() === 'SELL' || (row.order_type || '').toString().toUpperCase() === 'STOP_LOSS'; }).map(function(row) { var h = holdMap[(row.ticker || '').toString()] || {}; var psr = psrMap[(row.ticker || '').toString()] || {}; var limitPx = toNumber_(row.limit_price_krw) || 0; var stopPx = toNumber_(row.stop_price_krw) || 0; var qty = toNumber_(row.order_quantity) || 0; var holdQty = toNumber_(h.holdingQty) || 1; // 가격축: stop과 limit 간격이 ATR20의 0.5배 이상 var close = toNumber_(h.close) || limitPx || 1; var priceSpread = (limitPx > 0 && stopPx > 0) ? (limitPx - stopPx) / close * 100 : 0; var priceScore = priceSpread >= 2.0 ? 100 : priceSpread >= 1.0 ? 70 : 40; // 수량축: 보유량 대비 5%~70% var sellRatio = holdQty > 0 ? qty / holdQty * 100 : 0; var qtyScore = (sellRatio >= 5 && sellRatio <= 70) ? 100 : (sellRatio > 0 && sellRatio < 5) ? 60 : sellRatio > 70 ? 50 : 0; // 타이밍축: PSR-V2 CRITICAL/WARNING 있으면 타이밍 좋음 var radarLevel = psr.radar_level || 'CLEAR'; var timingScore = radarLevel === 'CRITICAL' ? 100 : radarLevel === 'WARNING' ? 80 : radarLevel === 'WATCH' ? 60 : 40; var totalScore = Math.round((priceScore + qtyScore + timingScore) / 3); var grade = totalScore >= 80 ? 'A' : totalScore >= 65 ? 'B' : totalScore >= 50 ? 'C' : 'D'; return { ticker: row.ticker, name: row.name || (h.name || ''), order_type: row.order_type, price_score: priceScore, quantity_score: qtyScore, timing_score: timingScore, total_score: totalScore, execution_grade: grade, sell_ratio_pct: Math.round(sellRatio * 10) / 10, price_spread_pct: Math.round(priceSpread * 10) / 10, radar_level_ref: radarLevel, formula_id: 'SELL_EXECUTION_QUALITY_GATE_V1' }; }); } // ═══════════════════════════════════════════════════════════════════════ // [PROPOSAL51] P2-B: PROACTIVE_SELL_RADAR_V2 — 8신호 사전 분배 감지 (D-3일) // 분배 전 3일 이내 조기 경보 → CRITICAL/WARNING/WATCH 단계 분류 // ═══════════════════════════════════════════════════════════════════════ /** * calcProactiveSellRadarV2_ * 8가지 사전 분배 감지 신호: 고가 근접+수축, 기관 순매도 전환, 개인 집중유입, * 옵션 풋/콜 역전, 뉴스 감성 급락, 거래량 이상, RSI 다이버전스, 수익 보호 트리거. * @param {Array} holdings * @param {Object} dfMap * @param {Object} profitPreservMap ticker→profitPreservRow 맵 * @return {Array} PSR-V2 rows */ function calcProactiveSellRadarV2_(holdings, dfMap, profitPreservMap) { var ppMap = profitPreservMap || {}; return (holdings || []).map(function(h) { var df = dfMap[h.ticker] || {}; var close = toNumber_(h.close || df.close) || 0; var high52w = toNumber_(df.high52w || df['High52W']) || 0; var volume = toNumber_(df.volume) || 0; var avgVol5d = toNumber_(df.avgVolume5d || df.avgVol5d) || 0; var rsi14 = toNumber_(df.rsi14 || df['RSI14']) || 50; var inst5d = toNumber_(df.inst5d || df['Inst_5D']) || 0; var frg5d = toNumber_(df.frg5d || df['FRG_5D']) || 0; var ret5d = toNumber_(df.ret5d || df['Ret5D']) || 0; var sentScore = toNumber_(df.sentimentScore || df['Sentiment_Score']) || 0; var pp = ppMap[h.ticker] || {}; var autoTrail = toNumber_(pp.auto_trailing_stop) || 0; var holdQty = toNumber_(h.holdingQty) || 0; var signals = []; // SIG_1: 고가 대비 2% 이내 + 거래량 30% 수축 (고점 분배 전형) var nearHigh = high52w > 0 && close > 0 && (high52w - close) / high52w <= 0.02; var volShrink = avgVol5d > 0 && volume > 0 && volume < avgVol5d * 0.7; if (nearHigh && volShrink) signals.push({ id: 'SIG_1_HIGH_SHRINK', weight: 2.0 }); // SIG_2: 기관 5일 순매도 전환 (inst5d < -음수) if (inst5d < 0) signals.push({ id: 'SIG_2_INST_SELL', weight: 2.0 }); // SIG_3: 개인 집중유입 비율 > 70% (설거지 전형) var retailRatio = toNumber_(df.retailBuyRatio5d || df['Retail_Buy_Ratio_5D']) || 0; if (retailRatio > 0.70) signals.push({ id: 'SIG_3_RETAIL_INFLOW', weight: 1.5 }); // SIG_4: 옵션 풋/콜 비율 역전 (put_call_ratio > 1.3) var pcRatio = toNumber_(df.putCallRatio || df['Put_Call_Ratio']) || 0; if (pcRatio > 1.3) signals.push({ id: 'SIG_4_PUT_CALL_INVERT', weight: 1.5 }); // SIG_5: 뉴스 감성 점수 급락 (sentiment < -20) if (sentScore < -20) signals.push({ id: 'SIG_5_SENTIMENT_DROP', weight: 1.0 }); // SIG_6: 거래량 이상 급증 (vol > 1.5x 평균) + 음봉 var open_ = toNumber_(df.open || df['Open']) || close; var volSpike = avgVol5d > 0 && volume > avgVol5d * 1.5; var bearCandle = close < open_ && close > 0; if (volSpike && bearCandle) signals.push({ id: 'SIG_6_VOL_SPIKE_BEAR', weight: 1.5 }); // SIG_7: RSI 다이버전스 (rsi14 >= 70 + 5일 수익 감소) if (rsi14 >= 70 && ret5d < 0) signals.push({ id: 'SIG_7_RSI_DIVERGE', weight: 1.5 }); // SIG_8: 수익 보호 트리거 근접 (close <= auto_trailing_stop * 1.02) if (autoTrail > 0 && close > 0 && close <= autoTrail * 1.02) { signals.push({ id: 'SIG_8_TRAIL_PROXIMITY', weight: 2.0 }); } var weightedSum = signals.reduce(function(acc, s) { return acc + s.weight; }, 0); var radarLevel = weightedSum >= 5.0 ? 'CRITICAL' : weightedSum >= 3.0 ? 'WARNING' : weightedSum >= 1.5 ? 'WATCH' : 'CLEAR'; return { ticker: h.ticker, name: h.name || df.name || '', radar_level: radarLevel, weighted_sum: Math.round(weightedSum * 10) / 10, signal_count: signals.length, signals: signals.map(function(s) { return s.id; }), rsi14: round2_(rsi14), inst5d: round2_(inst5d), retail_ratio: retailRatio ? round2_(retailRatio) : null, auto_trail_ref: autoTrail || null, formula_id: 'PROACTIVE_SELL_RADAR_V2' }; }); } /** * L1: SECTOR_ROTATION_MOMENTUM_V1 * 섹터 로테이션 모멘텀 추적 — rank_delta W1/W2 기반 RISING/STABLE/FADING/TOPPING_OUT 분류 * 결과는 sector_rotation_momentum_json으로 buildHarnessRows_()에 전달된다. * calcAlphaLeadRow_()에서 FADING/TOPPING_OUT 페널티 적용. * @param {Object} sectorFlowData — readSectorFlowForRadar_() 반환값 * @return {Array} sector_rotation_momentum_json rows */ function calcSectorRotationMomentum_(sectorFlowData) { var rows = []; var sectorNames = Object.keys(sectorFlowData || {}); sectorNames.forEach(function(sName) { var sf = sectorFlowData[sName]; if (!sf || !Number.isFinite(sf.rank)) return; var rankDeltaW1 = Number.isFinite(sf.prevRank) ? sf.rank - sf.prevRank : null; var rankDeltaW2 = Number.isFinite(sf.prevRankW2) ? sf.rank - sf.prevRankW2 : null; var momentumState = 'STABLE'; if (rankDeltaW1 !== null && rankDeltaW2 !== null) { if (rankDeltaW1 >= 2 && rankDeltaW2 >= 2) { // 1주일 및 2주일 연속 순위 하락 → 추세 약화 momentumState = 'FADING'; } else if (sf.rank <= 3 && rankDeltaW1 >= 1) { // 상위권이지만 이미 하락 전환 → 고점 신호 momentumState = 'TOPPING_OUT'; } else if (rankDeltaW1 <= -2) { // 순위 상승 → 로테이션 유입 momentumState = 'RISING'; } } else if (rankDeltaW1 !== null) { if (rankDeltaW1 >= 3) momentumState = 'FADING'; else if (rankDeltaW1 <= -2) momentumState = 'RISING'; } rows.push({ sector: sName, rank: sf.rank, prev_rank_w1: Number.isFinite(sf.prevRank) ? sf.prevRank : null, prev_rank_w2: Number.isFinite(sf.prevRankW2) ? sf.prevRankW2 : null, rank_delta_w1: rankDeltaW1, rank_delta_w2: rankDeltaW2, momentum_state: momentumState, formula_id: 'SECTOR_ROTATION_MOMENTUM_V1' }); }); // 현재 순위 오름차순 정렬 rows.sort(function(a, b) { return a.rank - b.rank; }); return rows; } /** * calcAlphaShield_ * 보유 종목별 Alpha-Shield 지표 계산: * X1 deviation_ratio → MRG001 BUY_HARD_BLOCK (이격 차단) * X3 rs_ratio → RS001 RS_LAGGARD (상대강도 부진) * W1 수급 다이버전스 → DIVERGENCE_ALERT * W2 오버행 압력 → OVERHANG_WARNING * W3 섹터 로테이션 이탈 → ROTATION_WARNING * W4 수급 감속 → FLOW_DECEL_WARNING * critical_alert: 2개 이상 레이더 동시 발화 시 전면 재검토 강제 */ function calcAlphaShield_(holdings, dfMap, kospiRet5d, sectorFlowData) { var perHolding = []; var criticalAlerts = 0; holdings.forEach(function(h) { var df = dfMap[h.ticker] || {}; var close = df.close || 0; var ma20 = df.ma20 || 0; // ── X1: MEAN_REVERSION_GATE_V1 ───────────────────────────────────────── var deviationRatio = (close > 0 && ma20 > 0) ? round2_(close / ma20) : null; var mrgGate = deviationRatio === null ? 'INSUFFICIENT_DATA' : deviationRatio >= 1.15 ? 'BUY_HARD_BLOCK' : 'PASS'; // ── X3: RS_RATIO_V1 ──────────────────────────────────────────────────── var stockRet5d = df.ret5d; // null = 컬럼 없음, 0 = 실제 0% var rsRatio = (stockRet5d !== null && typeof kospiRet5d === 'number' && kospiRet5d !== 0) ? round2_(stockRet5d / kospiRet5d) : null; var rsStatus = rsRatio === null ? 'INSUFFICIENT_DATA' : rsRatio < 0.80 ? 'RS_LAGGARD' : 'RS_OK'; // ── 공통 수급 데이터 ────────────────────────────────────────────────── var frg5d = df.frg5d; // null = 컬럼 없음 var inst5d = df.inst5d; var frg20d = df.frg20d; var volume = df.volume; var avgVol5d = df.avgVolume5d; var ma20Slope = typeof df.ma20Slope === 'number' ? df.ma20Slope : null; var priceAboveMa20 = close > 0 && ma20 > 0 && close > ma20; var ma20SlopePositive = ma20Slope !== null && ma20Slope > 0; var frgNetNeg = frg5d !== null && inst5d !== null && frg5d < 0 && inst5d < 0; var volRatio = (volume !== null && avgVol5d !== null && avgVol5d > 0) ? round2_(volume / avgVol5d) : null; // ── W1: DIVERGENCE_SCORE_V1 ──────────────────────────────────────────── var w1Status = 'INSUFFICIENT_DATA'; if (frg5d !== null && inst5d !== null && ma20Slope !== null && volume !== null && avgVol5d !== null) { w1Status = (priceAboveMa20 && !ma20SlopePositive && frgNetNeg && volRatio !== null && volRatio <= 0.80) ? 'DIVERGENCE_ALERT' : 'CLEAR'; } // ── W2: OVERHANG_PRESSURE_V1 ─────────────────────────────────────────── var w2Status = 'INSUFFICIENT_DATA'; var overhangPressure = null; if (frg20d !== null && avgVol5d !== null && avgVol5d > 0) { overhangPressure = round2_(Math.abs(frg20d) / (avgVol5d * 20)); w2Status = (frg20d < 0 && overhangPressure > 0.30) ? 'OVERHANG_WARNING' : 'CLEAR'; } // ── W3: SECTOR_ROTATION_RADAR_V1 ────────────────────────────────────── var w3Status = 'INSUFFICIENT_DATA'; var sectorName = TICKER_SECTOR_MAP[h.ticker] || null; var sfRow = sectorName ? (sectorFlowData[sectorName] || null) : null; var sectorRank = null; var sectorPrvRank = null; if (sfRow) { sectorRank = sfRow.rank; sectorPrvRank = sfRow.prevRank; if (Number.isFinite(sfRow.rank) && Number.isFinite(sfRow.prevRank)) { var dropW1 = sfRow.rank - sfRow.prevRank; var dropW2 = Number.isFinite(sfRow.prevRankW2) ? sfRow.rank - sfRow.prevRankW2 : dropW1; w3Status = (dropW1 >= 3 && dropW2 >= 3) ? 'ROTATION_WARNING' : 'CLEAR'; } } // ── W4: FLOW_ACCELERATION_V1 ─────────────────────────────────────────── var w4Status = 'INSUFFICIENT_DATA'; var flowAccelRatio = null; if (frg5d !== null && frg20d !== null) { var buyEnergy20dAvg = frg20d / 4; if (buyEnergy20dAvg > 0) { flowAccelRatio = round2_(frg5d / buyEnergy20dAvg); w4Status = (priceAboveMa20 && frg5d > 0 && flowAccelRatio < 0.50) ? 'FLOW_DECEL_WARNING' : 'CLEAR'; } else { w4Status = 'CLEAR'; } } // ── 발화 집계 ──────────────────────────────────────────────────────── var ALERT_STATUSES = ['DIVERGENCE_ALERT','OVERHANG_WARNING','ROTATION_WARNING','FLOW_DECEL_WARNING']; var fires = [w1Status, w2Status, w3Status, w4Status].filter(function(s) { return ALERT_STATUSES.indexOf(s) >= 0; }).length; if (fires >= 2) criticalAlerts++; perHolding.push({ ticker: h.ticker, name: h.name || '', weight_pct: h.weightPct || 0, // X1 MRG001 deviation_ratio: deviationRatio, mrg_gate: mrgGate, // X3 RS001 stock_ret5d: stockRet5d, kospi_ret5d: typeof kospiRet5d === 'number' ? kospiRet5d : null, rs_ratio: rsRatio, rs_status: rsStatus, // W1 volume_ratio: volRatio, w1_status: w1Status, // W2 overhang_pressure: overhangPressure, w2_status: w2Status, // W3 sector: sectorName, sector_rank: sectorRank, sector_prev_rank: sectorPrvRank, w3_status: w3Status, // W4 flow_accel_ratio: flowAccelRatio, w4_status: w4Status, // 종합 radar_fires: fires, critical_alert: fires >= 2 ? 'CRITICAL_ALERT' : 'OK' }); }); return { per_holding: perHolding, critical_alert_count: criticalAlerts, lock: true }; } // ── APEX V1: 판단 자료 생성 시점 하네스 ───────────────────────────────────── /** * calcRegimeAdjustedSellPriority_ [K3: 국면·섹터 연계 H2 동적 우선순위] * 시장 국면(regime)에 따라 H2 매도후보의 매도 우선순위를 동적으로 조정한다. * H2 원래 순위(rank)는 변경하지 않고 regime_priority_adjustment(음수=우선↑)와 * final_regime_rank을 추가로 부여한다. * LLM은 regime_adjusted_sell_priority_json을 H2보다 우선 참조하되, * sell_priority_lock=true 이므로 최종 순위를 임의 재해석할 수 없다. */ function calcRegimeAdjustedSellPriority_(h2Candidates, regime, dfMap, kospiRet5d) { var result = []; h2Candidates.forEach(function(cand) { var candScore = (typeof cand.sell_priority_score === 'number') ? cand.sell_priority_score : cand.score; if (typeof candScore !== 'number' || !Number.isFinite(candScore)) { throw new Error('SELL_PRIORITY_SCHEMA_INVALID: missing score field for ticker=' + cand.ticker); } var df = dfMap[cand.ticker] || {}; var adj = 0; var reason = 'NO_REGIME_ADJ'; if (regime === 'RISK_OFF' || regime === 'EVENT_SHOCK') { // 추세 붕괴/충격 국면: KOSPI 대비 고베타(많이 떨어지는) 종목 우선 매도 var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null; var kRet5d = typeof kospiRet5d === 'number' ? kospiRet5d : null; if (ret5d !== null && kRet5d !== null && kRet5d < -1) { var betaProxy = ret5d / kRet5d; if (Number.isFinite(betaProxy) && betaProxy > 1.3) { adj = -3; reason = 'high_beta_breakdown_sell_first'; } else if (Number.isFinite(betaProxy) && betaProxy > 1.0) { adj = -1; reason = 'above_beta_breakdown'; } } // 수급 동반 이탈 종목 우선 if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) { adj = Math.min(adj, -2); reason = reason === 'NO_REGIME_ADJ' ? 'dual_outflow_breakdown' : reason; } } else if (regime === 'RISK_OFF_CANDIDATE') { // 분배장 경고: 수급 약하고 flow_credit 낮은 종목 우선 var fcOk = typeof df.flowCredit === 'number'; if (fcOk && df.flowCredit < 0.30) { adj = -2; reason = 'low_flow_credit_distribution'; } else if (fcOk && df.flowCredit < 0.45) { adj = -1; reason = 'moderate_low_flow_distribution'; } } else if (regime === 'RISK_ON' || regime === 'SECULAR_LEADER_RISK_ON') { // 상승기: 섹터 대비 상대적 약자 우선 정리 (리더 보호) var sRet = typeof df.ret5d === 'number' ? df.ret5d : null; var kRet = typeof kospiRet5d === 'number' ? kospiRet5d : null; if (sRet !== null && kRet !== null && sRet < kRet - 3) { adj = -2; reason = 'sector_lag_in_risk_on_trim'; } // 중복 ETF는 상승기에도 먼저 정리 if (df.isDuplicateEtf) { adj = Math.min(adj, -2); reason = 'duplicate_etf_in_risk_on'; } } else if (regime === 'LEADER_CONCENTRATION' || regime === 'NEUTRAL') { // 조정기: AC(안티클라이막스) 발동 종목 우선 if (df.acGate && String(df.acGate).toUpperCase().indexOf('CLIMAX') >= 0) { adj = -1; reason = 'anti_climax_in_pullback'; } } result.push({ rank: cand.rank, ticker: cand.ticker, name: cand.name, tier: cand.tier, original_score: candScore, trim_style: cand.trim_style || '', regime_priority_adjustment: adj, adjusted_sort_key: cand.tier * 100 + (cand.rank + adj), adjustment_reason: reason, regime_applied: regime }); }); result.sort(function(a, b) { return a.adjusted_sort_key - b.adjusted_sort_key; }); result.forEach(function(r, i) { r.final_regime_rank = i + 1; }); return result; } function findCandidateByTicker_(candidates, ticker) { for (var i = 0; i < (candidates || []).length; i++) { if (candidates[i].ticker === ticker) return candidates[i]; } return null; } function findOrderBlueprintRow_(orders, ticker) { for (var i = 0; i < (orders || []).length; i++) { if (orders[i].ticker === ticker) return orders[i]; } return null; } function calcDistributionRiskRow_(h, df, kospiRet5d, sectorFlowData) { // THIN_ADAPTER: [risk_score] delegated to Python — src/quant_engine/inject_computed_harness.py:calc_distribution_detector_per_ticker var close = df.close || h.close || 0; var ma20 = df.ma20 || 0; var high = df.high || close; var low = df.low || close; var volume = df.volume; var avgVol5d = df.avgVolume5d; var flowCredit = typeof df.flowCredit === 'number' ? df.flowCredit : null; var priceAboveMa20 = close > 0 && ma20 > 0 && close > ma20; var score = 0; var reasons = []; if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) { score += 30; reasons.push('smart_money_outflow'); } if (volume !== null && avgVol5d !== null && avgVol5d > 0 && volume < avgVol5d * 0.80) { score += 20; reasons.push('volume_fade_after_surge'); } if (high > low && close > 0) { var upperWickRatio = (high - close) / Math.max(high - low, 1); if (upperWickRatio >= 0.45 && priceAboveMa20) { score += 15; reasons.push('upper_wick_distribution'); } } if (flowCredit !== null && flowCredit < 0.40) { score += 20; reasons.push('flow_credit_low'); } if (typeof df.ret5d === 'number' && typeof kospiRet5d === 'number' && df.ret5d < kospiRet5d - 3) { score += 15; reasons.push('sector_relative_lag'); } // J2: Anti-Climax Gate — 가격은 유지되나 수급 에너지 고갈 신호 (acGate / acTotal) if (df.acGate && String(df.acGate).toUpperCase().indexOf('CLIMAX') >= 0) { score += 15; reasons.push('anti_climax_gate'); } if (typeof df.acTotal === 'number' && df.acTotal >= 2) { score += 10; reasons.push('ac_total_gte2'); } // J2: 거래량 상승 국면에서 상승폭 축소 (가격 상승 + 거래량 급증 + 수익 미실현 구간) if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 40 && priceAboveMa20 && (flowCredit === null || flowCredit < 0.50)) { score += 10; reasons.push('val_surge_no_flow_support'); } // L4: PRE_DISTRIBUTION_EARLY_WARNING_V1 // Signal 1: 신고점 근접 + 거래량 수축 — 분배 직전 전형적 패턴 var high52w = typeof df.high52w === 'number' && df.high52w > 0 ? df.high52w : null; var nearNewHigh = (high52w !== null && close > 0 && close >= high52w * 0.97) || (ma20 > 0 && close > ma20 * 1.15); // 52W high 미제공 시 MA20 +15% 이상 연장으로 대체 if (nearNewHigh && volume !== null && avgVol5d !== null && avgVol5d > 0 && volume < avgVol5d * 0.80) { score += 12; reasons.push('new_high_volume_contraction'); } // Signal 2: 최근 급등 후 수급 약화 — 5일 +5% 이상 상승했으나 flow credit 저조 if (typeof df.ret5d === 'number' && df.ret5d >= 5 && flowCredit !== null && flowCredit < 0.45) { score += 10; reasons.push('surge_weak_flow'); } var state = score >= 70 ? 'BLOCK_BUY' : score >= 55 ? 'TRIM_REVIEW' : 'PASS'; var preDistWarning = (reasons.indexOf('new_high_volume_contraction') >= 0 || reasons.indexOf('surge_weak_flow') >= 0) ? 'EARLY_WARNING' : 'NONE'; return { ticker: h.ticker, name: h.name || df.name || '', ["distribution_risk_score"]: Math.min(100, Math.max(0, score)), anti_distribution_state: state, pre_distribution_warning: preDistWarning, reason_codes: reasons, formula_id: 'DISTRIBUTION_RISK_SCORE_V1' }; } function calcAlphaLeadRow_(h, df, sectorFlowData, distributionRow) { var close = df.close || h.close || 0; var ma20 = df.ma20 || 0; var closeVsMa20Pct = (close > 0 && ma20 > 0) ? (close / ma20 - 1) * 100 : null; var sectorName = TICKER_SECTOR_MAP[h.ticker] || null; var sf = sectorName ? sectorFlowData[sectorName] : null; var score = 0; var lateChaseRisk = 0; var reasons = []; if (sf && Number.isFinite(sf.rank) && sf.rank <= 2) { score += 20; reasons.push('sector_rank_top2'); } // L1: SECTOR_ROTATION_MOMENTUM_V1 — 로테이션 모멘텀 패널티 if (sf && Number.isFinite(sf.rank) && Number.isFinite(sf.prevRank)) { var rdW1 = sf.rank - sf.prevRank; var rdW2 = Number.isFinite(sf.prevRankW2) ? sf.rank - sf.prevRankW2 : rdW1; if (rdW1 >= 2 && rdW2 >= 2) { score -= 15; reasons.push('sector_fading'); } else if (sf.rank <= 3 && rdW1 >= 1) { score -= 10; reasons.push('sector_topping_out'); } } if (typeof df.ret5d === 'number' && df.ret5d > 0) { score += 10; reasons.push('ret5d_positive'); } if (df.frg5d !== null && df.inst5d !== null && (df.frg5d + df.inst5d) > 0) { score += 25; reasons.push('smart_money_inflow'); } if (typeof df.leaderTotal === 'number') { score += Math.min(20, df.leaderTotal * 5); reasons.push('leader_scan'); } if (typeof df.avgTradeVal5d === 'number' && df.avgTradeVal5d >= 50) { score += 10; reasons.push('liquidity_ok'); } if (closeVsMa20Pct !== null && closeVsMa20Pct >= 0 && closeVsMa20Pct <= 6) { score += 15; reasons.push('ma20_controlled_extension'); } var lateChase = closeVsMa20Pct !== null && closeVsMa20Pct > 10; if (closeVsMa20Pct !== null) { if (closeVsMa20Pct > 10) lateChaseRisk += 60; else if (closeVsMa20Pct > 6) lateChaseRisk += 25; else if (closeVsMa20Pct > 3) lateChaseRisk += 10; } if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 60) { lateChase = true; lateChaseRisk += 25; reasons.push('value_surge_extreme'); } else if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 35) { lateChaseRisk += 10; } if (distributionRow && distributionRow.anti_distribution_state === 'BLOCK_BUY') { lateChase = true; lateChaseRisk += 40; reasons.push('distribution_block'); } if (typeof df.dartRiskStatus === 'string' && df.dartRiskStatus !== 'OK') { lateChase = true; lateChaseRisk += 30; reasons.push('dart_risk'); } // N2: VOLUME_BREAKOUT_CONFIRM_V1 — 신고가 부근 거래량 미확인 시 뒷박 차단 var n2High52w = typeof df.high52w === 'number' && df.high52w > 0 ? df.high52w : 0; var n2Vol = typeof df.volume === 'number' ? df.volume : 0; var n2AvgVol5d = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : 0; if (n2High52w > 0 && close > 0 && close >= n2High52w * 0.97) { if (n2AvgVol5d > 0 && n2Vol < n2AvgVol5d * 1.2) { score -= 10; lateChaseRisk += 15; reasons.push('unconfirmed_breakout_volume'); } } var state = lateChase ? 'BLOCKED_LATE_CHASE' : score >= 75 ? 'PILOT_ALLOWED' : score >= 55 ? 'WATCH_ONLY' : 'WATCH_ONLY'; var buyState = state === 'PILOT_ALLOWED' ? 'ALLOW_PILOT' : (state === 'BLOCKED_LATE_CHASE' ? 'BLOCKED' : 'WATCH'); return { ticker: h.ticker, name: h.name || df.name || '', alpha_lead_score: Math.min(100, Math.max(0, Math.round(score))), lead_entry_state: state, allowed_tranche_pct: state === 'PILOT_ALLOWED' ? 30 : 0, buy_permission_state: buyState, close_vs_ma20_pct: closeVsMa20Pct === null ? null : round2_(closeVsMa20Pct), ["late_chase_risk_score"]: Math.min(100, Math.max(0, Math.round(lateChaseRisk))), blocked_reason_codes: lateChase ? ['late_chase_or_distribution'] : [], reason_codes: reasons, formula_id: 'ALPHA_LEAD_SCORE_V1' }; } function calcFollowThroughRow_(h, df) { var close = df.close || h.close || 0; var prevClose = df.prevClose || 0; var ma5Proxy = prevClose || close; var state = 'WAIT_PULLBACK'; var score = 25; var reasons = []; if (close > 0 && df.ma20 > 0 && close < df.ma20) { state = 'FAILED_BREAKOUT'; reasons.push('close_below_ma20'); score = 0; } else if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) { state = 'FAILED_BREAKOUT'; reasons.push('dual_outflow'); score = 0; } else if (close > 0 && ma5Proxy > 0 && close >= ma5Proxy && df.frg5d !== null && df.frg5d > 0) { state = 'CONFIRMED_ADD_ON'; reasons.push('price_hold_and_foreign_inflow'); score = 100; } else if (close > 0 && ma5Proxy > 0 && close >= ma5Proxy) { score = 60; } return { ticker: h.ticker, name: h.name || df.name || '', follow_through_state: state, follow_through_score: score, reason_codes: reasons, formula_id: 'FOLLOW_THROUGH_CONFIRM_V1' }; } // --- Source: src/gas_adapter_parts/gdf_04_execution_quality.gs --- function calcProfitPreservationRow_(h, df, priceRow, distributionRow) { // THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/inject_computed_harness.py:trailing_stop_v2 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) { // THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/inject_computed_harness.py:cash_recovery 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= 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) { // THIN_ADAPTER: [sizing/decision] delegated to Python — src/quant_engine/inject_computed_harness.py:main 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_ 키-값을 읽어 기본값과 병합. * 오버라이드가 존재하면 _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_ 형태로 기록 → 다음 실행 시 반영. */ 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) { // THIN_ADAPTER: [sizing] delegated to Python — src/quant_engine/inject_computed_harness.py:cash_recovery 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) { // THIN_ADAPTER: [unknown] delegated to Python — tools/gas_thin_adapter_stubs_v1.py:stub_calc_export_gate 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) { // THIN_ADAPTER: [stop_loss/take_profit] delegated to Python — tools/gas_thin_adapter_stubs_v1.py:stub_build_watch_ledger 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' }; } // ============================================================ // WBS-4.4 일별 성과 대시보드 (포트폴리오 수익률 vs KOSPI 알파) // ============================================================ /** * 매일 runDataFeed 이후 호출. evaluation_dashboard 시트에 * 포트폴리오 수익률·KOSPI 수익률·알파·누적알파·MDD 를 기록. * * 설계 원칙: * - daily_history → total_asset, mdd_pct * - macro 시트 → KOSPI Close (어제/오늘 Close 차이로 1D 수익률 계산) * - evaluation_dashboard 시트의 직전 행을 기준 자산·KOSPI Close 로 사용 * - 시트 없으면 자동 생성, 오늘 행이 이미 있으면 덮어쓰기 */ function updateEvaluationDashboard_() { var ss = getSpreadsheet_(); var today = Utilities.formatDate(new Date(), 'Asia/Seoul', 'yyyy-MM-dd'); // ── 1. daily_history에서 오늘 total_asset, mdd_pct 읽기 ────────────────── var histSheet = ss.getSheetByName('daily_history'); if (!histSheet) { Logger.log('[EVAL_DASH] daily_history 시트 없음, 건너뜀'); return; } var histData = histSheet.getDataRange().getValues(); if (histData.length < 2) { Logger.log('[EVAL_DASH] daily_history 데이터 부족'); return; } var hHdr = histData[0].map(function(c) { return String(c).trim(); }); var hDateIdx = hHdr.indexOf('date'); var hAssetIdx = hHdr.indexOf('total_asset'); var hMddIdx = hHdr.indexOf('mdd_pct'); if (hDateIdx < 0 || hAssetIdx < 0) { Logger.log('[EVAL_DASH] daily_history 헤더 불일치: ' + hHdr.join(',')); return; } var todayHistRow = null; for (var r = 1; r < histData.length; r++) { if (String(histData[r][hDateIdx]).trim() === today) { todayHistRow = histData[r]; break; } } if (!todayHistRow) { Logger.log('[EVAL_DASH] daily_history에 오늘 행 없음: ' + today); return; } var todayAsset = parseFloat(todayHistRow[hAssetIdx]) || 0; var todayMdd = hMddIdx >= 0 ? (parseFloat(todayHistRow[hMddIdx]) || 0) : 0; // ── 2. macro 시트에서 KOSPI Close 읽기 ──────────────────────────────────── var todayKospiClose = null; var macroSheet = ss.getSheetByName('macro'); if (macroSheet) { var mData = macroSheet.getDataRange().getValues(); var mHdrRowIdx = 0; for (var i = 0; i < Math.min(5, mData.length); i++) { if (mData[i].join(',').indexOf('Name') >= 0) { mHdrRowIdx = i; break; } } var mHdr = mData[mHdrRowIdx].map(function(c) { return String(c).trim(); }); var mNameIdx = mHdr.indexOf('Name'); var mCloseIdx = mHdr.indexOf('Close'); for (var j = mHdrRowIdx + 1; j < mData.length; j++) { if (mNameIdx >= 0 && String(mData[j][mNameIdx]).trim() === 'KOSPI') { if (mCloseIdx >= 0) todayKospiClose = parseFloat(mData[j][mCloseIdx]) || null; break; } } } // ── 3. evaluation_dashboard 시트 가져오기/생성 ─────────────────────────── var EVD_HDRS = [ 'Date', 'Total_Asset', 'KOSPI_Close', 'Portfolio_Return_1D_Pct', 'KOSPI_Return_1D_Pct', 'Alpha_1D_Pct', 'Cumulative_Alpha_Pct', 'MDD_Pct' ]; var evdSheet = ss.getSheetByName('evaluation_dashboard'); if (!evdSheet) { evdSheet = ss.insertSheet('evaluation_dashboard'); evdSheet.getRange(1, 1, 1, EVD_HDRS.length).setValues([EVD_HDRS]); evdSheet.setFrozenRows(1); } // ── 4. 직전 행(prev) 및 오늘 행 위치 파악 ────────────────────────────── var evdData = evdSheet.getDataRange().getValues(); var eHdr = evdData.length > 0 ? evdData[0].map(function(c) { return String(c).trim(); }) : EVD_HDRS; var eDateIdx = eHdr.indexOf('Date'); var eAssetIdx = eHdr.indexOf('Total_Asset'); var eKospiIdx = eHdr.indexOf('KOSPI_Close'); var eCumAlphaIdx = eHdr.indexOf('Cumulative_Alpha_Pct'); var prevAsset = null; var prevKospi = null; var prevCumAlpha = 0; var todayRowIdx = -1; // 1-based sheet row index (0 = not found) for (var k = 1; k < evdData.length; k++) { var rowDate = eDateIdx >= 0 ? String(evdData[k][eDateIdx]).trim() : ''; if (rowDate === today) { todayRowIdx = k + 1; // getRange은 1-based } else if (rowDate !== '' && rowDate < today) { prevAsset = eAssetIdx >= 0 ? (parseFloat(evdData[k][eAssetIdx]) || null) : null; prevKospi = eKospiIdx >= 0 ? (parseFloat(evdData[k][eKospiIdx]) || null) : null; prevCumAlpha = eCumAlphaIdx >= 0 ? (parseFloat(evdData[k][eCumAlphaIdx]) || 0) : 0; } } // ── 5. 수익률·알파 계산 ──────────────────────────────────────────────── var portfolioRet1D = null; if (prevAsset !== null && prevAsset > 0 && todayAsset > 0) { portfolioRet1D = Math.round(((todayAsset - prevAsset) / prevAsset * 100) * 100) / 100; } var kospiRet1D = null; if (prevKospi !== null && prevKospi > 0 && todayKospiClose !== null && todayKospiClose > 0) { kospiRet1D = Math.round(((todayKospiClose - prevKospi) / prevKospi * 100) * 100) / 100; } var alpha1D = (portfolioRet1D !== null && kospiRet1D !== null) ? Math.round((portfolioRet1D - kospiRet1D) * 100) / 100 : null; var cumAlpha = alpha1D !== null ? Math.round((prevCumAlpha + alpha1D) * 100) / 100 : prevCumAlpha; var newRow = [ today, todayAsset, todayKospiClose, portfolioRet1D, kospiRet1D, alpha1D, cumAlpha, todayMdd ]; // ── 6. 오늘 행 덮어쓰기 또는 추가 ──────────────────────────────────── if (todayRowIdx > 0) { evdSheet.getRange(todayRowIdx, 1, 1, newRow.length).setValues([newRow]); Logger.log('[EVAL_DASH] 오늘 행 업데이트 date=' + today + ' portfolio_ret=' + portfolioRet1D + ' alpha=' + alpha1D + ' cum_alpha=' + cumAlpha); } else { evdSheet.appendRow(newRow); Logger.log('[EVAL_DASH] 오늘 행 추가 date=' + today + ' portfolio_ret=' + portfolioRet1D + ' alpha=' + alpha1D + ' cum_alpha=' + cumAlpha); } } // --- Source: src/gas_adapter_parts/gdf_05_alpha_engines.gs --- function safeStringifyForChecksum_(value) { var s = JSON.stringify(value); return (s === undefined || s === null) ? '' : s; } // ═══════════════════════════════════════════════════════════════════════ // [PROPOSAL50] P2-2: YAML_GAS_COVERAGE_AUDIT_ENGINE_V1 (YGCA-V1) // YAML 지침 ↔ GAS 함수 커버리지 감사 — settings 탭에 결과 기록 // ═══════════════════════════════════════════════════════════════════════ /** * auditYamlGasCoverage_ * 필수 함수 목록과 실제 정의를 비교해 커버리지 % 산출. * GAS에서는 typeof 로 함수 존재 여부를 확인한다. */ function auditYamlGasCoverage_() { var REQUIRED = [ // Stage 0 { yaml: 'HARNESS_DATA_FRESHNESS_GATE_V1', gs: 'calcHarnessDataFreshnessGate_' }, { yaml: 'INTRADAY_ACTION_MATRIX_V1', gs: 'calcIntradayLock_' }, // Stage 1 { yaml: 'FLOW_CREDIT_V1', gs: 'buildAllowedAction' }, { yaml: 'TARGET_CASH_PCT_V1', gs: 'calcCashFloor_' }, { yaml: 'TOTAL_HEAT_V1', gs: 'calcHarnessPortfolioGuardState_' }, { yaml: 'CASH_SHORTFALL_V1', gs: 'calcCashShortfallHarness_' }, { yaml: 'CASH_RECOVERY_OPTIMIZER_V1', gs: 'calcCashPreservationPlan_' }, // Stage 2 { yaml: 'POSITION_SIZE_V1', gs: 'calcQuantities_' }, { yaml: 'STOP_PRICE_CORE_V1', gs: 'calcPrices_' }, { yaml: 'PROFIT_RATCHET_TIERED_V2', gs: 'calcProfitPreservationRow_' }, { yaml: 'TAKE_PROFIT_LADDER_V1', gs: 'calcTpQuantityLadder_' }, // Stage 3 { yaml: 'DISTRIBUTION_SELL_DETECTOR_V1', gs: 'calcDistributionRiskRow_' }, { yaml: 'DIVERGENCE_SCORE_V1', gs: 'calcSellConflictScore_' }, { yaml: 'OVERHANG_PRESSURE_V1', gs: 'calcReboundHoldbackScore_' }, { yaml: 'FLOW_ACCELERATION_V1', gs: 'calcAlphaShield_' }, { yaml: 'PRE_DISTRIBUTION_EARLY_WARNING_V1', gs: 'calcDistributionRiskRow_' }, // Stage 4 { yaml: 'ANTI_LATE_ENTRY_GATE_V2', gs: 'calcAntiLateEntryGateV2_' }, { yaml: 'PULLBACK_ENTRY_TRIGGER_V1', gs: 'calcEntryTimingSignal_' }, { yaml: 'BREAKOUT_QUALITY_GATE_V2', gs: 'calcBreakoutQualityGate_' }, { yaml: 'STAGED_ENTRY_TRANCHE_V1', gs: 'calcCoreSatelliteExecutionState_' }, // Stage 5 { yaml: 'SELL_WATERFALL_ENGINE_V1', gs: 'calcSmartCashRaiseV2_' }, { yaml: 'SELL_EXECUTION_TIMING_V1', gs: 'calcExitSellAction_' }, { yaml: 'SELL_VALUE_PRESERVATION_TIERED_V2', gs: 'calcCashPreservationSellEngineV2_' }, { yaml: 'SELL_PRICE_SANITY_V1', gs: 'calcSellSignalSanityScore_' }, { yaml: 'K2_STAGED_REBOUND_SELL_V1', gs: 'calcAntiWhipsawGate_' }, // Stage 6 { yaml: 'TICK_NORMALIZER_V1', gs: 'tickNormalize_' }, // Stage 7-8 { yaml: 'RS_VERDICT_V2', gs: 'calcIndexRelativeHealthGate_' }, { yaml: 'BENCHMARK_RELATIVE_TIMESERIES_V1', gs: 'calcIndexRelativeHealthGate_' }, { yaml: 'SATELLITE_ALPHA_QUALITY_GATE_V1', gs: 'calcCoreCandidateQualityGrade_' }, { yaml: 'SATELLITE_LIFECYCLE_GATE_V1', gs: 'calcSatelliteLifecycleGate_' }, { yaml: 'PORTFOLIO_CORRELATION_GATE_V1', gs: 'calcPortfolioCorrelationGate_' }, // Stage 9 { yaml: 'LLM_SERVING_CONSTRAINT_V1', gs: 'calcDeterministicServingLock_' }, { yaml: 'DETERMINISTIC_ROUTING_ENGINE_V1', gs: 'buildHarnessContext_' }, // Portfolio risk { yaml: 'DRAWDOWN_GUARD_V1', gs: 'calcDrawdownGuard_' }, { yaml: 'PORTFOLIO_BETA_GATE_V1', gs: 'calcPortfolioBetaGate_' }, { yaml: 'SECTOR_CONCENTRATION_LIMIT_V1', gs: 'calcSectorConcentrationGate_' }, { yaml: 'POSITION_COUNT_LIMIT_V1', gs: 'calcPositionCountLimit_' }, { yaml: 'SINGLE_POSITION_WEIGHT_CAP_V1', gs: 'calcSinglePositionWeightCap_' }, { yaml: 'SEMICONDUCTOR_CLUSTER_GATE_V1', gs: 'calcSemiconductorClusterGate_' }, { yaml: 'PORTFOLIO_DRAWDOWN_GATE_V1', gs: 'calcPortfolioDrawdownGate_' }, { yaml: 'WIN_LOSS_STREAK_GUARD_V1', gs: 'calcWinLossStreakGuard_' }, // Alerts { yaml: 'STOP_BREACH_ALERT_V1', gs: 'calcStopBreachAlert_' }, { yaml: 'RELATIVE_STOP_SIGNAL_V1', gs: 'calcRelativeStopSignal_' }, { yaml: 'TP_TRIGGER_ALERT_V1', gs: 'calcTpTriggerAlert_' }, { yaml: 'HEAT_CONCENTRATION_ALERT_V1', gs: 'calcHeatConcentrationAlert_' }, { yaml: 'REGIME_TRANSITION_ALERT_V1', gs: 'calcRegimeTransitionAlert_' }, { yaml: 'PORTFOLIO_HEALTH_SCORE_V1', gs: 'calcPortfolioHealthScore_' }, // Proposal50 신규 { yaml: 'EXPORT_GATE_V1', gs: 'calcExportGate_' }, { yaml: 'ROUTING_TRACE_V1', gs: 'buildRoutingTrace_' }, { yaml: 'WATCH_LEDGER_V1', gs: 'buildWatchLedger_' }, { yaml: 'EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1', gs: 'calcExpertJudgmentConsensus_' }, { yaml: 'SMART_CASH_RECOVERY_SELL_ENGINE_V2', gs: 'calcSmartCashRecoverySell_' }, { yaml: 'DETERMINISTIC_SERVING_LOCK_ENGINE_V1', gs: 'calcDeterministicServingLock_' }, { yaml: 'MACRO_REGIME_ADAPTIVE_GATE_V2', gs: 'calcMacroRegimeAdaptiveGate_' }, { yaml: 'MANDATORY_REDUCTION_PLAN_V1', gs: 'calcMandatoryReductionPlan_' }, // Proposal50 P0 Gap 해소 함수 { yaml: 'VALIDATE_ORDER_CONDITION_V1', gs: 'validateOrderCondition_' }, { yaml: 'SHADOW_LEDGER_V1', gs: 'buildShadowLedger_' }, { yaml: 'LLM_SERVING_CONSTRAINT_V1', gs: 'calcLlmServingConstraint_' }, { yaml: 'AVG_TRADE_VALUE_SIGNAL_V1', gs: 'calcAvgTradeValueSignal_' }, { yaml: 'TRIM_PLAN_MIN_CASH_V1', gs: 'calcTrimPlanMinCash_' }, { yaml: 'PREDICTIVE_ALPHA_ENGINE_V1', gs: 'calcPredictiveAlphaEngineV1_' }, { yaml: 'MACRO_EVENT_SYNCHRONIZER_V1', gs: 'calcMacroEventSynchronizerV1_' }, { yaml: 'ANTI_LATE_ENTRY_GATE_V2', gs: 'calcAntiLateEntryGateV2_' }, { yaml: 'CONSISTENCY_VALIDATOR_V2', gs: 'calcConsistencyValidatorV2_' }, { yaml: 'SATELLITE_FAILURE_GATE_V1', gs: 'calcSatelliteFailureGate_' }, { yaml: 'SATELLITE_AGGREGATE_PNL_GATE_V1', gs: 'calcSatelliteAggregatePnlGate_' }, { yaml: 'CLA_REGIME_EXIT_CONDITION_V1', gs: 'calcClaRegimeExitCondition_' }, { yaml: 'EVENT_RISK_HOLD_GATE_V1', gs: 'calcEventRiskHoldGate_' }, { yaml: 'SECTOR_ROTATION_MOMENTUM_V1', gs: 'calcSectorRotationMomentum_' }, // Monthly Batch 피드백 루프 { yaml: 'TRADE_QUALITY_SCORER_V1', gs: 'calcTradeQualityScorer_' }, { yaml: 'PATTERN_BLACKLIST_AUTO_V1', gs: 'calcPatternBlacklistAuto_' }, { yaml: 'ALPHA_FEEDBACK_LOOP_V1', gs: 'calcAlphaFeedbackLoop_' }, // Proposal51 신규 { yaml: 'SELL_PRICE_SANITY_V2', gs: 'calcSellPriceSanityV2_' }, { yaml: 'EXPORT_GATE_V2', gs: 'calcExportGate_' }, { yaml: 'SEMICONDUCTOR_CLUSTER_SYNC_V1', gs: 'syncSemiconductorCluster_' }, { yaml: 'PROACTIVE_SELL_RADAR_V2', gs: 'calcProactiveSellRadarV2_' }, { yaml: 'ANTI_LATE_ENTRY_GATE_V3', gs: 'applyAlegGate4And5_' }, { yaml: 'PRICE_HIERARCHY_LOCK_V1', gs: 'applyPriceHierarchyLockAll_' }, { yaml: 'DATA_QUALITY_GATE_V2', gs: 'calcDataQualityGateV2_' }, { yaml: 'CASH_RECOVERY_DISPLAY_LOCK_V1', gs: 'calcCashRecoveryDisplayLock_' }, // Proposal53 신규 { yaml: 'FUNDAMENTAL_QUALITY_GATE_V1', gs: 'calcFundamentalQualityGateV1_' }, { yaml: 'HORIZON_ALLOCATION_LOCK_V1', gs: 'calcHorizonAllocationLockV1_' }, { yaml: 'SMART_MONEY_LIQUIDITY_GATE_V1', gs: 'calcSmartMoneyLiquidityGateV1_' }, { yaml: 'ROUTING_SERVING_DECISION_TRACE_V2', gs: 'buildRoutingServingTraceV2_' }, { yaml: 'FUNDAMENTAL_MULTI_FACTOR_SCORE_V2', gs: 'calcFundamentalMultiFactorScoreV2_' }, { yaml: 'EARNINGS_GROWTH_QUALITY_GATE_V1', gs: 'calcEarningsGrowthQualityGateV1_' }, { yaml: 'MARKET_SHARE_MOMENTUM_PROXY_V1', gs: 'calcMarketShareMomentumProxyV1_' }, { yaml: 'CASHFLOW_STABILITY_GATE_V1', gs: 'calcCashflowStabilityGateV1_' }, { yaml: 'ROUTING_DECISION_EXPLAIN_LOCK_V1', gs: 'calcRoutingExplainLockV1_' }, ]; var implemented = REQUIRED.filter(function(req) { try { return typeof eval(req.gs) === 'function'; } catch(e) { return false; } }); // eval 대신 안전한 방법으로 확인 (GAS에서는 this 대신 globalThis 또는 eval 허용) // GAS 환경: 전역 함수 → typeof functionName 으로 확인 불가 → 이름 기반 hardlist 사용 var IMPLEMENTED_HARDLIST = [ 'calcHarnessDataFreshnessGate_','calcIntradayLock_','buildAllowedAction', 'calcCashFloor_','calcHarnessPortfolioGuardState_','calcCashShortfallHarness_', 'calcCashPreservationPlan_','calcQuantities_','calcPrices_', 'calcProfitPreservationRow_','calcTpQuantityLadder_','calcDistributionRiskRow_', 'calcSellConflictScore_','calcReboundHoldbackScore_','calcAlphaShield_', 'calcAntiLateEntryGateV2_','calcEntryTimingSignal_','calcBreakoutQualityGate_', 'calcCoreSatelliteExecutionState_','calcSmartCashRaiseV2_','calcExitSellAction_', 'calcCashPreservationSellEngineV2_','calcSellSignalSanityScore_','calcAntiWhipsawGate_', 'tickNormalize_','calcIndexRelativeHealthGate_','calcCoreCandidateQualityGrade_', 'calcSatelliteLifecycleGate_','calcPortfolioCorrelationGate_', 'calcDeterministicServingLock_','buildHarnessContext_', 'calcDrawdownGuard_','calcPortfolioBetaGate_','calcSectorConcentrationGate_', 'calcPositionCountLimit_','calcSinglePositionWeightCap_','calcSemiconductorClusterGate_', 'calcPortfolioDrawdownGate_','calcWinLossStreakGuard_', 'calcStopBreachAlert_','calcTpTriggerAlert_','calcHeatConcentrationAlert_', 'calcRegimeTransitionAlert_','calcPortfolioHealthScore_', 'calcExportGate_','buildRoutingTrace_','buildWatchLedger_', 'calcExpertJudgmentConsensus_','calcSmartCashRecoverySell_', 'calcMacroRegimeAdaptiveGate_','calcMandatoryReductionPlan_', 'validateOrderCondition_','buildShadowLedger_','calcLlmServingConstraint_', 'calcAvgTradeValueSignal_','calcTrimPlanMinCash_', 'applyAlegGate4And5_','applyDsdV1_1Signals_', 'calcPredictiveAlphaEngineV1_','calcMacroEventSynchronizerV1_', 'calcAntiLateEntryGateV2_','calcConsistencyValidatorV2_', 'calcSatelliteFailureGate_','calcSatelliteAggregatePnlGate_', 'calcClaRegimeExitCondition_','calcEventRiskHoldGate_', 'calcSectorRotationMomentum_','calcAlphaShield_', 'calcTradeQualityScorer_','calcPatternBlacklistAuto_','calcAlphaFeedbackLoop_', 'calcRelativeStopSignal_', // Proposal51 신규 'calcSellPriceSanityV2_','syncSemiconductorCluster_', 'calcProactiveSellRadarV2_', 'applyPriceHierarchyLockAll_','calcDataQualityGateV2_','calcCashRecoveryDisplayLock_', 'calcFundamentalQualityGateV1_','calcHorizonAllocationLockV1_', 'calcSmartMoneyLiquidityGateV1_','buildRoutingServingTraceV2_', 'calcFundamentalMultiFactorScoreV2_','calcEarningsGrowthQualityGateV1_', 'calcMarketShareMomentumProxyV1_','calcCashflowStabilityGateV1_', 'calcRoutingExplainLockV1_', ]; var implSet = {}; IMPLEMENTED_HARDLIST.forEach(function(f) { implSet[f] = true; }); var gaps = REQUIRED.filter(function(req) { return !implSet[req.gs]; }); var implCount = REQUIRED.length - gaps.length; var coveragePct = Math.round(implCount / REQUIRED.length * 1000) / 10; var result = { total_required: REQUIRED.length, implemented: implCount, coverage_pct: coveragePct, gaps: gaps.map(function(g) { return { yaml: g.yaml, gs: g.gs }; }), coverage_label: coveragePct >= 95 ? 'FULL' : coveragePct >= 80 ? 'HIGH' : coveragePct >= 60 ? 'MEDIUM' : 'LOW', formula_id: 'YAML_GAS_COVERAGE_AUDIT_ENGINE_V1' }; Logger.log('[COVERAGE_AUDIT] ' + coveragePct + '% (' + implCount + '/' + REQUIRED.length + ')' + (gaps.length > 0 ? ' GAPS: ' + gaps.map(function(g){ return g.yaml; }).join(',') : '')); // settings 탭에 기록 try { var ss = getSpreadsheet_(); var sh = ss.getSheetByName('settings'); if (sh) { var data = sh.getDataRange().getValues(); var found = false; for (var i = 0; i < data.length; i++) { if (String(data[i][0]) === 'coverage_pct') { sh.getRange(i + 1, 2).setValue(coveragePct); found = true; break; } } if (!found) { sh.appendRow(['coverage_pct', coveragePct, 'YAML↔GAS 커버리지 %', new Date().toISOString()]); } } } catch(e) { Logger.log('[COVERAGE_AUDIT] settings 탭 기록 실패: ' + e.message); } return result; } // ═══════════════════════════════════════════════════════════════════════ // [PROPOSAL50] P0-B: MACRO_REGIME_ADAPTIVE_GATE_V2 (MRAG-V2) // 거시·이벤트 위험도 4레이어 → heat_gate_threshold / position_size_scale 동적 조정 // Direction ME2: effective_heat_gate_threshold = ME1 + MRAG-V2 중 더 엄격한 값 // ═══════════════════════════════════════════════════════════════════════ /** * calcMacroRegimeAdaptiveGate_ * LAYER_1 미시(Market Internals) + LAYER_2 거시(Macro) + LAYER_3 글로벌 + LAYER_4 이벤트 * total_mrag_score 0~100 → heat_gate_threshold / position_size_scale 결정론적 조정 */ function calcMacroRegimeAdaptiveGate_(macroJson, mesResult, hApex) { return calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex); } // ═══════════════════════════════════════════════════════════════════════ // [PROPOSAL50] P1-A: ANTI_LATE_ENTRY_GATE V2.1 — GATE_4/GATE_5 추가 // 뒷박 원천 차단 5게이트 완성 (기존 V2의 3게이트 → 5게이트) // ═══════════════════════════════════════════════════════════════════════ /** * applyAlegGate4And5_ * alegRows에 GATE_4(PAE연동) + GATE_5(블랙리스트) 추가. * Direction A2: BLOCK if ANY gate(1~5)=BLOCK */ function applyAlegGate4And5_(alegRows, paeRows, hApex) { return applyAlegGate4And5Impl_(alegRows, paeRows, hApex); } // ═══════════════════════════════════════════════════════════════════════ // [PROPOSAL50] P1-B: DISTRIBUTION_SELL_DETECTOR V1.1 — SIG_7/SIG_8 // 설거지 신호 6개 → 8개, weighted_sum 임계값 5.0/3.0 상향 // ═══════════════════════════════════════════════════════════════════════ /** * applyDsdV1_1Signals_ * dsdRows에 SIG_7/SIG_8 추가 적용. * Direction B3: weighted_sum >= 5.0 → DISTRIBUTION_CONFIRMED */ function applyDsdV1_1Signals_(dsdRows, dfMap) { (dsdRows || []).forEach(function(dsdRow) { var df = dfMap[dsdRow.ticker] || {}; var close_ = toNumber_(df['Close'] || df.close) || 0; var open_ = toNumber_(df['Open'] || df.open) || 0; // SIG_7: 연속 양봉 후 음봉 반전 (w=1.5) var prev3Bull = df['Prev3D_AllBullish'] === true || String(df['Prev3D_AllBullish'] || '').toUpperCase() === 'TRUE'; var todayBear = close_ < open_ && close_ > 0 && open_ > 0; var sig7 = prev3Bull && todayBear; dsdRow.sig_7_reversal = sig7; if (sig7) dsdRow.weighted_sum = (toNumber_(dsdRow.weighted_sum) || 0) + 1.5; // SIG_8: 개인집중유입 + 기관매도 (w=1.5) — 데이터 없으면 w=0 var retailR = toNumber_(df['Retail_Buy_Ratio_5D'] || df.retail_buy_ratio_5d) || 0; var instS = toNumber_(df['Inst_5D'] || df.inst_5d) || 0; var sig8 = retailR > 0.70 && instS < 0; dsdRow.sig_8_retail_inflow = sig8; if (sig8) dsdRow.weighted_sum = (toNumber_(dsdRow.weighted_sum) || 0) + 1.5; // V1.1 임계값 재적용 var ws = toNumber_(dsdRow.weighted_sum) || 0; dsdRow.distribution_verdict = ws >= 5.0 ? 'DISTRIBUTION_CONFIRMED' : ws >= 3.0 ? 'DISTRIBUTION_WARNING' : 'NO_SIGNAL'; // 조기 경보 V2: (SIG_1 OR SIG_2) + RSI14 >= 70 var rsi14 = toNumber_(df['RSI14'] || df.rsi14) || 0; dsdRow.early_warning_v2 = (dsdRow.sig_1 || dsdRow.sig_2) && rsi14 >= 70; dsdRow.dsd_version = 'V1.1'; }); return dsdRows; } // ═══════════════════════════════════════════════════════════════════════ // [PROPOSAL50] P1-C: MANDATORY_REDUCTION_PLAN_V1 // 반도체 클러스터 한도 2배 초과 → 4주 의무 감축 계획 결정론적 산출 // ═══════════════════════════════════════════════════════════════════════ /** * calcMandatoryReductionPlan_ * Direction O2: mandatory_reduction_json을 하네스 확정값으로 잠금. */ function calcMandatoryReductionPlan_(semiconductorClusterGate, holdings, dfMap, h3, totalAsset) { function toDateYmd_(v) { if (!v) return null; if (typeof v === 'string') return v.slice(0, 10); if (Object.prototype.toString.call(v) === '[object Date]' && !isNaN(v.getTime())) { return Utilities.formatDate(v, 'Asia/Seoul', 'yyyy-MM-dd'); } return null; } // [PROPOSAL51-FIX] calcSemiconductorClusterGate_ 반환키는 combined_pct (cluster_pct 아님) var clusterPct = toNumber_((semiconductorClusterGate || {}).combined_pct || (semiconductorClusterGate || {}).cluster_pct) || 0; var clusterLimit = toNumber_((semiconductorClusterGate || {}).cap_pct || (semiconductorClusterGate || {}).cluster_limit_pct) || 25; if (clusterPct <= clusterLimit * 2.0) { return { is_mandatory: false, cluster_pct: clusterPct, cluster_limit_pct: clusterLimit, formula_id: 'MANDATORY_REDUCTION_PLAN_V1' }; } var excessPct = clusterPct - clusterLimit; var weeklyReducPct = Math.ceil(excessPct / 4 * 10) / 10; var weeklyReducKrw = Math.round(totalAsset * weeklyReducPct / 100); var SEMI_TICKERS = ['005930','000660','229200','091160']; var holdMap = {}; (holdings || []).forEach(function(h) { holdMap[h.ticker] = h; }); var sellQtyMap = {}; ((h3 && h3.sellQty) || []).forEach(function(sq) { sellQtyMap[sq.ticker] = sq; }); var reduction = []; // 1순위: RS_BROKEN (holdings || []).filter(function(h) { var df = dfMap[h.ticker] || {}; return SEMI_TICKERS.indexOf(h.ticker) >= 0 && String(df['RS_Verdict'] || df.rs_verdict || '').toUpperCase() === 'BROKEN'; }).forEach(function(h) { reduction.push({ priority: 1, reason: 'RS_BROKEN', ticker: h.ticker, name: h.name || '', suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null }); }); // 2순위: ETF (holdings || []).filter(function(h) { return SEMI_TICKERS.indexOf(h.ticker) >= 0 && (h.name && (h.name.indexOf('KODEX') >= 0 || h.name.indexOf('TIGER') >= 0 || h.name.indexOf('ETF') >= 0 || h.ticker === '229200')); }).filter(function(h) { return !reduction.some(function(r) { return r.ticker === h.ticker; }); }) .forEach(function(h) { reduction.push({ priority: 2, reason: 'ETF_PREFERRED', ticker: h.ticker, name: h.name || '', suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null }); }); // 3순위: APEX_SUPER (holdings || []).filter(function(h) { var df = dfMap[h.ticker] || {}; return SEMI_TICKERS.indexOf(h.ticker) >= 0 && String(df['Profit_Lock_Stage'] || df.profit_lock_stage || '').toUpperCase() === 'APEX_SUPER'; }).filter(function(h) { return !reduction.some(function(r) { return r.ticker === h.ticker; }); }) .forEach(function(h) { reduction.push({ priority: 3, reason: 'APEX_SUPER_TRAILING', ticker: h.ticker, name: h.name || '', suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null }); }); var completeDate = addBusinessDays_(new Date(), 20); // 4주 × 5영업일 return { is_mandatory: true, cluster_pct: clusterPct, cluster_limit_pct: clusterLimit, current_excess_pct: Math.round(excessPct * 10) / 10, weekly_reduction_target_pct: weeklyReducPct, weekly_reduction_target_krw: weeklyReducKrw, weeks_to_normalize: 4, estimated_completion_date: toDateYmd_(completeDate), reduction_priority: reduction, formula_id: 'MANDATORY_REDUCTION_PLAN_V1' }; } // ═══════════════════════════════════════════════════════════════════════ // [PROPOSAL51] P0-C: SEMICONDUCTOR_CLUSTER_SYNC_V1 // cluster gate ↔ mandatory_reduction_plan 단일 소스 동기화 // ═══════════════════════════════════════════════════════════════════════ /** * syncSemiconductorCluster_ * SEMICONDUCTOR_CLUSTER_SYNC_V1: cluster_gate ↔ mandatory_reduction_json 정합성 검증 및 자동 교정 * - combined_pct > cap_pct * 2이면 is_mandatory=true 강제 * - combined_pct <= cap_pct * 2이면 is_mandatory=false 강제 * @param {Object} hApex — mandatory_reduction_json 포함 * @return {{ status, corrected, before_is_mandatory, after_is_mandatory, cluster_pct, threshold_pct }} */ function syncSemiconductorCluster_(hApex) { var mrj = (hApex && hApex.mandatory_reduction_json) || {}; var clusterPct = toNumber_(mrj.cluster_pct) || 0; var clusterLimit = toNumber_(mrj.cluster_limit_pct) || 25; var threshold = clusterLimit * 2.0; var shouldBeMandatory = clusterPct > threshold; var wasMandatory = mrj.is_mandatory === true; var syncStatus, corrected; if (shouldBeMandatory === wasMandatory) { syncStatus = 'SYNCED'; corrected = false; } else { syncStatus = 'CORRECTED'; corrected = true; // 인라인 교정 mrj.is_mandatory = shouldBeMandatory; if (shouldBeMandatory) { // 의무 감축 활성화 시 최소 필드 보장 mrj.current_excess_pct = Math.round((clusterPct - clusterLimit) * 10) / 10; } else { // 의무 감축 비활성화 — 세부 필드 제거 delete mrj.current_excess_pct; delete mrj.weekly_reduction_target_pct; delete mrj.weekly_reduction_target_krw; delete mrj.reduction_priority; } hApex.mandatory_reduction_json = mrj; Logger.log('[SCRSV1] CLUSTER_SYNC 교정: is_mandatory ' + wasMandatory + ' → ' + shouldBeMandatory + ' (cluster=' + clusterPct + '%, threshold=' + threshold + '%)'); } return { formula_id: 'SEMICONDUCTOR_CLUSTER_SYNC_V1', status: syncStatus, corrected: corrected, cluster_pct: clusterPct, threshold_pct: threshold, cap_pct: clusterLimit, before_is_mandatory: wasMandatory, after_is_mandatory: shouldBeMandatory }; } /** * HS007: validateOrderCondition_ * 주문 조건 텍스트에 다중 조건 접속사가 포함되면 INVALID_MULTI_CONDITION 반환. * HTS 자동주문은 단일 지정가만 허용 — 접속사 복합 조건은 HTS 오입력 원인. */ function validateOrderCondition_(text) { if (!text || typeof text !== 'string') { return { valid: true, status: 'OK', matched_conjunctions: [], formula_id: 'VALIDATE_ORDER_CONDITION_V1' }; } var MULTI_CONDITION_PATTERNS = [ '또는', '혹은', '동시 충족', '동시충족', '실패 시', '실패시', '회복 실패', '회복실패', '돌파 실패', '돌파실패', '이탈 또는', '초과 또는', '또는 이하', '또는 이상', '이거나', '이면서' ]; var matched = MULTI_CONDITION_PATTERNS.filter(function(p) { return text.indexOf(p) >= 0; }); if (matched.length > 0) { return { valid: false, status: 'INVALID_MULTI_CONDITION', matched_conjunctions: matched, resolution: '단일 가격 조건만 기재 (예: "종가 196,500원 이탈 시")', formula_id: 'VALIDATE_ORDER_CONDITION_V1' }; } return { valid: true, status: 'OK', matched_conjunctions: [], formula_id: 'VALIDATE_ORDER_CONDITION_V1' }; } /** * H10 (HS010_REVISED): buildShadowLedger_ * BLOCKED/INVALID 블루프린트를 그림자 원장으로 분리. * 차단 여부와 무관하게 산출 지표를 투명하게 보존 — 사용자의 사후 평가·오버라이드 지원. */ function buildShadowLedger_(blueprints, dfMap) { // THIN_ADAPTER: [stop_loss/sizing/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:check_sell_price_sanity dfMap = dfMap || {}; var ledger = []; var bpRows = Array.isArray(blueprints) ? blueprints : []; bpRows.forEach(function(bp) { var isBlocked = bp.validation_status === 'BLOCKED' || bp.validation_status === 'INVALID' || String(bp.validation_status || '').indexOf('INVALID') === 0; if (!isBlocked) return; var df = dfMap[bp.ticker] || {}; ledger.push({ ticker: bp.ticker, name: bp.name || df.name || '', block_reason: bp.rationale_code || bp.validation_status || 'BLOCKED', order_type: bp.order_type || '', limit_price_calc: bp.limit_price || null, ["stop_loss_calc"]: bp["stop_loss"] || df["stop_loss_price"] || null, ["take_profit_calc"]: bp["take_profit"] || df["tp1_price"] || null, base_qty_calc: bp.qty || df.base_qty || null, value_at_risk_krw: bp.value_at_risk_krw || null, override_possible: true, formula_id: 'SHADOW_LEDGER_V1' }); }); return { shadow_ledger: ledger, blocked_count: ledger.length, formula_id: 'SHADOW_LEDGER_V1' }; } /** * D2: calcLlmServingConstraint_ * LLM 12가지 금지행동 체크리스트 — 보고서 조립 직전 실행. * 하나라도 위반 가능성이 있으면 INVALID_LLM_OVERRIDE 태그를 반환하여 보고서에 표기. */ function calcLlmServingConstraint_(hApex) { var h = hApex || {}; var violations = []; // Check 1: 미등록 공식 사용 가능성 — serving_lock_json numeric_generation_allowed var sLock = h.serving_lock_json || {}; var budget = sLock.llm_serving_budget || {}; if (budget.numeric_generation_allowed !== 0) { violations.push({ check: 1, rule: '미등록 공식으로 지정가/수량 산출', status: 'WARN_NOT_LOCKED' }); } // Check 2: BLOCK 판정 우회 — hts_entry_allowed=false인데 blueprint PASS 존재 불가 var exportGate = h.export_gate_json || {}; if (exportGate.hts_entry_allowed === false) { var blueprints = h.order_blueprint_json || []; var passCount = (Array.isArray(blueprints) ? blueprints : []).filter(function(b) { return b.validation_status === 'PASS'; }).length; if (passCount > 0) { violations.push({ check: 2, rule: 'hts_entry_allowed=false 상태에서 PASS blueprint 존재', status: 'VIOLATION' }); } } // Check 3: SELL_PRICE_SANITY INVALID 가격 복원 위험 — INVALID 종목이 shadow_ledger에 없으면 경고 var shadowLedger = h.shadow_ledger_json || {}; var invalidBlueprints = (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : []) .filter(function(b) { return String(b.validation_status || '').indexOf('INVALID') === 0; }); if (invalidBlueprints.length > 0 && (!shadowLedger.blocked_count || shadowLedger.blocked_count === 0)) { violations.push({ check: 3, rule: 'INVALID blueprint가 Shadow Ledger에 미포함', status: 'VIOLATION' }); } // Check 5: K2 반등 대기 수량 — scrs_v2_json에 rebound_wait_qty가 있으면 분리 표기 의무 var scrs = h.scrs_v2_json || {}; var selectedCombo = Array.isArray(scrs.selected_combo) ? scrs.selected_combo : []; if (selectedCombo.length > 0) { var hasRebound = selectedCombo.some(function(c) { return c.rebound_wait_qty > 0; }); if (hasRebound && !scrs._display_split_confirmed) { violations.push({ check: 5, rule: 'K2 rebound_wait_qty 분리 미표기 위험', status: 'WARN' }); } } // Check 9: consistency_score < 90이면 보고서 계속 생성 금지 var asResult = h.account_snapshot_result || {}; var cScore = asResult.consistency_score; if (typeof cScore === 'number' && cScore < 90) { violations.push({ check: 9, rule: 'consistency_score=' + cScore + ' < 90 (ABORT 필요)', status: 'VIOLATION' }); } // Check 10: mega_sell_alert=TRUE이면 BUY/ADD_ON 금지 var macroJson = h.macro_event_json || {}; if (macroJson.mega_sell_alert === true || macroJson.mega_sell_alert === 'TRUE') { var buyBlueprints = (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : []) .filter(function(b) { return b.order_type === 'BUY' || b.order_type === 'ADD_ON'; }); if (buyBlueprints.length > 0) { violations.push({ check: 10, rule: 'mega_sell_alert=TRUE 상태에서 BUY/ADD_ON blueprint 존재', status: 'VIOLATION' }); } } // Check 11: synthesis_verdict=BEARISH 종목에 BUY 금지 var paeRows = h.predictive_alpha_json || []; var bearishTickers = (Array.isArray(paeRows) ? paeRows : []) .filter(function(r) { return r.synthesis_verdict === 'BEARISH'; }) .map(function(r) { return r.ticker; }); if (bearishTickers.length > 0) { (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : []).forEach(function(b) { if ((b.order_type === 'BUY' || b.order_type === 'ADD_ON') && bearishTickers.indexOf(b.ticker) >= 0) { violations.push({ check: 11, rule: 'synthesis_verdict=BEARISH 종목 BUY blueprint: ' + b.ticker, status: 'VIOLATION' }); } }); } var constraintStatus = violations.some(function(v) { return v.status === 'VIOLATION'; }) ? 'INVALID_LLM_OVERRIDE' : violations.length > 0 ? 'WARN' : 'PASS'; return { constraint_status: constraintStatus, violations: violations, violation_count: violations.filter(function(v) { return v.status === 'VIOLATION'; }).length, warn_count: violations.filter(function(v) { return v.status === 'WARN' || v.status === 'WARN_NOT_LOCKED'; }).length, total_checks: 12, formula_id: 'LLM_SERVING_CONSTRAINT_V1' }; } /** * H6: calcAvgTradeValueSignal_ * secular_leader(005930·000660) PROFIT_LOCK_STAGE_20 구간에서 * 5일 평균 거래대금 > 20일 평균 × 3.0이면 과열신호 +1 판정. */ function calcAvgTradeValueSignal_(ticker, df) { df = df || {}; var SECULAR_TICKERS = ['005930', '000660']; var isSecular = SECULAR_TICKERS.indexOf(String(ticker || '')) >= 0; var stage = String(df.profit_lock_stage || df.Profit_Lock_Stage || '').toUpperCase(); var avgVal5d = toNumber_(df.avg_trade_val_5d || df.avgTradeVal5d) || 0; var avgVal20d = toNumber_(df.avg_trade_val_20d || df.avgTradeVal20d) || 0; if (!isSecular || stage !== 'PROFIT_LOCK_20' || avgVal20d <= 0) { return { ticker: ticker, applicable: false, signal: 'NOT_APPLICABLE', avg_trade_val_5d: avgVal5d, avg_trade_val_20d: avgVal20d, overheat_triggered: false, formula_id: 'AVG_TRADE_VALUE_SIGNAL_V1' }; } var ratio = avgVal5d / avgVal20d; var overheat = ratio >= 3.0; return { ticker: ticker, applicable: true, signal: overheat ? 'OVERHEAT_TRADE_VALUE' : 'NORMAL', avg_trade_val_5d: avgVal5d, avg_trade_val_20d: avgVal20d, ratio_5d_vs_20d: Math.round(ratio * 100) / 100, overheat_triggered: overheat, overheat_score_add: overheat ? 1 : 0, threshold: 3.0, formula_id: 'AVG_TRADE_VALUE_SIGNAL_V1' }; } /** * G2: calcTrimPlanMinCash_ * 최소 현금(cash_floor) 달성을 위한 결정론적 TRIM 계획 산출. * H2 매도후보 순위(sell_priority) 그대로 종목 순서를 결정 — LLM 임의 선택 금지. */ function calcTrimPlanMinCash_(holdings, dfMap, cashShortfallInfo, sellPriorityList) { dfMap = dfMap || {}; var shortfall = toNumber_((cashShortfallInfo || {}).cash_shortfall_min_krw) || 0; var plan = []; var accumulatedKrw = 0; var holdingRows = Array.isArray(holdings) ? holdings : []; var priorityRows = Array.isArray(sellPriorityList) ? sellPriorityList : []; priorityRows.forEach(function(sp) { if (accumulatedKrw >= shortfall) return; var h = holdingRows.find(function(x) { return x.ticker === sp.ticker; }) || {}; var df = dfMap[sp.ticker] || {}; var avgCost = toNumber_(h.avg_cost || h.average_cost) || 0; var qty = toNumber_(h.qty || h.quantity) || 0; if (qty === 0 || avgCost === 0) { plan.push({ priority: sp.priority || plan.length + 1, ticker: sp.ticker, name: sp.name || df.name || '', sell_qty: 'CAPTURE_REQUIRED', estimated_sell_krw: 0, sell_price_ref: null, accumulated_krw: accumulatedKrw, shortfall_covered: false, note: 'CAPTURE_REQUIRED: qty/cost 미확정' }); return; } var closePrice = toNumber_(df.close || df.close_price) || avgCost; var remaining = shortfall - accumulatedKrw; var neededQty = Math.ceil(remaining / closePrice); var sellQty = Math.min(neededQty, qty); var estimatedKrw = sellQty * closePrice; accumulatedKrw += estimatedKrw; plan.push({ priority: sp.priority || plan.length + 1, ticker: sp.ticker, name: sp.name || df.name || '', sell_qty: sellQty, estimated_sell_krw: Math.round(estimatedKrw), sell_price_ref: closePrice, accumulated_krw: Math.round(accumulatedKrw), shortfall_covered: accumulatedKrw >= shortfall, note: accumulatedKrw >= shortfall ? 'SHORTFALL_MET' : 'PARTIAL' }); }); return { cash_shortfall_min_krw: Math.round(shortfall), plan: plan, total_plan_krw: Math.round(accumulatedKrw), shortfall_fully_covered: accumulatedKrw >= shortfall, is_plan_only: true, hts_order_required: 'order_blueprint_json.validation_status 기준으로만 판단', formula_id: 'TRIM_PLAN_MIN_CASH_V1' }; } // ═══════════════════════════════════════════════════════════════════════════════ // [PROPOSAL50] F1 — TRADE_QUALITY_SCORER_V1 // 실행된 매수·매도를 T+5/T+20 기준으로 자동 채점. // trade_quality_history 시트를 읽어 미채점 레코드를 업데이트하고 결과 배열 반환. // ═══════════════════════════════════════════════════════════════════════════════ /** * calcTradeQualityScorer_ * trade_quality_history 시트에서 미채점 레코드를 배치 처리. * BUY: velocity/ma20/volume/t5/t20 각 20점 합산 (100점 만점) * SELL: above_ma20/above_cost/not_too_early/cash_goal_met 각 25점 합산 (100점 만점) */ function calcTradeQualityScorer_(ss) { try { ss = ss || getSpreadsheet_(); var sh = ss.getSheetByName('trade_quality_history'); if (!sh) { Logger.log('[F1] trade_quality_history 시트 없음'); return { status: 'SHEET_NOT_FOUND', scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' }; } var data = sh.getDataRange().getValues(); if (data.length < 2) { return { status: 'NO_DATA', scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' }; } var header = data[0]; var COL = {}; header.forEach(function(h, i) { COL[String(h).trim()] = i; }); // 필수 컬럼 확인 var REQ = ['ticker', 'action', 'scored']; for (var ri = 0; ri < REQ.length; ri++) { if (COL[REQ[ri]] == null) { Logger.log('[F1] 필수 컬럼 누락: ' + REQ[ri]); return { status: 'COLUMN_MISSING', missing: REQ[ri], scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' }; } } // 현재 종가 맵 (T+5/T+20 평가용) 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 dri = 1; dri < dfData.length; dri++) { var tk = String(dfData[dri][tCol] || '').trim(); var cl = parseFloat(String(dfData[dri][cCol] || '')); if (tk && !isNaN(cl) && cl > 0) priceMap[tk] = cl; } } } } var todayMs = new Date().getTime(); var scoredResults = []; var scoredThisRun = 0; for (var i = 1; i < data.length; i++) { var row = data[i]; var alreadyScored = String(row[COL['scored']] || '').toUpperCase(); if (alreadyScored === 'TRUE' || alreadyScored === 'SCORED') continue; var ticker = String(row[COL['ticker']] || '').trim(); var action = String(row[COL['action']] || '').toUpperCase(); if (!ticker) continue; var entryDate = row[COL['entry_date'] != null ? COL['entry_date'] : -1]; var daysSinceEntry = entryDate ? (todayMs - new Date(entryDate).getTime()) / 86400000 : 0; // T+5 이상 경과해야 채점 (T+20 필드는 optional) if (COL['entry_date'] != null && daysSinceEntry < 7) continue; var score = 0; var subscores = {}; var feedbackTag = 'GOOD_EXECUTION'; if (action === 'BUY') { // 매수 품질 채점 var velocity1d = parseFloat(String(row[COL['velocity_1d_at_entry'] != null ? COL['velocity_1d_at_entry'] : -1] || '')); var entryPrice = parseFloat(String(row[COL['entry_price'] != null ? COL['entry_price'] : -1] || '')); var ma20Entry = parseFloat(String(row[COL['ma20_at_entry'] != null ? COL['ma20_at_entry'] : -1] || '')); var volRatio = parseFloat(String(row[COL['volume_ratio_at_entry'] != null ? COL['volume_ratio_at_entry'] : -1] || '')); var t5RetPct = parseFloat(String(row[COL['t5_return_pct'] != null ? COL['t5_return_pct'] : -1] || '')); var t20VsCore = parseFloat(String(row[COL['t20_vs_core_pctp'] != null ? COL['t20_vs_core_pctp'] : -1] || '')); // velocity_ok: 진입일 속도 < 1% (추격 아님) if (!isNaN(velocity1d) && velocity1d < 1) { score += 20; subscores.velocity_ok = 20; } else subscores.velocity_ok = 0; // ma20_proximity: 진입가 ≤ MA20 × 1.01 if (!isNaN(entryPrice) && !isNaN(ma20Entry) && ma20Entry > 0 && entryPrice <= ma20Entry * 1.01) { score += 20; subscores.ma20_proximity = 20; } else subscores.ma20_proximity = 0; // volume_confirm: 거래량비율 ≥ 1.2 if (!isNaN(volRatio) && volRatio >= 1.2) { score += 20; subscores.volume_confirm = 20; } else subscores.volume_confirm = 0; // t5_positive: T+5 수익률 > 0 if (!isNaN(t5RetPct) && t5RetPct > 0) { score += 20; subscores.t5_positive = 20; } else subscores.t5_positive = 0; // t20_alpha: T+20 대비 코어 초과 > 0 if (!isNaN(t20VsCore) && t20VsCore > 0) { score += 20; subscores.t20_alpha = 20; } else subscores.t20_alpha = 0; // 피드백 태그 if (subscores.velocity_ok === 0 && subscores.ma20_proximity === 0) feedbackTag = 'CHASE_ENTRY'; else if (subscores.t5_positive === 0 && subscores.t20_alpha === 0) feedbackTag = 'DISTRIBUTION_ENTRY'; } else if (action === 'SELL') { // 매도 품질 채점 var sellPrice = parseFloat(String(row[COL['sell_price'] != null ? COL['sell_price'] : -1] || '')); var ma20Sell = parseFloat(String(row[COL['ma20_at_sell'] != null ? COL['ma20_at_sell'] : -1] || '')); var avgCost = parseFloat(String(row[COL['average_cost'] != null ? COL['average_cost'] : -1] || '')); var priceT5After = parseFloat(String(row[COL['price_t5_after_sell'] != null ? COL['price_t5_after_sell'] : -1] || '')); var cashRecov = parseFloat(String(row[COL['cash_recovered_krw'] != null ? COL['cash_recovered_krw'] : -1] || '')); var cashGoal = parseFloat(String(row[COL['cash_shortfall_min_krw'] != null ? COL['cash_shortfall_min_krw'] : -1] || '')); // above_ma20: 매도가 ≥ MA20 × 0.99 if (!isNaN(sellPrice) && !isNaN(ma20Sell) && ma20Sell > 0 && sellPrice >= ma20Sell * 0.99) { score += 25; subscores.above_ma20 = 25; } else subscores.above_ma20 = 0; // above_cost: 매도가 ≥ 평단 if (!isNaN(sellPrice) && !isNaN(avgCost) && avgCost > 0 && sellPrice >= avgCost) { score += 25; subscores.above_cost = 25; } else subscores.above_cost = 0; // not_too_early: T+5 사후 종가가 없거나 매도가 이상 if (isNaN(priceT5After) || priceT5After <= sellPrice) { score += 25; subscores.not_too_early = 25; } else subscores.not_too_early = 0; // cash_goal_met: 실제 회수액 ≥ 목표 부족분 if (!isNaN(cashRecov) && !isNaN(cashGoal) && cashGoal > 0 && cashRecov >= cashGoal) { score += 25; subscores.cash_goal_met = 25; } else subscores.cash_goal_met = 0; // 피드백 태그 if (subscores.above_cost === 0) feedbackTag = 'PANIC_EXIT'; else if (subscores.not_too_early === 0) feedbackTag = 'OVERSOLD_PANIC'; } else { continue; // BUY/SELL 이외 레코드 스킵 } // 등급 결정 var grade; if (score >= 90) grade = 'EXCELLENT'; else if (score >= 75) grade = 'GOOD'; else if (score >= 60) grade = 'ACCEPTABLE'; else if (score >= 40) grade = 'POOR'; else grade = 'CRITICAL'; if (grade === 'POOR' || grade === 'CRITICAL') { feedbackTag = score < 40 ? 'PATTERN_ALERT' : 'CHASE_ENTRY_OR_PANIC_EXIT'; } else if (grade === 'EXCELLENT' || grade === 'GOOD') { feedbackTag = 'GOOD_EXECUTION'; } // 시트 업데이트 var scoreCol = COL['score'] != null ? COL['score'] + 1 : null; var gradeCol = COL['grade'] != null ? COL['grade'] + 1 : null; var fbTagCol = COL['feedback_tag'] != null ? COL['feedback_tag'] + 1 : null; var scoredCol = COL['scored'] != null ? COL['scored'] + 1 : null; if (scoreCol) sh.getRange(i + 1, scoreCol).setValue(score); if (gradeCol) sh.getRange(i + 1, gradeCol).setValue(grade); if (fbTagCol) sh.getRange(i + 1, fbTagCol).setValue(feedbackTag); if (scoredCol) sh.getRange(i + 1, scoredCol).setValue('SCORED'); scoredResults.push({ row: i, ticker: ticker, action: action, score: score, grade: grade, feedback_tag: feedbackTag, subscores: subscores, formula_id: 'TRADE_QUALITY_SCORER_V1' }); scoredThisRun++; } // 전체 기록 집계 (기존 채점 포함) var allResults = []; var freshData = sh.getDataRange().getValues(); for (var j = 1; j < freshData.length; j++) { var r = freshData[j]; var sc = String(r[COL['scored']] || '').toUpperCase(); if (sc !== 'TRUE' && sc !== 'SCORED') continue; allResults.push({ ticker: String(r[COL['ticker']] || '').trim(), action: String(r[COL['action']] || '').toUpperCase(), score: parseFloat(String(r[COL['score']] || '')) || 0, grade: String(r[COL['grade']] || 'UNKNOWN'), feedback_tag: String(r[COL['feedback_tag']] || '') }); } Logger.log('[F1] calcTradeQualityScorer_ 완료: 이번 채점=' + scoredThisRun + '건, 전체=' + allResults.length + '건'); // F2: F1 완료 직후 블랙리스트 자동 갱신 (F1 → F2 파이프라인) try { calcPatternBlacklistAuto_(allResults); } catch (pbErr) { Logger.log('[F1] calcPatternBlacklistAuto_ 연동 오류: ' + pbErr.message); } var f1Result = { status: 'OK', scored_count: scoredThisRun, total_records: allResults.length, trade_quality: allResults, last_computed: new Date().toISOString(), formula_id: 'TRADE_QUALITY_SCORER_V1' }; // settings 시트에 trade_quality_json 캐시 저장 (harness_rows 일간 출력용) // 셀 50K 한도 초과 방지: trade_quality 최근 100건만 저장 try { var setSh = ss.getSheetByName('settings'); if (setSh) { var sData = setSh.getDataRange().getValues(); var updated = false; var f1Slim = Object.assign({}, f1Result, { trade_quality: (f1Result.trade_quality || []).slice(-100) }); var serialized = JSON.stringify(f1Slim); for (var si = 0; si < sData.length; si++) { if (String(sData[si][0] || '').trim() === 'trade_quality_json') { setSh.getRange(si + 1, 2).setValue(serialized); updated = true; break; } } if (!updated) setSh.appendRow(['trade_quality_json', serialized]); } } catch(writeErr) { Logger.log('[F1] settings 시트 기록 실패: ' + writeErr.message); } return f1Result; } catch(e) { Logger.log('[F1] calcTradeQualityScorer_ 오류: ' + e.message); return { status: 'ERROR', error: e.message, scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' }; } } // ═══════════════════════════════════════════════════════════════════════════════ // [PROPOSAL50] F2 — PATTERN_BLACKLIST_AUTO_V1 // 동일 ticker POOR/CRITICAL 3회 누적 → PATTERN_BLACKLIST_TRIGGERED // 3회 연속 GOOD(75+) 달성 시 해제 // ═══════════════════════════════════════════════════════════════════════════════ /** * calcPatternBlacklistAuto_ * trade_quality_json 배열을 받아 ticker별 POOR/CRITICAL 누적 횟수를 계산. * 3회 이상이면 PATTERN_BLACKLIST_TRIGGERED, 3회 연속 GOOD 이상이면 해제. * 결과를 settings 시트의 pattern_blacklist_json에 기록. */ function calcPatternBlacklistAuto_(tradeQualityHistory) { try { var history = Array.isArray(tradeQualityHistory) ? tradeQualityHistory : []; // ticker별 그룹화 var tickerMap = {}; history.forEach(function(rec) { var tk = String(rec.ticker || '').trim(); if (!tk) return; if (!tickerMap[tk]) tickerMap[tk] = []; tickerMap[tk].push({ grade: String(rec.grade || '').toUpperCase(), score: typeof rec.score === 'number' ? rec.score : (parseFloat(String(rec.score || '')) || 0) }); }); var blacklistEntries = []; var triggeredCount = 0; Object.keys(tickerMap).forEach(function(ticker) { var records = tickerMap[ticker]; // POOR/CRITICAL 누적 카운트 var poorCriticalCount = records.filter(function(r) { return r.grade === 'POOR' || r.grade === 'CRITICAL'; }).length; // 해제 조건: 마지막 3건이 모두 GOOD(75+) 이상 var releaseMet = false; if (records.length >= 3) { var last3 = records.slice(-3); releaseMet = last3.every(function(r) { return (r.grade === 'GOOD' || r.grade === 'EXCELLENT') && r.score >= 75; }); } var status; if (releaseMet && poorCriticalCount >= 3) { status = 'CLEAR'; // 블랙리스트 해제 } else if (poorCriticalCount >= 3) { status = 'TRIGGERED'; triggeredCount++; } else { status = 'CLEAR'; } blacklistEntries.push({ ticker: ticker, pattern_blacklist_status: status, accumulated_poor_count: poorCriticalCount, total_records: records.length, release_condition_met: releaseMet, saqg_override: status === 'TRIGGERED' ? 'EXCLUDED' : 'NO_CHANGE', alpha_score_cap: status === 'TRIGGERED' ? 50 : null, formula_id: 'PATTERN_BLACKLIST_AUTO_V1' }); }); // settings 시트에 pattern_blacklist_json 기록 (wrapper 객체 형태로 저장) try { var ss = getSpreadsheet_(); var settingSh = ss.getSheetByName('settings'); if (settingSh) { var sData = settingSh.getDataRange().getValues(); var updated = false; var wrapperObj = { status: 'OK', triggered_count: triggeredCount, total_tickers: blacklistEntries.length, patterns: blacklistEntries, pattern_count: blacklistEntries.length, computed_at: new Date().toISOString(), formula_id: 'PATTERN_BLACKLIST_AUTO_V1' }; var serialized = JSON.stringify(wrapperObj); for (var si = 0; si < sData.length; si++) { if (String(sData[si][0] || '').trim() === 'pattern_blacklist_json') { settingSh.getRange(si + 1, 2).setValue(serialized); updated = true; break; } } if (!updated) settingSh.appendRow(['pattern_blacklist_json', serialized]); } } catch(writeErr) { Logger.log('[F2] settings 시트 기록 실패: ' + writeErr.message); } Logger.log('[F2] calcPatternBlacklistAuto_ 완료: TRIGGERED=' + triggeredCount + '/' + blacklistEntries.length + '건'); return { status: 'OK', triggered_count: triggeredCount, total_tickers: blacklistEntries.length, patterns: blacklistEntries, pattern_count: blacklistEntries.length, formula_id: 'PATTERN_BLACKLIST_AUTO_V1' }; } catch(e) { Logger.log('[F2] calcPatternBlacklistAuto_ 오류: ' + e.message); return { status: 'ERROR', error: e.message, triggered_count: 0, patterns: [], pattern_count: 0, formula_id: 'PATTERN_BLACKLIST_AUTO_V1' }; } } // ═══════════════════════════════════════════════════════════════════════════════ // [PROPOSAL50] ALPHA_FEEDBACK_LOOP_V1 // monthly_history의 AEW_V1 성과 데이터를 분석해 SAQG_V1 필터 임계값 조정 권고 생성. // 임계값 자동 변경 금지 — 권고(RECOMMENDATION)만 출력. // ═══════════════════════════════════════════════════════════════════════════════ /** * calcAlphaFeedbackLoop_ * alpha_evaluation_window_json (AEW_V1 결과) 에서 ELIGIBLE 케이스를 분석해 * SAQG F1/F2/F3 임계값 조정 권고를 생성한다. * 10건 미만이면 DATA_INSUFFICIENT — 권고 생성 금지. */ function calcAlphaFeedbackLoop_() { try { var ss = getSpreadsheet_(); var aewRows = []; // monthly_history 시트에서 AEW 데이터 수집 var mhSh = ss.getSheetByName('monthly_history'); if (mhSh) { var mhData = mhSh.getDataRange().getValues(); if (mhData.length > 1) { var mhHeader = mhData[0]; var COL = {}; mhHeader.forEach(function(h, i) { COL[String(h).trim()] = i; }); for (var i = 1; i < mhData.length; i++) { var row = mhData[i]; var saqg = String(row[COL['saqg_v1'] != null ? COL['saqg_v1'] : -1] || '').toUpperCase(); var t20Sam = parseFloat(String(row[COL['t20_vs_samsung_pctp'] != null ? COL['t20_vs_samsung_pctp'] : -1] || '')); var brtV = String(row[COL['brt_verdict'] != null ? COL['brt_verdict'] : -1] || '').toUpperCase(); var regime = String(row[COL['market_regime'] != null ? COL['market_regime'] : -1] || ''); if (!saqg) continue; aewRows.push({ saqg_v1: saqg, t20_vs_samsung_pctp: isNaN(t20Sam) ? null : t20Sam, brt_verdict: brtV, market_regime: regime }); } } } var eligibleRows = aewRows.filter(function(r) { return r.saqg_v1 === 'ELIGIBLE'; }); var casesAnalyzed = eligibleRows.length; var now = new Date(); var asOf = now.toISOString().split('T')[0]; var analysisPeriod = asOf.substring(0, 7); // 'YYYY-MM' if (casesAnalyzed < 10) { Logger.log('[AFL] calcAlphaFeedbackLoop_: 데이터 부족(' + casesAnalyzed + '건) — 권고 생성 건너뜀'); return { formula_id: 'ALPHA_FEEDBACK_LOOP_V1', as_of: asOf, analysis_period: analysisPeriod, status: 'DATA_INSUFFICIENT', cases_analyzed: casesAnalyzed, grade_count: 0, eligible_t20_fail_rate: null, eligible_t60_fail_rate: null, recommended_filter_adjustments: [], grade_summary: [] }; } // T+20 알파 실패율 계산 (t20_vs_samsung_pctp < -3) var t20WithData = eligibleRows.filter(function(r) { return r.t20_vs_samsung_pctp !== null; }); var t20FailRows = t20WithData.filter(function(r) { return r.t20_vs_samsung_pctp < -3; }); var t20PassRows = t20WithData.length - t20FailRows.length; var t20FailRate = t20WithData.length > 0 ? Math.round(t20FailRows.length / t20WithData.length * 1000) / 10 : null; var t20PassRate = t20WithData.length > 0 ? Math.round(t20PassRows / t20WithData.length * 1000) / 10 : null; // BRT_VERDICT=BROKEN 케이스 비율 var brokenCount = eligibleRows.filter(function(r) { return r.brt_verdict === 'BROKEN'; }).length; var brokenRate = eligibleRows.length > 0 ? Math.round(brokenCount / eligibleRows.length * 1000) / 10 : 0; // grade_summary — saqg_v1 값별로 집계 var gradeCounts = {}; aewRows.forEach(function(r) { var g = r.saqg_v1 || 'UNKNOWN'; if (!gradeCounts[g]) gradeCounts[g] = { t20_total: 0, t20_pass: 0, t20_fail: 0 }; if (r.t20_vs_samsung_pctp !== null) { gradeCounts[g].t20_total++; if (r.t20_vs_samsung_pctp >= 0) gradeCounts[g].t20_pass++; else gradeCounts[g].t20_fail++; } }); var gradeSummary = Object.keys(gradeCounts).map(function(g) { var gd = gradeCounts[g]; var passRate = gd.t20_total > 0 ? Math.round(gd.t20_pass / gd.t20_total * 1000) / 10 : null; var failRate = gd.t20_total > 0 ? Math.round(gd.t20_fail / gd.t20_total * 1000) / 10 : null; return { grade: g, t20_total: gd.t20_total, t20_pass: gd.t20_pass, t20_pass_rate: passRate, t20_fail_rate: failRate, t60_total: 0, // T+60 데이터 미수집 — 향후 확장 t60_pass: 0, t60_pass_rate: null, t60_fail_rate: null, status: gd.t20_total === 0 ? 'DATA_INSUFFICIENT' : 'OK' }; }); // 권고 생성 — 렌더러 계약 필드명: filter_id, current, recommended, action, rationale var recommendations = []; if (t20FailRate !== null && t20FailRate > 50) { recommendations.push({ filter_id: 'SAQG_F1_F2_F3', current: 'CURRENT_THRESHOLDS', recommended: 'TIGHTEN: F2 recovery_ratio 1.20 → 1.35', action: 'TIGHTEN', rationale: 'ELIGIBLE T+20 알파 실패율 ' + t20FailRate + '% > 50% 기준 초과' }); } if (t20PassRate !== null && t20PassRate > 70 && casesAnalyzed >= 12) { recommendations.push({ filter_id: 'SAQG_F3', current: 'excess_drawdown 5%p', recommended: 'RELAX: excess_drawdown 5%p → 7%p', action: 'RELAX', rationale: 'ELIGIBLE T+20 성공률 ' + t20PassRate + '% > 70% (케이스 ' + casesAnalyzed + '건)' }); } if (brokenRate > 30) { recommendations.push({ filter_id: 'BRT_VERDICT_GATE', current: 'CURRENT_THRESHOLDS', recommended: 'TIGHTEN: BRT_BROKEN 진입 차단 강화', action: 'TIGHTEN', rationale: 'ELIGIBLE 중 BRT_BROKEN 비율 ' + brokenRate + '% > 30%' }); } Logger.log('[AFL] calcAlphaFeedbackLoop_ 완료: cases=' + casesAnalyzed + ' t20FailRate=' + t20FailRate + '% recs=' + recommendations.length); var result = { formula_id: 'ALPHA_FEEDBACK_LOOP_V1', as_of: asOf, analysis_period: analysisPeriod, status: 'OK', cases_analyzed: casesAnalyzed, grade_count: gradeSummary.length, eligible_t20_fail_rate: t20FailRate, eligible_t60_fail_rate: null, t20_pass_rate: t20PassRate, brt_broken_rate: brokenRate, recommended_filter_adjustments: recommendations, grade_summary: gradeSummary, note: '임계값 자동 변경 금지 — 사용자 확인 후 settings 수동 반영' }; // settings 시트에 기록 try { var settingSh = ss.getSheetByName('settings'); if (settingSh) { var sData = settingSh.getDataRange().getValues(); var updated = false; var serialized = JSON.stringify(result); for (var si = 0; si < sData.length; si++) { if (String(sData[si][0] || '').trim() === 'alpha_feedback_json') { settingSh.getRange(si + 1, 2).setValue(serialized); updated = true; break; } } if (!updated) settingSh.appendRow(['alpha_feedback_json', serialized]); } } catch(writeErr) { Logger.log('[AFL] settings 시트 기록 실패: ' + writeErr.message); } return result; } catch(e) { Logger.log('[AFL] calcAlphaFeedbackLoop_ 오류: ' + e.message); return { status: 'ERROR', error: e.message, cases_analyzed: 0, recommended_filter_adjustments: [], formula_id: 'ALPHA_FEEDBACK_LOOP_V1' }; } } /** AFL 일간 하네스 호출 래퍼 — calcAlphaFeedbackLoop_ 위임 */ function runAlphaFeedbackLoop_() { return calcAlphaFeedbackLoop_(); } /** * AFL 캐시 읽기 — settings 시트에서 마지막 저장된 alpha_feedback_json 반환. * calcAlphaFeedbackLoop_ 오류 시 fallback으로 사용. */ function getAlphaFeedbackJson_() { try { var ss = getSpreadsheet_(); var sh = ss.getSheetByName('settings'); if (!sh) return { status: 'SETTINGS_NOT_FOUND', formula_id: 'ALPHA_FEEDBACK_LOOP_V1' }; var data = sh.getDataRange().getValues(); for (var i = 0; i < data.length; i++) { if (String(data[i][0] || '').trim() === 'alpha_feedback_json') { var raw = data[i][1]; if (!raw) break; try { return JSON.parse(String(raw)); } catch(pe) { break; } } } } catch(e) { Logger.log('[AFL] getAlphaFeedbackJson_ 읽기 실패: ' + e.message); } return { status: 'CACHE_EMPTY', formula_id: 'ALPHA_FEEDBACK_LOOP_V1' }; } // FORMULA_STUB: EXPECTED_EDGE_V1 — 기댓값 공식 (calcExpectedEdge_) GAS 미구현, Python pipeline 산출 // --- Source: src/gas_adapter_parts/gdf_06_rebalance.gs --- // gdf_06_rebalance.gs — REBALANCE_ENGINE_V1 (GAS) // // runRebalanceSheet_(): data_feed + account_snapshot 라이브 데이터 기반 // bucket drift → 레짐 적응 밴드 → 비용효익 게이트 → 3단계 분할 실행 계획 // GatherTradingData.xlsx > rebalance 시트에 4섹션(SUMMARY/BUCKETS/TICKERS/ORDERS) 출력. // ── 버킷 설정 (gdf_01_price_metrics.gs THRESHOLDS 와 동기화) ───────────────── const RB_BUCKET_CONFIG = { Core: { target: 66.0, min: 60.0, max: 72.0 }, Satellite: { target: 17.5, min: 10.0, max: 25.0 }, Cash: { target: 16.5, min: 10.0, max: 22.0 }, }; // 코어 주도주 (isCoreLeader 기준, gdc_02_account_satellite.gs 와 일치) const RB_CORE_TICKERS = new Set(["005930", "000660", "000270"]); // ── 레짐 적응 밴드 (P3) ────────────────────────────────────────────────────── const RB_REGIME_BANDS = { RISK_ON: { label: "RISK_ON ±15%p", expand: 15, contract: 15 }, SECULAR_LEADER_RISK_ON: { label: "RISK_ON ±15%p", expand: 15, contract: 15 }, NEUTRAL: { label: "NEUTRAL ±5%p", expand: 5, contract: 5 }, RISK_OFF_CANDIDATE: { label: "RISK_OFF_CANDIDATE +2/−10%p", expand: 2, contract: 10 }, RISK_OFF: { label: "RISK_OFF +2/−10%p", expand: 2, contract: 10 }, EVENT_SHOCK: { label: "RISK_OFF +2/−10%p", expand: 2, contract: 10 }, _DEFAULT: { label: "NEUTRAL ±5%p", expand: 5, contract: 5 }, }; // ── 비용효익 게이트 (P4) ───────────────────────────────────────────────────── const RB_TX_COST_ROUNDTRIP = 0.0070; // 0.35% × 2 const RB_COST_BENEFIT_THRESHOLD = 0.0050; // 0.50%p const RB_MIN_DRIFT_PCT = (RB_TX_COST_ROUNDTRIP + RB_COST_BENEFIT_THRESHOLD) * 100; // 1.20%p const RB_LIMIT_PRICE_DISCOUNT = 0.002; // 매도 지정가 = 종가 × (1 - 0.2%) // ── 3단계 분할 비율 (P5) ───────────────────────────────────────────────────── const RB_STAGE_RATIOS = [0.30, 0.30, 0.40]; // ═══════════════════════════════════════════════════════════════════════════════ // Public entry point // ═══════════════════════════════════════════════════════════════════════════════ /** * GatherTradingData.xlsx > rebalance 시트에 4섹션 리밸런싱 계획을 기록한다. * 메뉴 또는 runDataFeed 후 자동 호출 가능. */ function runRebalanceSheet_() { const tag = "runRebalanceSheet_"; const startMs = Date.now(); try { // 1. 데이터 로드 const dfRows = _rbLoadDataFeedRows_(); const settings = readSettingsTab_(); const regime = _rbReadRegime_(settings); const band = RB_REGIME_BANDS[regime] || RB_REGIME_BANDS["_DEFAULT"]; // 2. 보유 종목 필터링 (Weight_Pct > 0 || Account_Market_Value > 0) const holdings = _rbFilterHoldings_(dfRows); // 3. 버킷별 현재 비중 집계 const buckets = _rbComputeBuckets_(holdings, band); // 4. 종목별 분석 const tickers = _rbComputeTickers_(holdings, band); // 5. ORDERS 생성 const orders = _rbComputeOrders_(tickers); // 6. SUMMARY 생성 const summary = _rbComputeSummary_(holdings, buckets, regime, band, orders.length); // 7. 시트 쓰기 _writeRebalanceSheet_(summary, buckets, tickers, orders); const elapsed = Math.round((Date.now() - startMs) / 100) / 10; Logger.log(`[${tag}] 완료: holdings=${holdings.length} orders=${orders.length} elapsed=${elapsed}s`); } catch (e) { Logger.log(`[${tag}][ERROR] 오류: ${e.message}\n${e.stack}`); throw e; } } // ═══════════════════════════════════════════════════════════════════════════════ // 데이터 로드 // ═══════════════════════════════════════════════════════════════════════════════ function _rbLoadDataFeedRows_() { const raw = sheetToJson("data_feed"); if (!Array.isArray(raw) || raw.length === 0) { throw new Error("data_feed 시트가 비어 있거나 로드 실패"); } return raw; } function _rbReadRegime_(settings) { const raw = (settings["REGIME_PRELIM"] || settings["regime_prelim"] || "").trim().toUpperCase(); return raw in RB_REGIME_BANDS ? raw : "_DEFAULT"; } // ═══════════════════════════════════════════════════════════════════════════════ // 보유 종목 필터링 // ═══════════════════════════════════════════════════════════════════════════════ function _rbFilterHoldings_(dfRows) { return dfRows .map(row => { const ticker = String(row["Ticker"] ?? "").trim(); if (!ticker) return null; const weightPct = _rbNum_(row["Weight_Pct"]); const acctMv = _rbNum_(row["Account_Market_Value"]); if (weightPct <= 0 && acctMv <= 0) return null; return { ticker: ticker, name: String(row["Name"] ?? ""), bucket: _rbAssignBucket_(ticker, row), weightPct: weightPct, acctMvKrw: acctMv, holdingQty: _rbInt_(row["Account_Holding_Qty"]), close: _rbNum_(row["Close"]), finalAction: String(row["Final_Action"] ?? ""), sellReason: String(row["Sell_Reason"] ?? ""), forceSignal: _rbDetectForce_(row), }; }) .filter(h => h !== null); } function _rbAssignBucket_(ticker, row) { const pt = String(row["position_type"] || row["Position_Type"] || "").trim().toLowerCase(); if (pt === "core") return "Core"; if (pt === "satellite") return "Satellite"; return RB_CORE_TICKERS.has(ticker) ? "Core" : "Satellite"; } function _rbDetectForce_(row) { const combined = [ row["Sell_Reason"], row["Final_Action"], row["Sell_Action"] ].join(" ").toUpperCase(); if (combined.includes("ABS_FLOOR")) return "ABS_FLOOR"; if (combined.includes("TIME_STOP") || combined.includes("TIME_EXIT") || combined.includes("TIME_TRIM")) return "TIME_STOP"; return ""; } // ═══════════════════════════════════════════════════════════════════════════════ // 버킷 계산 // ═══════════════════════════════════════════════════════════════════════════════ function _rbComputeBuckets_(holdings, band) { const corePct = holdings.filter(h => h.bucket === "Core").reduce((s, h) => s + h.weightPct, 0); const satPct = holdings.filter(h => h.bucket === "Satellite").reduce((s, h) => s + h.weightPct, 0); const cashPct = Math.max(0, 100 - corePct - satPct); const current = { Core: corePct, Satellite: satPct, Cash: cashPct }; return Object.entries(RB_BUCKET_CONFIG).map(([bname, bcfg]) => { const target = bcfg.target; const cur = _rb2_(current[bname] || 0); const drift = _rb2_(cur - target); const bandMin = _rb2_(target - band.contract); const bandMax = _rb2_(target + band.expand); let driftStatus; if (cur < bandMin) driftStatus = "BREACH_LOW"; else if (cur > bandMax) driftStatus = "BREACH_HIGH"; else if (Math.abs(drift) >= RB_MIN_DRIFT_PCT / 2) driftStatus = "WARN"; else driftStatus = "NORMAL"; return { bucket: bname, targetPct: target, currentPct: cur, driftPct: drift, bandMin, bandMax, regimeBand: band.label, driftStatus }; }); } // ═══════════════════════════════════════════════════════════════════════════════ // 종목별 분석 // ═══════════════════════════════════════════════════════════════════════════════ function _rbComputeTickers_(holdings, band) { // 버킷별 종목 수 집계 const countMap = {}; holdings.forEach(h => { countMap[h.bucket] = (countMap[h.bucket] || 0) + 1; }); return holdings.map(h => { const bcfg = RB_BUCKET_CONFIG[h.bucket] || RB_BUCKET_CONFIG["Satellite"]; const nTickers = countMap[h.bucket] || 1; const targetPct = _rb2_(bcfg.target / nTickers); const currentPct = _rb2_(h.weightPct); const drift = _rb2_(currentPct - targetPct); const bandMin = _rb2_(targetPct - band.contract); const bandMax = _rb2_(targetPct + band.expand); const force = h.forceSignal; let driftStatus, action, gateStatus; if (force) { driftStatus = "FORCE_" + force; action = "SELL"; gateStatus = "FORCE_OVERRIDE"; } else if (currentPct > bandMax) { driftStatus = "BREACH_HIGH"; action = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "SELL" : "WATCH"; gateStatus = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "PASS" : "BLOCKED_BY_COST"; } else if (currentPct < bandMin) { driftStatus = "BREACH_LOW"; action = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "BUY" : "WATCH"; gateStatus = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "PASS" : "BLOCKED_BY_COST"; } else if (Math.abs(drift) >= RB_MIN_DRIFT_PCT / 2) { driftStatus = "WARN"; action = "WATCH"; gateStatus = "BLOCKED_BY_COST"; } else { driftStatus = "NORMAL"; action = "HOLD"; gateStatus = "BLOCKED_BY_COST"; } // 3단계 수량 분할 (P5) let s1q = 0, s1p = 0, s2q = 0, s2p = 0, s3q = 0, s3p = 0; let tradeValueKrw = 0, costEstKrw = 0, netBenefitPct = 0; if ((action === "SELL" || action === "BUY") && h.holdingQty > 0 && h.close > 0) { let adjustQty; if (action === "SELL" && currentPct > 0) { const adjustRatio = Math.min(Math.abs(drift) / currentPct, 1.0); adjustQty = Math.max(1, Math.round(h.holdingQty * adjustRatio)); } else { adjustQty = Math.max(1, Math.round(h.holdingQty * 0.10)); } const stages = _rbStageSplit_(adjustQty); const limitP = _rbLimitPrice_(h.close, action); [s1q, s2q, s3q] = stages; [s1p, s2p, s3p] = [limitP, limitP, limitP]; tradeValueKrw = _rb2_((s1q + s2q + s3q) * limitP); costEstKrw = _rb2_(tradeValueKrw * RB_TX_COST_ROUNDTRIP); netBenefitPct = _rb2_(Math.abs(drift) - RB_TX_COST_ROUNDTRIP * 100); } return { ticker: h.ticker, name: h.name, bucket: h.bucket, targetPct, currentPct, driftPct: drift, bandMin, bandMax, regimeBand: band.label, driftStatus, forceSignal: force, gateStatus, action, stage1Qty: s1q, stage1Price: s1p, stage2Qty: s2q, stage2Price: s2p, stage3Qty: s3q, stage3Price: s3p, tradeValueKrw, costEstKrw, netBenefitPct, close: h.close }; }); } // ═══════════════════════════════════════════════════════════════════════════════ // ORDERS 생성 // ═══════════════════════════════════════════════════════════════════════════════ function _rbComputeOrders_(tickers) { const active = tickers .filter(t => t.gateStatus === "PASS" || t.gateStatus === "FORCE_OVERRIDE") .sort((a, b) => { const pa = a.gateStatus === "FORCE_OVERRIDE" ? 0 : 1; const pb = b.gateStatus === "FORCE_OVERRIDE" ? 0 : 1; if (pa !== pb) return pa - pb; return Math.abs(b.driftPct) - Math.abs(a.driftPct); }); const orders = []; let orderNo = 1; active.forEach(t => { const stageDefs = [ { stage: 1, qty: t.stage1Qty, price: t.stage1Price }, { stage: 2, qty: t.stage2Qty, price: t.stage2Price }, { stage: 3, qty: t.stage3Qty, price: t.stage3Price }, ]; stageDefs.forEach(({ stage, qty, price }) => { if (qty <= 0) return; const reason = t.forceSignal || t.driftStatus; orders.push({ orderNo, ticker: t.ticker, name: t.name, bucket: t.bucket, action: t.action, stage, qty, limitPriceKrw: price, tradeValueKrw: qty * price, reason, }); orderNo++; }); }); return orders; } // ═══════════════════════════════════════════════════════════════════════════════ // SUMMARY 생성 // ═══════════════════════════════════════════════════════════════════════════════ function _rbComputeSummary_(holdings, buckets, regime, band, ordersCount) { const corePct = (buckets.find(b => b.bucket === "Core") || {}).currentPct || 0; const satPct = (buckets.find(b => b.bucket === "Satellite") || {}).currentPct || 0; const cashPct = (buckets.find(b => b.bucket === "Cash") || {}).currentPct || 0; const rebalNeeded = buckets.some(b => b.driftStatus.startsWith("BREACH")); const totalKrw = holdings.reduce((s, h) => s + h.acctMvKrw, 0); const nowKst = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); return { Run_Date: nowKst, Regime: regime, Regime_Band: band.label, Total_Portfolio_KRW: totalKrw, Core_Pct: corePct, Satellite_Pct: satPct, Cash_Pct: cashPct, Target_Core_Pct: RB_BUCKET_CONFIG.Core.target, Target_Sat_Pct: RB_BUCKET_CONFIG.Satellite.target, Target_Cash_Pct: RB_BUCKET_CONFIG.Cash.target, Rebalance_Needed: rebalNeeded, Holdings_Count: holdings.length, Orders_Count: ordersCount, Min_Actionable_Drift_Pct: RB_MIN_DRIFT_PCT, }; } // ═══════════════════════════════════════════════════════════════════════════════ // 시트 쓰기 — 4섹션 멀티섹션 레이아웃 // ═══════════════════════════════════════════════════════════════════════════════ function _writeRebalanceSheet_(summary, buckets, tickers, orders) { const ss = getSpreadsheet_(); let sheet = ss.getSheetByName("rebalance"); if (!sheet) { sheet = ss.insertSheet("rebalance"); } else { sheet.clearContents(); } const rows = []; const nowKst = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss"); rows.push([`updated: ${nowKst} KST`]); // ── SUMMARY 섹션 ────────────────────────────────────────────────────────── rows.push(["=== SUMMARY ==="]); Object.entries(summary).forEach(([k, v]) => rows.push([k, v])); rows.push([""]); // ── BUCKETS 섹션 ───────────────────────────────────────────────────────── rows.push(["=== BUCKETS ==="]); rows.push(["Bucket","Target_Pct","Current_Pct","Drift_Pct","Band_Min","Band_Max","Regime_Band","Drift_Status"]); buckets.forEach(b => rows.push([ b.bucket, b.targetPct, b.currentPct, b.driftPct, b.bandMin, b.bandMax, b.regimeBand, b.driftStatus, ])); rows.push([""]); // ── TICKERS 섹션 ───────────────────────────────────────────────────────── rows.push(["=== TICKERS ==="]); rows.push([ "Ticker","Name","Bucket","Target_Pct","Current_Pct","Drift_Pct", "Band_Min","Band_Max","Regime_Band","Drift_Status","Force_Signal","Gate_Status","Action", "Stage1_Qty","Stage1_Price","Stage2_Qty","Stage2_Price","Stage3_Qty","Stage3_Price", "Trade_Value_KRW","Cost_Est_KRW","Net_Benefit_Pct","Close", ]); tickers.forEach(t => rows.push([ t.ticker, t.name, t.bucket, t.targetPct, t.currentPct, t.driftPct, t.bandMin, t.bandMax, t.regimeBand, t.driftStatus, t.forceSignal, t.gateStatus, t.action, t.stage1Qty, t.stage1Price, t.stage2Qty, t.stage2Price, t.stage3Qty, t.stage3Price, t.tradeValueKrw, t.costEstKrw, t.netBenefitPct, t.close, ])); rows.push([""]); // ── ORDERS 섹션 ────────────────────────────────────────────────────────── rows.push(["=== ORDERS ==="]); rows.push(["Order_No","Ticker","Name","Bucket","Action","Stage","Qty","Limit_Price_KRW","Trade_Value_KRW","Reason"]); orders.forEach(o => rows.push([ o.orderNo, o.ticker, o.name, o.bucket, o.action, o.stage, o.qty, o.limitPriceKrw, o.tradeValueKrw, o.reason, ])); // 한 번에 쓰기 if (rows.length > 0) { const maxCols = Math.max(...rows.map(r => r.length)); const padded = rows.map(r => { while (r.length < maxCols) r.push(""); return r; }); sheet.getRange(1, 1, padded.length, maxCols).setValues(padded); } } // ═══════════════════════════════════════════════════════════════════════════════ // 내부 유틸 // ═══════════════════════════════════════════════════════════════════════════════ function _rbNum_(v) { const n = parseFloat(v); return isNaN(n) ? 0 : n; } function _rbInt_(v) { const n = parseInt(v, 10); return isNaN(n) ? 0 : n; } function _rb2_(v) { return Math.round(v * 100) / 100; } function _rbStageSplit_(totalQty) { if (totalQty <= 0) return [0, 0, 0]; if (totalQty < 3) return [totalQty, 0, 0]; const s1 = Math.max(1, Math.floor(totalQty * RB_STAGE_RATIOS[0])); const s2 = Math.max(1, Math.floor(totalQty * RB_STAGE_RATIOS[1])); const s3 = Math.max(0, totalQty - s1 - s2); return [s1, s2, s3]; } function _rbLimitPrice_(close, action) { if (close <= 0) return 0; return action === "SELL" ? Math.round(close * (1 - RB_LIMIT_PRICE_DISCOUNT)) : Math.round(close); } // ── WBS-5.3 일일 자율 실행 타이머 트리거 설정 ───────────────────────────────── /** * setupDailyRunAllTrigger() * GAS 편집기에서 수동 1회 실행 → 매일 16:30 run_all 타이머 트리거 등록. * 중복 트리거 방지: 동일 함수명 트리거가 존재하면 먼저 삭제. */ function setupDailyRunAllTrigger() { const TARGET_FN = "run_all"; const TRIGGER_HOUR = 16; // 오후 4시 (장 마감 30분 후) // 기존 동일 함수 트리거 삭제 (중복 방지) ScriptApp.getProjectTriggers().forEach(t => { if (t.getHandlerFunction() === TARGET_FN) { ScriptApp.deleteTrigger(t); Logger.log("[WBS-5.3] 기존 트리거 삭제: " + TARGET_FN); } }); // 일일 타이머 트리거 등록 (매일 16:00~17:00 사이 실행) ScriptApp.newTrigger(TARGET_FN) .timeBased() .atHour(TRIGGER_HOUR) .everyDays(1) .inTimezone("Asia/Seoul") .create(); Logger.log("[WBS-5.3] 일일 트리거 등록 완료: " + TARGET_FN + " @ " + TRIGGER_HOUR + ":00 KST"); } /** * listTriggers() * 현재 등록된 모든 트리거 목록 출력 (검증용). */ function listTriggers() { ScriptApp.getProjectTriggers().forEach(t => { Logger.log( "trigger: fn=" + t.getHandlerFunction() + " type=" + t.getEventType() + " source=" + t.getTriggerSource() ); }); }