72f8d61244
주요 변경: - [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>
420 lines
20 KiB
JavaScript
420 lines
20 KiB
JavaScript
// 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);
|
||
}
|