// 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); }