feat: Sprint-3 완결 + Sprint-4 착수 (WBS-3.2, 3.4, 5.2)

주요 변경:
- [WBS-3.2] 리밸런싱 V2 신호 가중 목표배분 (signal_weighted_ss001_v1)
  * equal_weight -> SS001_Norm_Score 비례 버킷내 배분
  * 하네스: 삼성(36.84%) > SK하이닉스(29.16%), Core=66.00% PASS
- [WBS-3.4] logDailyAssetHistory_ SpreadsheetApp.getActiveSpreadsheet() -> getSpreadsheet_() 수정
  * run_all 컨텍스트에서 null 반환 방지
- [WBS-5.2] deploy_gas.py 전면 재작성
  * src/gas_adapter_parts/ + src/gas/ 양쪽 소스 탐색
  * gdc_01+gdc_02 -> gas_data_collect.gs 번들링
  * dry-run PASS: 17개 파일 WARN 0건
- src/gas/ 디렉토리 신규 추가 (CLASP 조직화 구조)
- tools/automate_routine.py, download_trading_data.py 신규 추가
- .gitignore: .clasprc.json OAuth 토큰 제외 추가
- ROADMAP_WBS.md: Sprint-3 [x] 완료, Sprint-4 착수 목록 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 16:22:19 +09:00
parent cb4787ca2d
commit 72f8d61244
26 changed files with 22879 additions and 85 deletions
+419
View File
@@ -0,0 +1,419 @@
// 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);
}