chore: delete stale GAS files shadowed by src/gas_adapter_parts/
src/gas/engines/gdf_02~06 and src/gas/collection/gdc_01,gdc_02,gdf_01 are all stale shadows of src/gas_adapter_parts/ versions which include THIN_ADAPTER comments added in Phase 3 (PR#40). deploy_gas.py _find() searches gas_adapter_parts/ first, so engines/ and collection/ duplicates were never deployed — removing dead code. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,419 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user