feat: 리밸런싱 엔진 V1 + GAS 버그 수정 (2026-06-13)
주요 변경: - tools/build_rebalance_engine_v1.py: REBALANCE_ENGINE_V1 신규 * account_snapshot 직접 합산(_build_snap_position_map) → 소수주 분리 행 병합 * 레짐 소스 macro.REGIME_PRELIM 최우선 (GAS 와 동일) - src/gas_adapter_parts/gdf_06_rebalance.gs: runRebalanceSheet_() 신규 * Logger.log / getSpreadsheet_() 로 run_all 연동 수정 - src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs * _mergePositionRecord_(): 소수주 중복 행 합산 신규 * parseInt → parseFloat (qty, availQty) - src/gas_adapter_parts/gdf_01_price_metrics.gs * 미보유 종목 SELL_READY → WATCH_EXIT_SIGNAL - spec/41_release_dag.yaml: build_rebalance_sheet 노드 추가 (step_count 63) - spec/51_formula_lifecycle_registry.yaml: REBALANCE_ENGINE_V1 등록 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# GAS Adapter Parts
|
||||
|
||||
P5-T02 GAS 역할 분리 산출물. `gas_data_feed.gs`(10302 lines)와 `gas_data_collect.gs`(4460 lines)를 각각 2500 라인 이하 파일로 분리했습니다.
|
||||
|
||||
GAS 프로젝트는 모든 `.gs` 파일이 동일한 글로벌 네임스페이스를 공유하므로, 이 파일들은 루트의 스텁 파일과 함께 GAS 프로젝트에 추가합니다.
|
||||
|
||||
## gas_data_feed.gs 분리 (10302 → 5개 파일)
|
||||
|
||||
| 파일 | 라인 수 | 소유 역할 | 주요 함수 |
|
||||
|---|---|---|---|
|
||||
| `gdf_01_price_metrics.gs` | 2347 | price-metrics | calcDerivedPriceMetrics, calcRsi14_, calcEntryMode_, calcExitSignalDetail_, runSellPriority |
|
||||
| `gdf_02_harness_assembly.gs` | 2213 | harness-assembly | buildHarnessContext_, assembleHarnessCoreLayers_, buildRoutingServingTraceV2_, calcSatelliteLifecycleGate_ |
|
||||
| `gdf_03_portfolio_gates.gs` | 2246 | portfolio-gates | calcPortfolioHealthScore_, calcSectorConcentrationGate_, calcActions_, calcSellPriority_, calcQuantities_, calcPrices_ |
|
||||
| `gdf_04_execution_quality.gs` | 2209 | execution-quality | calcPriceHierarchyLock_, calcDistributionRiskRow_, calcApexExecutionHarness_, getPa1WeightOverrides_, calcMacroEventSynchronizerV1_ |
|
||||
| `gdf_05_alpha_engines.gs` | 1287 | alpha-engines | calcAntiLateEntryGateV2_, calcConsistencyValidatorV2_, calcExportGate_, calcTradeQualityScorer_, calcAlphaFeedbackLoop_ |
|
||||
|
||||
## gas_data_collect.gs 분리 (4460 → 2개 파일)
|
||||
|
||||
| 파일 | 라인 수 | 소유 역할 | 주요 함수 |
|
||||
|---|---|---|---|
|
||||
| `gdc_01_fetch_fundamentals.gs` | 2405 | fetch-fundamentals | beginFetchSession_, fetchNaverFlow, fetchYahooPrice, fetchNaverFundamentals_, runDataFeed |
|
||||
| `gdc_02_account_satellite.gs` | 2055 | account-satellite | readAccountSnapshotMap_, _tickerSetup_, buildTickerRowV2_, runCoreSatelliteBatch, buildDataFeedMap_ |
|
||||
|
||||
## Validator 연동
|
||||
|
||||
`src/quant_engine/refactor_master_helpers.py` 의 `collect_gas_files()` 가 이 디렉토리의 `*.gs` 파일을 자동으로 포함합니다.
|
||||
|
||||
## Migration Backlog
|
||||
|
||||
15개 GAS 실제 위반 항목은 `runtime/gas_migration_wave2_4.yaml` `action_items` 섹션에 추적됩니다.
|
||||
이 파일들이 Python canonical engine으로 이전되면 해당 함수들은 이 어댑터 파일에서도 삭제됩니다.
|
||||
|
||||
**Owner**: data_feed 팀
|
||||
**Created**: 2026-06-10 (QEDD P5-T02)
|
||||
@@ -0,0 +1,2 @@
|
||||
// Split parts for gas_data_feed.gs
|
||||
// Moved to Python canonical engine as per QEDD methodology.
|
||||
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
@@ -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