/** * 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: 11섹터×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: "064350", name: "현대로템" }, { code: "012450", name: "한화에어로스페이스" }, { code: "028050", name: "삼성E&A" }, { code: "010120", name: "LS ELECTRIC" }, { code: "0117V0", name: "TIGER AI전력기기" }, { code: "494670", name: "TIGER 조선TOP10" }, { code: "471990", name: "KODEX AI반도체핵심장비" }, ]; // 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": "건설/EPC","006360": "건설/EPC", "005380": "자동차", "000270": "자동차", "012330": "자동차", "105560": "금융/은행","055550": "금융/은행","086790": "금융/은행", "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": "금융/은행", "305720": "2차전지","139220": "소비재", }; // 섹터 → 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_2", "2차전지": "Tier_2", "바이오": "Tier_2", "원전": "Tier_2", "건설/EPC": "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) { 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 (정책/입력 상태 점검 필요)'); } }