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:
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* gas_apex_alpha_watch.gs
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* APEX 행위기반 커버리지 하네스 — 핵심 계산 엔진 (Impl)
|
||||
* [2026-05-30] BCH-V1 대응을 위해 분리된 순수 함수들
|
||||
*/
|
||||
|
||||
/**
|
||||
* PA2: ANTI_LATE_ENTRY_GATE_V2
|
||||
* [Python py_anti_late_entry_gate_v2 미러와 100% 동일 로직]
|
||||
*
|
||||
* @param {Array} holdings asResult.holdings
|
||||
* @param {Object} dfMap 종목별 데이터 피드
|
||||
* @return {Array} anti_late_entry_json
|
||||
*/
|
||||
function calcAntiLateEntryGateV2Impl_(holdings, dfMap) {
|
||||
var results = [];
|
||||
for (var i = 0; i < holdings.length; i++) {
|
||||
var h = holdings[i];
|
||||
var ticker = h.ticker || '';
|
||||
var df = dfMap[ticker] || {};
|
||||
|
||||
var close = Number(h.close || df.close || 0);
|
||||
var prevClose = Number(df.prevClose || 0);
|
||||
var ma20 = Number(df.ma20 || 0);
|
||||
var rsi14 = Number(df.rsi14 != null ? df.rsi14 : 50);
|
||||
var flowCredit = Number(df.flowCredit != null ? df.flowCredit : 0);
|
||||
var volume = Number(df.volume || 0);
|
||||
var avgVol5d = Number(df.avgVolume5d || 0);
|
||||
var frg5d = Number(df.frg5d || 0);
|
||||
var inst5d = Number(df.inst5d || 0);
|
||||
var ret5d = Number(df.ret5d || 0);
|
||||
var acGate = String(df.acGate || '');
|
||||
|
||||
var v1d = prevClose > 0 ? (close - prevClose) / prevClose * 100 : 0.0;
|
||||
var v5d = ret5d;
|
||||
|
||||
var distWs = 0.0;
|
||||
if (frg5d < 0) distWs += 2.0;
|
||||
if (inst5d < 0) distWs += 2.0;
|
||||
if (avgVol5d > 0 && volume > avgVol5d * 1.3) distWs += 1.5;
|
||||
if (prevClose > 0 && close < prevClose) distWs += 1.5;
|
||||
if (rsi14 > 70) distWs += 1.0;
|
||||
if (acGate === 'BLOCK') distWs += 1.0;
|
||||
|
||||
var gate1 = 'PASS';
|
||||
if (v1d >= 3.0) gate1 = 'BLOCK_CHASE';
|
||||
else if (v1d >= 1.5) gate1 = 'PULLBACK_WAIT';
|
||||
|
||||
var gate2 = 'PASS';
|
||||
if (v5d >= 8.0) gate2 = 'BLOCK_CHASE_5D';
|
||||
else if (v5d >= 5.0) gate2 = 'PULLBACK_WAIT_5D';
|
||||
|
||||
var gate3 = 'PASS';
|
||||
if (distWs >= 3.0) gate3 = 'BLOCK_DISTRIBUTION';
|
||||
else if (distWs >= 2.0) gate3 = 'PULLBACK_WAIT_DIST';
|
||||
|
||||
var hasBlock = (gate1 === 'BLOCK_CHASE' || gate2 === 'BLOCK_CHASE_5D' || gate3 === 'BLOCK_DISTRIBUTION');
|
||||
var hasPullback = (gate1 === 'PULLBACK_WAIT' || gate2 === 'PULLBACK_WAIT_5D' || gate3 === 'PULLBACK_WAIT_DIST');
|
||||
|
||||
var finalGate = 'PASS';
|
||||
if (hasBlock) finalGate = 'BLOCK';
|
||||
else if (hasPullback) finalGate = 'PULLBACK_WAIT';
|
||||
|
||||
var grade = 'B';
|
||||
if (finalGate === 'BLOCK') {
|
||||
grade = 'F';
|
||||
} else if (v1d < 0.5 && ma20 > 0 && close >= ma20 && close <= ma20 * 1.02 && flowCredit >= 0.55) {
|
||||
grade = 'A';
|
||||
} else if (v1d < 1.5 && ma20 > 0 && Math.abs(close - ma20) / ma20 <= 0.05) {
|
||||
grade = 'B';
|
||||
} else if (finalGate === 'PULLBACK_WAIT') {
|
||||
grade = 'C';
|
||||
} else if (v5d > 5.0) {
|
||||
grade = 'D';
|
||||
}
|
||||
|
||||
results.push({
|
||||
ticker: ticker,
|
||||
gate1_status: gate1,
|
||||
gate2_status: gate2,
|
||||
gate3_status: gate3,
|
||||
final_gate_status: finalGate,
|
||||
anti_late_entry_status: finalGate,
|
||||
entry_grade: grade,
|
||||
velocity_1d: Math.round(v1d * 100) / 100,
|
||||
velocity_5d: Math.round(v5d * 100) / 100,
|
||||
dist_weighted_sum: Math.round(distWs * 10) / 10
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* PA5: CONSISTENCY_VALIDATOR_V2
|
||||
* [P0 GAP 해소 - 데이터 정합성 검증]
|
||||
*/
|
||||
function calcConsistencyValidatorV2Impl_(hApex, asResult, cashFloorInfo, capturedAtIso, now) {
|
||||
var checks = [];
|
||||
var passed = [];
|
||||
var failed = [];
|
||||
var gapList = [];
|
||||
|
||||
// CV_01: sell_priority 방향 일관성
|
||||
var sellCandidates = hApex.sell_candidates_json || [];
|
||||
var tierOk = true;
|
||||
for (var i = 1; i < sellCandidates.length; i++) {
|
||||
if (sellCandidates[i].tier < sellCandidates[i-1].tier) {
|
||||
tierOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tierOk) passed.push('CV_01'); else failed.push({check_id: 'CV_01', reason: 'tier_reversal'});
|
||||
|
||||
// CV_02: 가격 순서 검증
|
||||
var prices = hApex.prices_json || [];
|
||||
var priceOk = true;
|
||||
for (var i = 0; i < prices.length; i++) {
|
||||
var p = prices[i];
|
||||
if (p.stop_price && p.current_price && p.stop_price >= p.current_price) priceOk = false;
|
||||
}
|
||||
if (priceOk) passed.push('CV_02'); else failed.push({check_id: 'CV_02', reason: 'price_hierarchy_violation'});
|
||||
|
||||
// CV_06: 수량 정수 검증
|
||||
var qtyOk = true;
|
||||
var bqi = hApex.buy_qty_inputs_json || [];
|
||||
for (var i = 0; i < bqi.length; i++) {
|
||||
if (bqi[i].final_qty && bqi[i].final_qty % 1 !== 0) qtyOk = false;
|
||||
}
|
||||
if (qtyOk) passed.push('CV_06'); else failed.push({check_id: 'CV_06', reason: 'float_quantity'});
|
||||
|
||||
// CV_08: 현금 계산 경로
|
||||
if (hApex.cash_ledger_basis === 'D2_ONLY') passed.push('CV_08');
|
||||
else failed.push({check_id: 'CV_08', reason: 'invalid_cash_basis'});
|
||||
|
||||
// Score 계산
|
||||
var score = Math.floor((passed.length / 12) * 100);
|
||||
var status = score >= 90 ? (score === 100 ? 'PASS' : 'WARNING') : 'BLOCK';
|
||||
|
||||
return {
|
||||
formula_id: 'CONSISTENCY_VALIDATOR_V2',
|
||||
consistency_score: score,
|
||||
cv_verdict: status === 'BLOCK' ? 'ABORT' : 'PASS',
|
||||
block_status: status,
|
||||
passed: passed,
|
||||
failed: failed,
|
||||
gap_list: gapList,
|
||||
consistency_report_json: { score: score, passed: passed, failed: failed }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PA4: MACRO_EVENT_SYNCHRONIZER_V1
|
||||
*/
|
||||
function calcMacroEventSynchronizerV1Impl_(macroJson, eventRows) {
|
||||
var usdKrw = Number(macroJson.usd_krw || 0);
|
||||
var foreignSellDays = Number(macroJson.foreign_sell_consecutive_days || 0);
|
||||
|
||||
var score = 0;
|
||||
if (usdKrw > 1500) score += 20;
|
||||
else if (usdKrw > 1480) score += 15;
|
||||
|
||||
if (foreignSellDays >= 10) score += 20;
|
||||
else if (foreignSellDays >= 5) score += 15;
|
||||
|
||||
var regime = 'MACRO_NEUTRAL';
|
||||
var heatAdj = 0;
|
||||
if (score >= 60) { regime = 'MACRO_CRITICAL'; heatAdj = -3; }
|
||||
else if (score >= 40) { regime = 'MACRO_ELEVATED'; heatAdj = -1; }
|
||||
else if (score < 20) { regime = 'MACRO_FAVORABLE'; heatAdj = 1; }
|
||||
|
||||
return {
|
||||
formula_id: 'MACRO_EVENT_SYNCHRONIZER_V1',
|
||||
macro_risk_score: score,
|
||||
macro_risk_regime: regime,
|
||||
effective_heat_gate_adjustment: heatAdj,
|
||||
mega_sell_alert: false,
|
||||
macro_event_json: { score: score, regime: regime, heat_gate_adj: heatAdj }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PA1: PREDICTIVE_ALPHA_ENGINE_V1
|
||||
*/
|
||||
function calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult, weightOverrides) {
|
||||
var results = [];
|
||||
for (var i = 0; i < holdings.length; i++) {
|
||||
var h = holdings[i];
|
||||
var ticker = h.ticker;
|
||||
var df = dfMap[ticker] || {};
|
||||
|
||||
var thesis = 0;
|
||||
if (df.close > df.ma20 && df.close < df.ma20 * 1.03) thesis += 20;
|
||||
if (df.flowCredit >= 0.55) thesis += 20;
|
||||
|
||||
var antithesis = 0;
|
||||
var v1d = df.prevClose > 0 ? (df.close - df.prevClose) / df.prevClose * 100 : 0;
|
||||
if (v1d >= 3.0) antithesis += 25;
|
||||
|
||||
var confidence = thesis - antithesis;
|
||||
var verdict = 'HOLD_NEUTRAL';
|
||||
if (confidence >= 40) verdict = 'STRONG_BUY_SIGNAL';
|
||||
else if (confidence >= 20) verdict = 'MODERATE_BUY_SIGNAL';
|
||||
else if (confidence < -30) verdict = 'EXIT_SIGNAL';
|
||||
else if (confidence < -10) verdict = 'TRIM_SIGNAL';
|
||||
|
||||
results.push({
|
||||
ticker: ticker,
|
||||
direction_confidence: confidence,
|
||||
thesis_score: thesis,
|
||||
antithesis_score: antithesis,
|
||||
synthesis_verdict: verdict,
|
||||
predictive_alpha_json: { confidence: confidence, verdict: verdict }
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* MACRO_REGIME_ADAPTIVE_GATE_V2
|
||||
*/
|
||||
function calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex) {
|
||||
var totalScore = mesResult.macro_risk_score || 0;
|
||||
var regime = 'MODERATE_RISK';
|
||||
var heatThreshold = 10.0;
|
||||
var sizeScale = 1.0;
|
||||
|
||||
if (totalScore >= 75) { regime = 'EXTREME_RISK'; heatThreshold = 5.0; sizeScale = 0.25; }
|
||||
else if (totalScore >= 50) { regime = 'HIGH_RISK'; heatThreshold = 7.0; sizeScale = 0.50; }
|
||||
else if (totalScore < 25) { regime = 'LOW_RISK'; heatThreshold = 12.0; sizeScale = 1.10; }
|
||||
|
||||
return {
|
||||
formula_id: 'MACRO_REGIME_ADAPTIVE_GATE_V2',
|
||||
total_mrag_score: totalScore,
|
||||
regime_label: regime,
|
||||
effective_heat_gate_threshold: heatThreshold,
|
||||
effective_position_size_scale: sizeScale,
|
||||
mrag_v2_json: { score: totalScore, regime: regime }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* applyAlegGate4And5Impl_
|
||||
*/
|
||||
function applyAlegGate4And5Impl_(alegRows, paeRows, hApex) {
|
||||
var results = [];
|
||||
var paeMap = {};
|
||||
for (var i = 0; i < paeRows.length; i++) paeMap[paeRows[i].ticker] = paeRows[i];
|
||||
|
||||
for (var i = 0; i < alegRows.length; i++) {
|
||||
var row = alegRows[i];
|
||||
var pae = paeMap[row.ticker] || {};
|
||||
|
||||
if (pae.synthesis_verdict === 'EXIT_SIGNAL' || pae.synthesis_verdict === 'TRIM_SIGNAL') {
|
||||
row.gate4_status = 'BLOCK_PAE';
|
||||
row.final_gate_status = 'BLOCK';
|
||||
row.anti_late_entry_status = 'BLOCK';
|
||||
} else {
|
||||
row.gate4_status = 'PASS';
|
||||
}
|
||||
results.push(row);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suite Aggregators
|
||||
*/
|
||||
function applyApexMacroAlphaSuiteImpl_(holdings, dfMap, hApex) {
|
||||
// Placeholder for macro alpha suite
|
||||
return hApex;
|
||||
}
|
||||
|
||||
function applyApexMacroEventSuiteImpl_(hApex) {
|
||||
// Placeholder for macro event suite
|
||||
return hApex;
|
||||
}
|
||||
|
||||
function applyApexPredictiveAlphaSuiteImpl_(holdings, dfMap, hApex) {
|
||||
var macroJson = hApex.macro_event_json || {};
|
||||
var mesResult = hApex.macro_event_json || {};
|
||||
var paeRows = calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult, null);
|
||||
hApex.predictive_alpha_json = paeRows;
|
||||
|
||||
// portfolio_alpha_confidence: mean direction_confidence across all holdings
|
||||
var sum = 0, n = 0;
|
||||
(paeRows || []).forEach(function(r) {
|
||||
if (typeof r.direction_confidence === 'number') { sum += r.direction_confidence; n++; }
|
||||
});
|
||||
hApex.portfolio_alpha_confidence = n > 0 ? Math.round(sum / n * 100) / 100 : 0;
|
||||
|
||||
return hApex;
|
||||
}
|
||||
|
||||
function applyApexWatchBreakoutSuiteImpl_(holdings, dfMap, hApex) {
|
||||
var slgRows = hApex.satellite_lifecycle_gate_json || [];
|
||||
var aleRows = hApex.anti_late_entry_json || [];
|
||||
hApex.watch_breakout_candidates_json = calcWatchBreakoutRealtimeGateV1_(holdings, dfMap, slgRows, aleRows);
|
||||
return hApex;
|
||||
}
|
||||
|
||||
// ---- TASK-006: ANTI_LATE_ENTRY_GATE_V2_CALIBRATED ----
|
||||
// [GAS_STUB_ONLY: requires Google Sheets deployment]
|
||||
function calibrateAntiLateEntryV2_(proposalHistory, captureDate) {
|
||||
// RC 수정: velocity 버킷별 T+5 승률 계산 (실측 표본 >= 30 충족 후 활성화)
|
||||
var buckets = { LOW: {n:0,wins:0}, MID: {n:0,wins:0}, HIGH: {n:0,wins:0} };
|
||||
var totalBuys = 0, chaseBuys = 0;
|
||||
(proposalHistory || []).forEach(function(p) {
|
||||
if (p.origin === 'REPLAY' || p.action !== 'BUY') return;
|
||||
if (p.realized_return_pct_t5 === undefined) return; // 미채움 제외
|
||||
totalBuys++;
|
||||
var v = parseFloat(p.velocity_1d || 0);
|
||||
var win = parseFloat(p.realized_return_pct_t5 || 0) > 0;
|
||||
var bucket = v < 1.0 ? 'LOW' : v < 3.0 ? 'MID' : 'HIGH';
|
||||
buckets[bucket].n++;
|
||||
if (win) buckets[bucket].wins++;
|
||||
if (v >= 3.0) chaseBuys++;
|
||||
});
|
||||
var minSamples = 30;
|
||||
var validated = Object.keys(buckets).every(function(k) { return buckets[k].n >= minSamples; });
|
||||
return {
|
||||
formula_id: 'ANTI_LATE_ENTRY_GATE_V2_CALIBRATED',
|
||||
validated: validated,
|
||||
unvalidated_label: validated ? null : '[UNVALIDATED_LIVE: n<30 per bucket]',
|
||||
chase_entry_rate_pct: totalBuys > 0 ? (chaseBuys / totalBuys * 100).toFixed(1) : null,
|
||||
buckets: buckets,
|
||||
threshold_source: validated ? 'DYNAMIC' : 'EXPERT_PRIOR',
|
||||
velocity_1d_block_pct: 3.0
|
||||
};
|
||||
}
|
||||
|
||||
// ---- TASK-007: DISTRIBUTION_BLOCK_EFFECTIVENESS_V1 ----
|
||||
// [GAS_STUB_ONLY: requires Google Sheets deployment]
|
||||
function trackDistributionBlockEffectiveness_(proposalHistory) {
|
||||
var blocked = (proposalHistory || []).filter(function(p) {
|
||||
return p.blocked_reason === 'DISTRIBUTION_CONFIRMED' || p.blocked_reason === 'DISTRIBUTION_BLOCK';
|
||||
});
|
||||
var avoidedLoss = blocked.filter(function(p) {
|
||||
return p.t5_return_if_not_blocked !== undefined && parseFloat(p.t5_return_if_not_blocked) < 0;
|
||||
});
|
||||
var blockedN = blocked.length;
|
||||
var avoidedLossRate = blockedN > 0 ? (avoidedLoss.length / blockedN) : null;
|
||||
return {
|
||||
formula_id: 'DISTRIBUTION_BLOCK_EFFECTIVENESS_V1',
|
||||
blocked_sample_count: blockedN,
|
||||
avoided_loss_rate: avoidedLossRate,
|
||||
target_avoided_loss_rate: 0.60,
|
||||
effectiveness_label: blockedN < 30
|
||||
? '[UNVALIDATED_LOW_N: n=' + blockedN + ' < 30]'
|
||||
: (avoidedLossRate >= 0.60 ? 'EFFECTIVE' : 'REVIEW_THRESHOLD')
|
||||
};
|
||||
}
|
||||
|
||||
// ---- TASK-010: SMART_MONEY_LIQUIDITY_OUTCOME_LINK_V1 ----
|
||||
// [GAS_STUB_ONLY: requires Google Sheets deployment]
|
||||
function linkSmartMoneyOutcome_(proposalHistory) {
|
||||
var buckets = {};
|
||||
(proposalHistory || []).forEach(function(p) {
|
||||
if (p.origin === 'REPLAY' || !p.liquidity_label) return;
|
||||
var lbl = p.liquidity_label;
|
||||
if (!buckets[lbl]) buckets[lbl] = {returns:[], slippages:[]};
|
||||
if (p.realized_return_pct_t5 !== undefined) buckets[lbl].returns.push(parseFloat(p.realized_return_pct_t5));
|
||||
if (p.slippage_pct !== undefined) buckets[lbl].slippages.push(parseFloat(p.slippage_pct));
|
||||
});
|
||||
var table = Object.keys(buckets).map(function(lbl) {
|
||||
var d = buckets[lbl];
|
||||
var n = d.returns.length;
|
||||
var wins = d.returns.filter(function(r){return r>0;}).length;
|
||||
return {
|
||||
liquidity_label: lbl,
|
||||
sample_count: n,
|
||||
t5_avg_return_pct: n > 0 ? d.returns.reduce(function(a,b){return a+b;},0)/n : null,
|
||||
t5_win_rate: n > 0 ? wins/n : null,
|
||||
label: n < 30 ? '[UNVALIDATED: n=' + n + ' < 30]' : 'VALIDATED'
|
||||
};
|
||||
});
|
||||
return {formula_id: 'SMART_MONEY_LIQUIDITY_OUTCOME_LINK_V1', table: table};
|
||||
}
|
||||
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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user