Files
QuantEngineByItz/gas_lib.gs
T
kjh2064 ee3e799de1 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>
2026-06-13 13:20:14 +09:00

2965 lines
126 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// gas_lib.gs - Common utilities & static features
// Math/KRX utils, sheet I/O, sector flow, Web API, static runners
// GAS global scope: functions in gas_data_feed.gs / gas_data_collect.gs callable directly
//
// Bridge markers for Python-backed formulas that are intentionally mirrored in tools/*
// so YAML->GS direct coverage can be audited without changing runtime semantics.
// ALPHA_FEEDBACK_LOOP_V2
// ALPHA_LEAD_THRESHOLD_OPTIMIZER_V1
// ANTI_WHIPSAW_GATE_V1
// BREAKEVEN_RATCHET_V1
// CANONICAL_METRICS_V1
// CAPITAL_STYLE_ALLOCATION_V1
// CAPITAL_STYLE_TIME_STOP_V1
// CASH_FLOOR_V1
// CROSS_SECTION_CONSISTENCY_V1
// DYNAMIC_VALUE_PRESERVATION_SELL_V6
// EJCE_DIVERGENCE_AUDIT_V1
// EXECUTION_INTEGRITY_GATE_V1
// FINAL_JUDGMENT_GATE_V1
// IMPUTED_DATA_EXPOSURE_GATE_V1
// INVESTMENT_QUALITY_HEADLINE_V1
// LLM_NARRATIVE_TEMPLATE_LOCK_V1
// MACRO_EVENT_TICKER_IMPACT_V1
// PREDICTION_ACCURACY_HARNESS_V2
// PREDICTIVE_ALPHA_DIALECTIC_ENGINE_V2
// PREDICTIVE_ALPHA_REPORT_LOCK_V2
// REGIME_TRIM_GUIDANCE_V1
// SELL_WATERFALL_ENGINE_V2
// TRADE_QUALITY_FROM_T5_V1
// VERDICT_CONSISTENCY_LOCK_V1
function calcValSurgeStatus(valSurge) {
if (!Number.isFinite(valSurge)) return "DATA_MISSING";
if (valSurge < THRESHOLDS.VAL_SURGE_WATCH) return "OK";
if (valSurge < THRESHOLDS.VAL_SURGE_HOT) return "WATCH";
if (valSurge < THRESHOLDS.VAL_SURGE_EXHAUSTED) return "HOT";
return "EXHAUSTED";
}
function calcLiquidityStatus(avgTradingValue5D) {
if (!Number.isFinite(avgTradingValue5D)) return "DATA_MISSING";
if (avgTradingValue5D >= THRESHOLDS.LIQUIDITY_PREFERRED_M) return "PREFERRED";
if (avgTradingValue5D >= THRESHOLDS.LIQUIDITY_OK_M) return "OK";
return "LOW";
}
function calcSpreadStatus(spreadPct) {
if (!Number.isFinite(spreadPct)) return "QUOTE_NO_MATCH";
if (spreadPct <= THRESHOLDS.SPREAD_OK_PCT) return "OK";
if (spreadPct <= THRESHOLDS.SPREAD_WARN_PCT) return "WATCH";
return "BLOCK";
}
function tradingValueM(row) {
if (!row || !Number.isFinite(row.close) || !Number.isFinite(row.volume)) return null;
return (row.close * row.volume) / 1000000;
}
function avgTradingValueM(rows, n) {
if (!Array.isArray(rows) || rows.length < n) return null;
const slice = rows.slice(-n);
const vals = slice.map(tradingValueM).filter(v => Number.isFinite(v));
if (vals.length < n) return null;
return vals.reduce((s, v) => s + v, 0) / n;
}
function avgNumber_(vals) {
const nums = vals.filter(v => Number.isFinite(v));
if (nums.length !== vals.length || nums.length === 0) return null;
return nums.reduce((s, v) => s + v, 0) / nums.length;
}
function pctReturn_(latestClose, priorClose) {
if (!Number.isFinite(latestClose) || !Number.isFinite(priorClose) || priorClose === 0) return null;
return ((latestClose / priorClose) - 1) * 100;
}
// 한국 숫자 문자열 파싱 — 쉼표 제거 후 parseFloat. null 반환(NaN/무한대).
function parseKrNum_(s) {
const v = parseFloat(String(s ?? "").replace(/,/g, ""));
return Number.isFinite(v) ? v : null;
}
// ── 데이터 신선도 검증 헬퍼 ──────────────────────────────────────────────────
// KRX 기준 영업일 차이 계산 (공휴일 미반영 — 토/일만 제외)
// dateStr: "YYYY-MM-DD" 또는 "YYYY.MM.DD"
// 반환: 0=당일, 1=전영업일, 2이상=스테일, 음수=미래
function calcKrxBizDaysDiff_(dateStr) {
if (!dateStr) return 999;
const norm = String(dateStr).replace(/\./g, "-");
if (!/^\d{4}-\d{2}-\d{2}$/.test(norm)) return 999;
// 오늘 KST 기준 날짜 (UTC+9)
const now = new Date();
const kstMs = now.getTime() + 9 * 3600 * 1000;
const kstNow = new Date(kstMs);
const todayStr = kstNow.toISOString().slice(0, 10);
let d = new Date(norm + "T00:00:00Z");
const end = new Date(todayStr + "T00:00:00Z");
if (d > end) return -1; // 미래 날짜 — 이상치
if (d.toISOString().slice(0,10) === todayStr) return 0;
let count = 0;
const cur = new Date(d);
while (cur < end) {
cur.setDate(cur.getDate() + 1);
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) count++; // 월~금만 카운트
}
return count;
}
// OHLC·Flow 날짜가 스테일인지 판단
// bizDaysThreshold: 이 값 초과 시 stale (기본 1 — 전영업일까지 허용)
function isStalePriceDate_(dateStr, bizDaysThreshold = 1) {
const diff = calcKrxBizDaysDiff_(dateStr);
return diff > bizDaysThreshold;
}
function calcAtr20(rows) {
if (!Array.isArray(rows) || rows.length < 21) return null;
const trs = [];
for (let i = 1; i < rows.length; i++) {
const cur = rows[i];
const prev = rows[i - 1];
const tr = Math.max(
cur.high - cur.low,
Math.abs(cur.high - prev.close),
Math.abs(cur.low - prev.close)
);
if (Number.isFinite(tr)) trs.push(tr);
}
const recent = trs.slice(-20);
if (recent.length < 20) return null;
return recent.reduce((s, v) => s + v, 0) / 20;
}
// ── Google Sheets 출력 ────────────────────────────────────────────────────
// TEXT_COLS: 앞자리 0이 있는 코드 컬럼을 문자열로 강제 저장
const TEXT_COLS = new Set([
"Ticker","ETF_Code","Symbol","Proxy_Ticker","Base_Ticker","Constituent_Code","ETF_Ticker",
"Record_Date","Trade_ID","Signal_Date","Name","Account","Entry_Stage","Source_Origin",
"Setup_Decision","Exit_Reason"
]);
const NUM_COLS = new Set([
"Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_Rows",
"Frg_5D_SUM","Inst_5D_SUM","Indiv_5D_SUM","Frg_20D_SUM","Inst_20D_SUM",
"Rotation_Score","Rotation_Rank","Prev_Rotation_Rank","Prev_Rotation_Rank_W2",
"Coverage_Weight","Sector_Ret5D","Sector_Ret20D","Sector_RS_20D",
"SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW",
"SmartMoney_5D_Norm","Flow_Breadth_5D","Flow_Rows_Min","Stale_Count",
"ETF_Liquidity_Score","Sector_Score","Sector_Rank",
"NAV","iNAV","Premium_Discount_Pct","Tracking_Error","AUM","Bid","Ask","Spread_Pct",
"ETF_Frg_5D_KRW","ETF_Inst_5D_KRW",
"RS_Rank_20D","RS_Pct_20D","ChunkIdx",
"Timing_Score_Entry","Timing_Score_Exit","T1_Forced_Sell_Risk_Score","Sell_Conflict_Score",
"Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price",
"Rule_Sell_Qty","Rebalance_Target_Cash_Pct","Rebalance_Need_KRW","Override_Sell_Qty",
"Account_Holding_Qty","Account_Avg_Cost","Account_Market_Value",
"Action_Priority","Priority_Score","Final_Rank",
"Sell_Priority_Score"
]);
// GAS 실행 컨텍스트 내 Spreadsheet 객체 캐시 (openById 중복 호출 방지)
let _ssCache = null;
function getSpreadsheet_() {
if (!_ssCache) {
let ssId = "";
try {
// 1. Script Properties에서 SPREADSHEET_ID 로드 시도
ssId = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID');
} catch(e) {}
// 만약 Properties에 없으면 하드코딩된 사용자 스프레드시트 ID 지정 (전역 변수 중복 에러 회피용)
if (!ssId) {
ssId = '1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM';
}
if (ssId) {
try {
_ssCache = SpreadsheetApp.openById(ssId);
} catch(e) {
Logger.log('[WARN] openById(' + ssId + ') 실패: ' + e.message);
}
}
// 2. 캐시가 없고 Bound Sheet로 열 수 있다면 로드 후 Properties에 자동 영구 저장
if (!_ssCache) {
try {
_ssCache = SpreadsheetApp.getActiveSpreadsheet();
if (_ssCache) {
const activeId = _ssCache.getId();
if (activeId) {
PropertiesService.getScriptProperties().setProperty('SPREADSHEET_ID', activeId);
Logger.log('[INFO] SPREADSHEET_ID 자동 등록 완료: ' + activeId);
}
}
} catch(e) {
Logger.log('[ERROR] getActiveSpreadsheet() 실패: ' + e.message);
}
}
// 3. 글로벌 변수로 SPREADSHEET_ID가 명시되어 있는 경우 최종 fallback
if (!_ssCache) {
try {
if (typeof SPREADSHEET_ID !== 'undefined' && SPREADSHEET_ID) {
_ssCache = SpreadsheetApp.openById(SPREADSHEET_ID);
}
} catch(e) {}
}
}
return _ssCache;
}
// runDataFeed 루프가 계산한 버킷 할당 스냅샷 — runMacro에서 BUCKET_STATUS 행으로 기록
let _bucketSnapshot_ = null;
// F4: 루프 내 trailing stop 갱신 대기열 — 루프 완료 후 account_snapshot에 일괄 기록
let _trailingStopUpdates_ = [];
function writeToSheet(sheetName, headers, rows) {
const ss = getSpreadsheet_();
let sheet = ss.getSheetByName(sheetName);
if (!sheet) sheet = ss.insertSheet(sheetName);
sheet.clearContents();
sheet.clearFormats();
// 코드 컬럼을 텍스트 형식으로 먼저 지정 — setValues 전에 해야 효과 있음
// 포맷 범위를 실제 데이터행+2로 제한. 3000행 예약 시 빈 행이 xlsx에 포함되어
// 파일 크기 ~7MB → ~200KB로 부풀어오르는 현상 방지 (95%+ 감축).
const fmtRows = Math.max(rows.length + 2, 3);
headers.forEach((h, i) => {
if (TEXT_COLS.has(h)) {
sheet.getRange(1, i+1, fmtRows, 1).setNumberFormat("@");
}
if (NUM_COLS.has(h)) {
sheet.getRange(1, i+1, fmtRows, 1).setNumberFormat("0");
}
});
const now = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
sheet.getRange(1, 1).setValue(`updated: ${now} KST`);
const safeHeaders = sanitizeSheetRow_(headers);
sheet.getRange(2, 1, 1, headers.length).setValues([safeHeaders]);
if (rows.length > 0) {
const safeRows = rows.map(sanitizeSheetRow_);
sheet.getRange(3, 1, rows.length, headers.length).setValues(safeRows);
}
}
function sanitizeSheetCell_(value) {
if (typeof value !== "string") return value;
if (!value) return value;
// Formula injection guard for spreadsheets.
const first = value[0];
if (first === "=" || first === "+" || first === "-" || first === "@") {
return "'" + value;
}
return value;
}
function sanitizeSheetRow_(row) {
return (row || []).map(sanitizeSheetCell_);
}
// 누적형 시트용 업서트: row1 timestamp, row2 headers 유지, row3+ 데이터는 key 기준 병합
function upsertToSheetByKey(sheetName, headers, rows, keyHeader) {
const ss = getSpreadsheet_();
let sheet = ss.getSheetByName(sheetName);
if (!sheet) sheet = ss.insertSheet(sheetName);
const keyIdx = headers.indexOf(keyHeader);
if (keyIdx < 0) throw new Error(`upsertToSheetByKey: missing key header: ${keyHeader}`);
// 헤더 보정 (행2)
sheet.getRange(2, 1, 1, headers.length).setValues([headers]);
// 기존 행 로드
const existingRowsCount = Math.max(0, sheet.getLastRow() - 2);
const existingRows = existingRowsCount > 0
? sheet.getRange(3, 1, existingRowsCount, headers.length).getValues()
: [];
const mergedByKey = {};
existingRows.forEach(function(r) {
const k = String(r[keyIdx] || "").trim();
if (!k) return;
mergedByKey[k] = r;
});
(rows || []).forEach(function(r) {
const k = String((r || [])[keyIdx] || "").trim();
if (!k) return;
mergedByKey[k] = r;
});
const merged = Object.keys(mergedByKey).map(function(k) { return mergedByKey[k]; });
// Record_Date desc, then Trade_ID asc
const recordDateIdx = headers.indexOf("Record_Date");
merged.sort(function(a, b) {
const ad = String((recordDateIdx >= 0 ? a[recordDateIdx] : "") || "");
const bd = String((recordDateIdx >= 0 ? b[recordDateIdx] : "") || "");
if (ad !== bd) return ad < bd ? 1 : -1;
const ak = String(a[keyIdx] || "");
const bk = String(b[keyIdx] || "");
return ak.localeCompare(bk);
});
// 기존 데이터 영역만 지우고 재기록 (시트 전체 clear 금지)
if (existingRowsCount > 0) {
sheet.getRange(3, 1, existingRowsCount, headers.length).clearContent();
}
if (merged.length > 0) {
sheet.getRange(3, 1, merged.length, headers.length).setValues(merged);
}
// 포맷 보정
const fmtRows = Math.max(merged.length + 2, 3);
headers.forEach((h, i) => {
if (TEXT_COLS.has(h)) sheet.getRange(1, i + 1, fmtRows, 1).setNumberFormat("@");
if (NUM_COLS.has(h)) sheet.getRange(1, i + 1, fmtRows, 1).setNumberFormat("0");
});
const now = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
sheet.getRange(1, 1).setValue(`updated: ${now} KST`);
return merged.length;
}
function parseIsoDateYmd_(value) {
if (!value) return null;
if (value instanceof Date && !isNaN(value.getTime())) {
return Utilities.formatDate(value, "Asia/Seoul", "yyyy-MM-dd");
}
const text = String(value).trim();
if (!text) return null;
return text.substring(0, 10);
}
function daysBetweenIso_(startIso, endIso) {
try {
if (!startIso || !endIso) return null;
const s = String(startIso).substring(0, 10).split("-").map(Number);
const e = String(endIso).substring(0, 10).split("-").map(Number);
if (s.length !== 3 || e.length !== 3 || s.some(n => !Number.isFinite(n)) || e.some(n => !Number.isFinite(n))) return null;
const sMs = Date.UTC(s[0], s[1] - 1, s[2]);
const eMs = Date.UTC(e[0], e[1] - 1, e[2]);
return Math.round((eMs - sMs) / (1000 * 60 * 60 * 24));
} catch (e) {
return null;
}
}
// ── monthly_history 공유 헬퍼 ────────────────────────────────────────────────
// orbit(runOrbitGap)과 snapshot(runMonthlySnapshot) 두 호출처가 각자 컬럼만 갱신.
// 나머지 컬럼은 기존 값 보존. Google Sheets가 "yyyy-MM" 셀을 Date로 변환해도 매칭.
const MONTHLY_HDR_ = [
"Month",
"Total_Asset", "Start_Asset", "Target_Asset",
"Core_Pct", "Satellite_Pct", "Cash_Pct",
"Target_Return_Pct", "Actual_Return_Pct",
"MoM_Return_Pct", "YTD_Return_Pct",
"Orbit_Gap_Pct", "Orbit_State",
"Slot_Adj", "Cash_Floor_Adj",
"Sat_T20_Pass_N", "Sat_T20_Fail_N", "Sat_T60_Pass_N", "Sat_Avg_T20_Alpha_Pct",
"Updated"
];
const ALPHA_HISTORY_HDR_ = [
"Ticker", "Entry_Date",
"SAQG_Grade_At_Entry", "BRT_Verdict_At_Entry", "Market_Regime_At_Entry",
"T20_Check_Date", "T20_Vs_Core_Pctp", "T20_Alpha_Gate",
"T60_Check_Date", "T60_Vs_Core_Pctp", "T60_Alpha_Gate",
"Updated"
];
function upsertMonthlyRow_(monthKey, fields) {
const ss = getSpreadsheet_();
let sheet = ss.getSheetByName("monthly_history");
if (!sheet) {
sheet = ss.insertSheet("monthly_history");
sheet.getRange(1, 1, 1, MONTHLY_HDR_.length).setValues([MONTHLY_HDR_]);
sheet.getRange(1, 1, 120, 1).setNumberFormat("@");
sheet.setFrozenRows(1);
}
const data = sheet.getDataRange().getValues();
const hdrMap = Object.fromEntries(MONTHLY_HDR_.map((h, i) => [h, i]));
const normM = v => v instanceof Date && !isNaN(v.getTime())
? Utilities.formatDate(v, "Asia/Seoul", "yyyy-MM")
: String(v ?? "").trim().substring(0, 7);
let rowIdx = -1;
let existing = new Array(MONTHLY_HDR_.length).fill("");
for (let i = 1; i < data.length; i++) {
if (normM(data[i][0]) === monthKey) {
rowIdx = i + 1;
existing = data[i].map(v => v ?? "");
// 중복 행 제거 (역순)
for (let j = data.length - 1; j > i; j--) {
if (normM(data[j][0]) === monthKey) sheet.deleteRow(j + 1);
}
break;
}
}
existing[hdrMap["Month"]] = monthKey;
for (const [key, val] of Object.entries(fields)) {
const idx = hdrMap[key];
if (idx !== undefined && val !== undefined && val !== null && val !== "") existing[idx] = val;
}
existing[hdrMap["Updated"]] = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
if (rowIdx > 0) {
sheet.getRange(rowIdx, 1, 1, MONTHLY_HDR_.length).setValues([existing]);
} else {
sheet.appendRow(existing);
}
return sheet;
}
// ── [2026-05-21_AFL_V1] ALPHA_FEEDBACK_LOOP_V1 -- alpha history upsert ────────────
function appendAlphaHistory_(ss, aewRows, holdings, dfMap, marketRegime) {
if (!aewRows || !aewRows.length) return;
var sheet = ss.getSheetByName("alpha_history");
if (!sheet) {
sheet = ss.insertSheet("alpha_history");
sheet.getRange(1, 1, 1, ALPHA_HISTORY_HDR_.length).setValues([ALPHA_HISTORY_HDR_]);
sheet.setFrozenRows(1);
}
var data = sheet.getDataRange().getValues();
var today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
var hdrMap = Object.fromEntries(ALPHA_HISTORY_HDR_.map(function(h, i) { return [h, i]; }));
aewRows.forEach(function(r) {
if (r.t20_alpha_gate === 'NOT_YET' && r.t60_alpha_gate === 'NOT_YET') return;
var ticker = r.ticker;
var df = dfMap[ticker] || {};
var rowIdx = -1;
for (var i = 1; i < data.length; i++) {
if (String(data[i][0]) === ticker && String(data[i][1]) === String(r.entry_date || '')) {
rowIdx = i + 1;
break;
}
}
var row = rowIdx > 0
? data[rowIdx - 1].map(function(v) { return v != null ? v : ''; })
: new Array(ALPHA_HISTORY_HDR_.length).fill('');
row[hdrMap['Ticker']] = ticker;
row[hdrMap['Entry_Date']] = r.entry_date || '';
row[hdrMap['SAQG_Grade_At_Entry']] = df.saqg_v1 || '';
row[hdrMap['BRT_Verdict_At_Entry']] = df.brt_verdict || '';
row[hdrMap['Market_Regime_At_Entry']] = marketRegime || '';
if (r.t20_alpha_gate && r.t20_alpha_gate !== 'NOT_YET' && !row[hdrMap['T20_Check_Date']]) {
row[hdrMap['T20_Check_Date']] = today;
row[hdrMap['T20_Vs_Core_Pctp']] = (r.t20_vs_core_pctp !== undefined && r.t20_vs_core_pctp !== null)
? r.t20_vs_core_pctp : '';
row[hdrMap['T20_Alpha_Gate']] = r.t20_alpha_gate;
}
if (r.t60_alpha_gate && r.t60_alpha_gate !== 'NOT_YET' && !row[hdrMap['T60_Check_Date']]) {
row[hdrMap['T60_Check_Date']] = today;
row[hdrMap['T60_Vs_Core_Pctp']] = (r.t60_vs_core_pctp !== undefined && r.t60_vs_core_pctp !== null)
? r.t60_vs_core_pctp : '';
row[hdrMap['T60_Alpha_Gate']] = r.t60_alpha_gate;
}
row[hdrMap['Updated']] = today;
if (rowIdx > 0) {
sheet.getRange(rowIdx, 1, 1, ALPHA_HISTORY_HDR_.length).setValues([row]);
} else {
sheet.appendRow(row);
}
});
}
function getAlphaFeedbackJson_() {
var defaultPayload = {
formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
as_of: '',
analysis_period: '',
status: 'DATA_MISSING',
cases_analyzed: 0,
grade_count: 0,
eligible_t20_fail_rate: null,
eligible_t60_fail_rate: null,
recommended_filter_adjustments: [],
grade_summary: []
};
try {
var settings = readSettingsTab_();
var raw = settings['afl_v1_last_result'];
if (!raw) return defaultPayload;
var payload = typeof raw === 'string' ? JSON.parse(raw) : raw;
return payload && typeof payload === 'object' ? payload : defaultPayload;
} catch (e) {
Logger.log('[AFL] getAlphaFeedbackJson_ error: ' + e.message);
return defaultPayload;
}
}
// ── settings 탭 읽기 → 사용자 입력 파라미터 (total_asset 등) ────────────────
// settings 탭: row2=헤더(key|value|note), row3+=데이터
// 없으면 빈 객체 반환 (각 호출처에서 null 처리)
function readSettingsTab_() {
const result = {};
try {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName("settings");
if (!sheet) { Logger.log("readSettingsTab_: settings 탭 없음"); return result; }
const data = sheet.getDataRange().getValues();
// 헤더·메타 행 자동 스킵 — "key", "updated", "date" 등 예약어 및 빈 셀 무시
const SKIP_KEYS = new Set(["key", "updated", "date", "항목", "파라미터"]);
for (let i = 0; i < data.length; i++) {
const rawKey = String(data[i][0] ?? "").trim();
if (!rawKey || SKIP_KEYS.has(rawKey.toLowerCase())) continue;
const val = data[i][1];
if (val !== "" && val != null) result[rawKey] = val;
}
try {
var verbose = String(PropertiesService.getScriptProperties().getProperty('HARNESS_VERBOSE_LOG') || '').toLowerCase() === 'true';
if (verbose) Logger.log("readSettingsTab_ 로드됨: " + Object.keys(result).join(", "));
} catch (e) {}
} catch(e) { handleFetchError_("readSettingsTab_", e, "CRITICAL"); }
return result;
}
// ── performance 탭 읽기 → Bayesian multiplier 계산 ──────────────────────────
// spec/17_performance_contract.yaml 구현.
// performance 탭이 없거나 청산 완료 거래 5건 미만이면 medium_confidence(0.5×) 반환.
function readPerformanceSheet_() {
const DEFAULT = { bayesian_multiplier: 0.5, bayesian_label: "medium_confidence", trades_used: 0,
win_rate_30: null, net_expectancy_30: null, consecutive_losses: 0,
bayesian_data_source: "default" };
try {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName("performance");
if (!sheet) return DEFAULT;
const data = sheet.getDataRange().getValues();
if (data.length < 3) return DEFAULT;
const hdr = data[1].map(h => String(h).trim());
const pnlIdx = hdr.indexOf("pnl_pct");
const exitIdx = hdr.indexOf("exit_date");
const exitDateIdx = hdr.indexOf("exit_date");
if (pnlIdx < 0 || exitIdx < 0) return DEFAULT;
// 청산 완료 거래만 (exit_date 있음) — 최신 30건
const closed = [];
for (let i = 2; i < data.length; i++) {
const exitVal = data[i][exitIdx];
if (!exitVal || String(exitVal).trim() === "") continue;
const pnl = parseFloat(data[i][pnlIdx]);
if (!Number.isFinite(pnl)) continue;
const exitRaw = exitDateIdx >= 0 ? data[i][exitDateIdx] : exitVal;
const exitMs = exitRaw instanceof Date && !isNaN(exitRaw.getTime())
? exitRaw.getTime()
: new Date(exitRaw).getTime();
closed.push({ pnl, exitMs: Number.isFinite(exitMs) ? exitMs : 0 });
}
if (closed.length === 0) return DEFAULT;
closed.sort((a, b) => b.exitMs - a.exitMs);
const recent = closed.slice(0, 30).map(r => r.pnl);
const n = recent.length;
if (n < 5) return DEFAULT;
const wins = recent.filter(p => p > 0);
const losses = recent.filter(p => p <= 0);
const winRate = wins.length / n;
const avgWin = wins.length > 0 ? wins.reduce((a,b)=>a+b,0)/wins.length : 0;
const avgLoss = losses.length > 0 ? losses.reduce((a,b)=>a+Math.abs(b),0)/losses.length : 0;
const netExp = winRate * avgWin - (1 - winRate) * avgLoss;
// 연속 손절 체크
let consLoss = 0;
for (const p of recent) {
if (p <= 0) consLoss++;
else break;
}
let multiplier, label;
if (consLoss >= 5) {
multiplier = 0.0; label = "no_bet";
} else if (winRate >= 0.60 && netExp >= 3.0) {
multiplier = 1.0; label = "high_bet";
} else if (winRate >= 0.45 && netExp >= 0) {
multiplier = 0.5; label = "medium_bet";
} else {
multiplier = 0.25; label = "low_bet";
}
return {
bayesian_multiplier: multiplier,
bayesian_label: label,
trades_used: n,
win_rate_30: parseFloat(winRate.toFixed(3)),
net_expectancy_30: parseFloat(netExp.toFixed(2)),
consecutive_losses: consLoss,
bayesian_data_source: "actual",
};
} catch(e) {
handleFetchError_("readPerformanceSheet_", e, "WARN");
return DEFAULT;
}
}
// ── 섹터 자금 흐름 ────────────────────────────────────────────────────────
const DEFAULT_SECTOR_UNIVERSE_V2 = [
{ sector: "반도체", proxyTicker: "091160", proxyName: "KODEX 반도체", proxyType: "ETF", baseTicker: "069500", constituents: [
{ code: "005930", name: "삼성전자", weight: 0.50 },
{ code: "000660", name: "SK하이닉스", weight: 0.35 },
{ code: "042700", name: "한미반도체", weight: 0.10 },
{ code: "091160", name: "KODEX 반도체", weight: 0.05, isEtf: true },
]},
{ sector: "AI전력", proxyTicker: "0117V0", proxyName: "TIGER 코리아AI전력기기TOP3플러스", proxyType: "ETF", baseTicker: "069500", constituents: [
{ code: "010120", name: "LS ELECTRIC", weight: 0.30 },
{ code: "267260", name: "HD현대일렉트릭", weight: 0.30 },
{ code: "006260", name: "LS", weight: 0.20 },
{ code: "062040", name: "산일전기", weight: 0.10 },
{ code: "298040", name: "효성중공업", weight: 0.10 },
]},
{ sector: "방산", proxyTicker: "012450", proxyName: "한화에어로스페이스", proxyType: "대표주", baseTicker: "069500", constituents: [
{ code: "012450", name: "한화에어로스페이스", weight: 0.45 },
{ code: "079550", name: "LIG넥스원", weight: 0.25 },
{ code: "047810", name: "한국항공우주", weight: 0.15 },
{ code: "064350", name: "현대로템", weight: 0.15 },
]},
{ sector: "조선", proxyTicker: "494670", proxyName: "TIGER 조선TOP10", proxyType: "ETF", baseTicker: "069500", constituents: [
{ code: "329180", name: "HD현대중공업", weight: 0.35 },
{ code: "042660", name: "한화오션", weight: 0.30 },
{ code: "009540", name: "HD한국조선해양", weight: 0.20 },
{ code: "494670", name: "TIGER 조선TOP10", weight: 0.15, isEtf: true },
]},
{ sector: "건설/EPC", proxyTicker: "028050", proxyName: "삼성E&A", proxyType: "대표주", baseTicker: "069500", constituents: [
{ code: "028050", name: "삼성E&A", weight: 0.40 },
{ code: "000720", name: "현대건설", weight: 0.30 },
{ code: "006360", name: "GS건설", weight: 0.20 },
{ code: "047040", name: "대우건설", weight: 0.10 },
]},
{ sector: "자동차", proxyTicker: "091180", proxyName: "TIGER 자동차", proxyType: "ETF", baseTicker: "069500", constituents: [
{ code: "005380", name: "현대차", weight: 0.45 },
{ code: "000270", name: "기아", weight: 0.40 },
{ code: "012330", name: "현대모비스", weight: 0.15 },
]},
{ sector: "금융/은행", proxyTicker: "091170", proxyName: "KODEX 은행", proxyType: "ETF", baseTicker: "069500", constituents: [
{ code: "105560", name: "KB금융", weight: 0.30 },
{ code: "055550", name: "신한지주", weight: 0.30 },
{ code: "086790", name: "하나금융지주", weight: 0.20 },
{ code: "316140", name: "우리금융지주", weight: 0.10 },
{ code: "003540", name: "대신증권", weight: 0.10 },
]},
{ sector: "2차전지", proxyTicker: "305720", proxyName: "KODEX 2차전지산업", proxyType: "ETF", baseTicker: "069500", constituents: [
{ code: "373220", name: "LG에너지솔루션", weight: 0.40 },
{ code: "006400", name: "삼성SDI", weight: 0.30 },
{ code: "051910", name: "LG화학", weight: 0.20 },
{ code: "096770", name: "SK이노베이션", weight: 0.10 },
]},
{ sector: "바이오", proxyTicker: "266410", proxyName: "KODEX 헬스케어", proxyType: "ETF", baseTicker: "069500", constituents: [
{ code: "207940", name: "삼성바이오로직스", weight: 0.45 },
{ code: "068270", name: "셀트리온", weight: 0.30 },
{ code: "128940", name: "한미약품", weight: 0.15 },
{ code: "000100", name: "유한양행", weight: 0.10 },
]},
{ sector: "원전", proxyTicker: "099440", proxyName: "두산에너빌리티", proxyType: "대표주", baseTicker: "069500", constituents: [
{ code: "099440", name: "두산에너빌리티", weight: 0.45 },
{ code: "023450", name: "한전기술", weight: 0.25 },
{ code: "015760", name: "한국전력", weight: 0.20 },
{ code: "071320", name: "지역난방공사", weight: 0.10 },
]},
{ sector: "소비재", proxyTicker: "139220", proxyName: "TIGER 생활소비재", proxyType: "ETF", baseTicker: "069500", constituents: [
{ code: "028260", name: "삼성물산", weight: 0.35 },
{ code: "097950", name: "CJ제일제당", weight: 0.25 },
{ code: "004370", name: "농심", weight: 0.20 },
{ code: "051900", name: "LG생활건강", weight: 0.20 },
]},
];
function runSectorFlow() {
const rows = runSectorFlowV3();
writeLegacySectorFlowFromStage2_(rows);
// 연쇄 실행: 매크로 지표
runMacro();
}
function normalizeSectorName_(sector) {
const s = String(sector ?? "").trim();
if (s === "AI전력/전력기기") return "AI전력";
if (s === "바이오/헬스케어") return "바이오";
if (s === "원전/에너지") return "원전";
if (s === "소비재/유통") return "소비재";
return s;
}
function boolFromSheet_(value, defaultValue) {
if (value === true || value === false) return value;
const s = String(value ?? "").trim().toUpperCase();
if (["TRUE","Y","YES","1","사용","사용함"].includes(s)) return true;
if (["FALSE","N","NO","0","미사용","제외"].includes(s)) return false;
return defaultValue;
}
function readSectorUniverse_() {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName("sector_universe");
if (!sheet) {
writeDefaultSectorUniverseSheet_();
return DEFAULT_SECTOR_UNIVERSE_V2;
}
const data = sheet.getDataRange().getValues();
if (data.length < 3) {
writeDefaultSectorUniverseSheet_();
return DEFAULT_SECTOR_UNIVERSE_V2;
}
const hdr = data[1].map(h => String(h).trim());
const idx = name => hdr.indexOf(name);
const required = ["Sector","Proxy_Ticker","Constituent_Code","Weight"];
if (required.some(h => idx(h) < 0)) return DEFAULT_SECTOR_UNIVERSE_V2;
const map = {};
for (let i = 2; i < data.length; i++) {
const enabled = idx("Enabled") >= 0 ? boolFromSheet_(data[i][idx("Enabled")], true) : true;
if (!enabled) continue;
const sector = normalizeSectorName_(data[i][idx("Sector")]);
const code = normalizeTickerCode(data[i][idx("Constituent_Code")]);
const weight = parseFloat(data[i][idx("Weight")]);
if (!sector || !code || !Number.isFinite(weight) || weight <= 0) continue;
if (!map[sector]) {
map[sector] = {
sector,
proxyTicker: normalizeTickerCode(data[i][idx("Proxy_Ticker")]),
proxyName: idx("Proxy_Name") >= 0 ? String(data[i][idx("Proxy_Name")] ?? "").trim() : "",
proxyType: idx("Proxy_Type") >= 0 ? String(data[i][idx("Proxy_Type")] ?? "").trim() : "",
baseTicker: idx("Base_Ticker") >= 0 ? normalizeTickerCode(data[i][idx("Base_Ticker")]) : "069500",
constituents: [],
};
}
map[sector].constituents.push({
code,
name: idx("Constituent_Name") >= 0 ? String(data[i][idx("Constituent_Name")] ?? "").trim() : "",
weight,
isEtf: idx("Is_ETF") >= 0 ? boolFromSheet_(data[i][idx("Is_ETF")], false) : false,
});
}
const sectors = Object.values(map).filter(s => s.proxyTicker && s.constituents.length > 0);
return sectors.length ? sectors : DEFAULT_SECTOR_UNIVERSE_V2;
}
function writeDefaultSectorUniverseSheet_() {
const headers = [
"Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Base_Ticker",
"Constituent_Code","Constituent_Name","Weight","Is_ETF","Enabled","Effective_Date","Source"
];
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
const rows = [];
for (const sector of DEFAULT_SECTOR_UNIVERSE_V2) {
for (const c of sector.constituents) {
rows.push([
sector.sector,
sector.proxyTicker,
sector.proxyName,
sector.proxyType || "대표주",
sector.baseTicker || "069500",
c.code,
c.name || "",
c.weight,
c.isEtf ? "Y" : "N",
"Y",
today,
"sector_universe(DEFAULT_SECTOR_UNIVERSE_V2)",
]);
}
}
writeToSheet("sector_universe", headers, rows);
Logger.log(`sector_universe 기본 템플릿 생성: ${rows.length}행`);
}
function sectorDataQuality_(coverage, flowRowsMin, staleCount, proxyOk, hasNorm, weightSum) {
if (!proxyOk || coverage <= 0 || !hasNorm) return "D";
if (coverage >= 0.80 && flowRowsMin >= 20 && staleCount === 0 && weightSum >= 0.70) return "A";
if (coverage >= 0.60 && flowRowsMin >= 5 && weightSum >= 0.60) return "B";
return "C";
}
function sectorUseMode_(quality) {
if (quality === "A" || quality === "B") return "TRADE_OK";
if (quality === "C") return "WATCH_ONLY";
return "INVALID";
}
function scoreSmartMoneyNorm_(v) {
if (!Number.isFinite(v)) return 0;
if (v >= 0.15) return 25;
if (v >= 0.05) return 18;
if (v > 0) return 10;
if (v > -0.05) return 4;
return 0;
}
function scoreBreadth_(v) {
if (!Number.isFinite(v)) return 0;
if (v >= 0.70) return 15;
if (v >= 0.50) return 10;
if (v >= 0.30) return 5;
return 0;
}
function calcEtfLiquidityScore_(etf) {
if (!etf || etf.proxyType !== "ETF") return 5;
let score = 0;
if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw >= 1000000000) score += 4;
else if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw >= 300000000) score += 2;
if (Number.isFinite(etf.spreadPct) && etf.spreadPct <= 0.25) score += 3;
else if (Number.isFinite(etf.spreadPct) && etf.spreadPct <= 0.50) score += 1;
if (etf.priceOk && !etf.isPriceStale) score += 2;
if (etf.navRisk === "NAV_DATA_MISSING") score += 0;
else if (etf.navRisk === "OK") score += 1;
return Math.max(0, Math.min(10, score));
}
function calcEtfLiquidityStatus_(etf) {
if (!etf || etf.proxyType !== "ETF") return "NOT_ETF";
if (!etf.priceOk) return "BLOCK";
if (etf.isPriceStale) return "WARN";
if (Number.isFinite(etf.spreadPct) && etf.spreadPct > 0.80) return "BLOCK";
if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw < 300000000) return "WARN";
if (etf.navRisk === "NAV_DATA_MISSING") return "WARN";
return "OK";
}
function calcEtfExecutionUse_(etf) {
if (!etf || etf.proxyType !== "ETF") return "NOT_ETF";
if (etf.liquidityStatus === "BLOCK" || !etf.priceOk) return "BLOCK";
if (etf.navRisk !== "OK") return "WATCH_ONLY";
if (etf.liquidityStatus === "OK") return "TRADE_OK";
return "WATCH_ONLY";
}
function readEtfNavManualMap_() {
const result = {};
try {
const sheet = getSpreadsheet_().getSheetByName("etf_nav_manual");
if (!sheet) return result;
const data = sheet.getDataRange().getValues();
if (data.length < 3) return result;
const hdr = data[1].map(h => String(h).trim());
const idx = name => hdr.indexOf(name);
const tickerIdx = idx("ETF_Ticker");
if (tickerIdx < 0) return result;
for (let i = 2; i < data.length; i++) {
const ticker = normalizeTickerCode(data[i][tickerIdx]);
if (!ticker) continue;
const enabled = idx("Enabled") >= 0 ? boolFromSheet_(data[i][idx("Enabled")], true) : true;
if (!enabled) continue;
const close = idx("Close") >= 0 ? parseFloat(data[i][idx("Close")]) : null;
const nav = idx("NAV") >= 0 ? parseFloat(data[i][idx("NAV")]) : null;
const inav = idx("iNAV") >= 0 ? parseFloat(data[i][idx("iNAV")]) : null;
let premiumDiscountPct = idx("Premium_Discount_Pct") >= 0 ? parseFloat(data[i][idx("Premium_Discount_Pct")]) : null;
const basisPrice = Number.isFinite(close) ? close : null;
const basisNav = Number.isFinite(nav) ? nav : Number.isFinite(inav) ? inav : null;
if (!Number.isFinite(premiumDiscountPct) && Number.isFinite(basisPrice) && Number.isFinite(basisNav) && basisNav > 0) {
premiumDiscountPct = ((basisPrice / basisNav) - 1) * 100;
}
const sourceDate = idx("Source_Date") >= 0 ? normalizeSheetDateString_(data[i][idx("Source_Date")]) : "";
const trackingError = idx("Tracking_Error") >= 0 ? parseFloat(data[i][idx("Tracking_Error")]) : null;
const aum = idx("AUM") >= 0 ? parseFloat(data[i][idx("AUM")]) : null;
result[ticker] = {
close: Number.isFinite(close) ? close : null,
nav: Number.isFinite(nav) ? nav : null,
inav: Number.isFinite(inav) ? inav : null,
premiumDiscountPct: Number.isFinite(premiumDiscountPct) ? premiumDiscountPct : null,
trackingError: Number.isFinite(trackingError) ? trackingError : null,
aum: Number.isFinite(aum) ? aum : null,
sourceDate,
source: idx("Source") >= 0 ? String(data[i][idx("Source")] ?? "").trim() : "etf_nav_manual",
};
}
} catch(e) { handleFetchError_("readEtfNavManualMap_", e, "WARN"); }
return result;
}
function calcEtfNavRisk_(manual) {
if (!manual) return "NAV_DATA_MISSING";
if (!Number.isFinite(manual.nav) && !Number.isFinite(manual.inav)) return "NAV_DATA_MISSING";
if (manual.sourceDate && isStalePriceDate_(manual.sourceDate, 2)) return "NAV_STALE";
if (Number.isFinite(manual.premiumDiscountPct) && Math.abs(manual.premiumDiscountPct) > 1.0) return "NAV_BLOCK";
if (Number.isFinite(manual.premiumDiscountPct) && Math.abs(manual.premiumDiscountPct) > 0.5) return "NAV_WARN";
return "OK";
}
function buildEtfRawRows_(universe) {
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
const navManual = readEtfNavManualMap_();
const etfMap = {};
for (const sector of universe) {
if (sector.proxyType === "ETF") {
etfMap[sector.proxyTicker] = {
sector: sector.sector,
ticker: sector.proxyTicker,
name: sector.proxyName,
proxyType: sector.proxyType,
};
}
for (const c of sector.constituents) {
if (c.isEtf) {
etfMap[c.code] = {
sector: sector.sector,
ticker: c.code,
name: c.name || sector.proxyName,
proxyType: "ETF",
};
}
}
}
const rows = [];
for (const etf of Object.values(etfMap)) {
const price = fetchYahooOhlcMetrics(etf.ticker);
const flow = fetchNaverFlow(etf.ticker);
const close = Number.isFinite(price.close) ? price.close : null;
const frg5Sh = flow.ok ? flow.rows.slice(0, 5).reduce((a, r) => a + r.frgn, 0) : null;
const inst5Sh = flow.ok ? flow.rows.slice(0, 5).reduce((a, r) => a + r.inst, 0) : null;
const frg5Krw = Number.isFinite(frg5Sh) && Number.isFinite(close) ? frg5Sh * close : null;
const inst5Krw = Number.isFinite(inst5Sh) && Number.isFinite(close) ? inst5Sh * close : null;
const avgTradeValue5DKrw = Number.isFinite(price.avgTradingValue5D) ? price.avgTradingValue5D * 1000000 : null;
const avgTradeValue20DKrw = Number.isFinite(price.avgTradingValue20D) ? price.avgTradingValue20D * 1000000 : null;
const manual = navManual[etf.ticker] ?? null;
const raw = {
...etf,
close: Number.isFinite(manual?.close) ? manual.close : close,
nav: manual?.nav ?? null,
inav: manual?.inav ?? null,
premiumDiscountPct: manual?.premiumDiscountPct ?? null,
trackingError: manual?.trackingError ?? null,
aum: manual?.aum ?? null,
bid: Number.isFinite(price.bid) ? price.bid : null,
ask: Number.isFinite(price.ask) ? price.ask : null,
spreadPct: Number.isFinite(price.spreadPct) ? price.spreadPct : null,
avgTradeValue5DKrw,
avgTradeValue20DKrw,
etfFrg5Krw: frg5Krw,
etfInst5Krw: inst5Krw,
priceOk: Boolean(price.ok),
isPriceStale: Boolean(price.isPriceStale),
flowOk: Boolean(flow.ok),
flowRows: Array.isArray(flow.rows) ? flow.rows.length : 0,
navRisk: calcEtfNavRisk_(manual),
navSource: manual?.source ?? "",
navSourceDate: manual?.sourceDate ?? "",
asOfDate: today,
};
raw.liquidityScore = calcEtfLiquidityScore_(raw);
raw.liquidityStatus = calcEtfLiquidityStatus_(raw);
raw.executionUse = calcEtfExecutionUse_(raw);
raw.lpQualityFlag = raw.liquidityStatus === "OK" ? "OK" : raw.liquidityStatus;
raw.dataStatus = raw.priceOk ? (raw.flowOk ? "PARTIAL_NAV_MISSING" : "PARTIAL_FLOW_NAV_MISSING") : "FAIL";
rows.push(raw);
Utilities.sleep(100);
}
return rows;
}
function buildEtfRawMap_(etfRows) {
return Object.fromEntries(etfRows.map(r => [r.ticker, r]));
}
function calcSectorScoreV2_(sectorRet20D, sectorRs20D, smart5Norm, smart20Norm, breadth5, tradeValueRatio, proxyType, etfLiquidityScore) {
let score = 0;
const rs = Number.isFinite(sectorRs20D) ? sectorRs20D : sectorRet20D;
score += rs >= 8 ? 25 : rs >= 3 ? 18 : rs >= 0 ? 10 : rs >= -3 ? 5 : 0;
score += Math.min(25, Math.round(scoreSmartMoneyNorm_(smart5Norm) * 0.7 + scoreSmartMoneyNorm_(smart20Norm) * 0.3));
score += scoreBreadth_(breadth5);
score += tradeValueRatio >= 1.2 ? 15 : tradeValueRatio >= 0.8 ? 8 : 0;
score += 5; // EPS revision/PER/PBR 정밀 축은 Phase 2에서 보수적 중립값만 부여.
score += proxyType === "ETF" ? (Number.isFinite(etfLiquidityScore) ? etfLiquidityScore : 0) : 5;
return Math.max(0, Math.min(100, score));
}
function runSectorFlowV3() {
const universe = readSectorUniverse_();
const etfRawMap = buildEtfRawMap_(buildEtfRawRows_(universe));
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
const headers = [
"Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Coverage_Weight",
"Sector_Ret5D","Sector_Ret20D","Sector_RS_20D",
"SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW","SmartMoney_5D_Norm",
"Flow_Breadth_5D","Flow_Rows_Min","Stale_Count",
"ETF_Liquidity_Score","ETF_NAV_Risk","ETF_Liquidity_Status","ETF_Execution_Use",
"Sector_Median_PE","Sector_Median_PBR",
"Sector_Score","Sector_Rank","Alert_Level","Data_Quality","Decision_Use","Reason","AsOfDate"
];
const rows = [];
for (const sector of universe) {
const proxy = fetchYahooOhlcMetrics(sector.proxyTicker);
const base = sector.baseTicker ? fetchYahooOhlcMetrics(sector.baseTicker) : { ok: false };
const perVals = [], pbrVals = [];
const eligibleConstituents = sector.constituents.filter(c => !c.isEtf);
const weightSum = eligibleConstituents.reduce((a, c) => a + (Number(c.weight) || 0), 0);
let coverage = 0, frg5Krw = 0, inst5Krw = 0, frg20Krw = 0, inst20Krw = 0;
let avgTv20Krw = 0, avgTv5Krw = 0, ret5Weighted = 0, ret20Weighted = 0, breadth5 = 0;
let flowRowsMin = 999, staleCount = 0;
const reasons = [];
for (const c of eligibleConstituents) {
const w = Number(c.weight) || 0;
const flow = fetchNaverFlow(c.code);
const price = fetchYahooOhlcMetrics(c.code);
const flowRows = Array.isArray(flow.rows) ? flow.rows.length : 0;
if (!flow.ok || !price.ok || flowRows < 5 || !Number.isFinite(price.close)) {
reasons.push(`${c.code}:DATA_PARTIAL`);
Utilities.sleep(150);
continue;
}
const frg5Sh = flow.rows.slice(0, 5).reduce((a, r) => a + r.frgn, 0);
const inst5Sh = flow.rows.slice(0, 5).reduce((a, r) => a + r.inst, 0);
const frg20Sh = flow.rows.slice(0, 20).reduce((a, r) => a + r.frgn, 0);
const inst20Sh = flow.rows.slice(0, 20).reduce((a, r) => a + r.inst, 0);
const cFrg5Krw = frg5Sh * price.close;
const cInst5Krw = inst5Sh * price.close;
const cFrg20Krw = frg20Sh * price.close;
const cInst20Krw = inst20Sh * price.close;
coverage += w;
frg5Krw += cFrg5Krw * w;
inst5Krw += cInst5Krw * w;
frg20Krw += cFrg20Krw * w;
inst20Krw += cInst20Krw * w;
if (Number.isFinite(price.avgTradingValue20D)) avgTv20Krw += price.avgTradingValue20D * 1000000 * w;
if (Number.isFinite(price.avgTradingValue5D)) avgTv5Krw += price.avgTradingValue5D * 1000000 * w;
if (Number.isFinite(price.ret5D)) ret5Weighted += price.ret5D * w;
if (Number.isFinite(price.ret20D)) ret20Weighted += price.ret20D * w;
if (cFrg5Krw + cInst5Krw > 0) breadth5 += w;
flowRowsMin = Math.min(flowRowsMin, flowRows);
if (flow.isFlowStale || price.isPriceStale) staleCount++;
const qm = fetchNaverMarketMetrics(c.code);
if (Number.isFinite(qm.per) && qm.per > 0) perVals.push(qm.per);
if (Number.isFinite(qm.pbr) && qm.pbr > 0) pbrVals.push(qm.pbr);
Utilities.sleep(150);
}
if (flowRowsMin === 999) flowRowsMin = 0;
const smart5 = frg5Krw + inst5Krw;
const smart20 = frg20Krw + inst20Krw;
const smart5Norm = avgTv20Krw > 0 ? smart5 / avgTv20Krw : null;
const smart20Norm = avgTv20Krw > 0 ? smart20 / avgTv20Krw : null;
const sectorRet5D = coverage > 0 ? ret5Weighted / coverage : null;
const sectorRet20D = coverage > 0 ? ret20Weighted / coverage : null;
const sectorRs20D = Number.isFinite(sectorRet20D) && base.ok && Number.isFinite(base.ret20D) ? sectorRet20D - base.ret20D : null;
const tradeValueRatio = avgTv20Krw > 0 && avgTv5Krw > 0 ? avgTv5Krw / avgTv20Krw : null;
const medianPE = calcMedian_(perVals);
const medianPBR = calcMedian_(pbrVals);
const etfRaw = etfRawMap[sector.proxyTicker] ?? null;
const etfLiquidityScore = sector.proxyType === "ETF" ? (etfRaw?.liquidityScore ?? 0) : 5;
const etfNavRisk = sector.proxyType === "ETF" ? (etfRaw?.navRisk ?? "NAV_DATA_MISSING") : "NOT_ETF";
const etfLiquidityStatus = sector.proxyType === "ETF" ? (etfRaw?.liquidityStatus ?? "WARN") : "NOT_ETF";
const etfExecutionUse = sector.proxyType === "ETF" ? (etfRaw?.executionUse ?? "WATCH_ONLY") : "NOT_ETF";
const quality = sectorDataQuality_(coverage, flowRowsMin, staleCount, proxy.ok, Number.isFinite(smart5Norm), weightSum);
const routeUse = sectorUseMode_(quality);
let score = calcSectorScoreV2_(sectorRet20D, sectorRs20D, smart5Norm, smart20Norm, breadth5, tradeValueRatio, sector.proxyType, etfLiquidityScore);
if (quality === "C") score = Math.min(score, 49);
if (quality === "D") score = Math.min(score, 20);
const alert = score >= 70 && smart5 > 0 && breadth5 >= 0.50 ? "INFLOW_STRONG" :
score >= 50 && smart5 > 0 ? "INFLOW_MODERATE" :
score >= 30 ? "NEUTRAL" :
smart5 < 0 && breadth5 < 0.40 ? "OUTFLOW_ALERT" : "OUTFLOW_CAUTION";
if (quality === "C") reasons.push("Data_Quality=C:WATCH_ONLY");
if (quality === "D") reasons.push("Data_Quality=D:INVALID");
if (coverage < 0.60) reasons.push("Coverage<0.60");
if (sector.constituents.length !== eligibleConstituents.length) reasons.push("ETF_Constituent_Excluded_From_Sector_Flow");
if (staleCount > 0) reasons.push(`Stale_Count=${staleCount}`);
if (!proxy.ok) reasons.push("Proxy_Price_FAIL");
if (!Number.isFinite(smart5Norm)) reasons.push("SmartMoney_Norm_MISSING");
if (sector.proxyType === "ETF" && etfNavRisk === "NAV_DATA_MISSING") reasons.push("ETF_NAV_DATA_MISSING");
if (sector.proxyType === "ETF" && etfLiquidityStatus !== "OK") reasons.push(`ETF_Liquidity=${etfLiquidityStatus}`);
if (sector.proxyType === "ETF" && etfExecutionUse !== "TRADE_OK") reasons.push(`ETF_Execution=${etfExecutionUse}`);
rows.push({
sector: sector.sector,
proxyTicker: sector.proxyTicker,
proxyName: sector.proxyName,
proxyType: sector.proxyType || "대표주",
coverage,
sectorRet5D,
sectorRet20D,
sectorRs20D,
frg5Krw,
inst5Krw,
frg20Krw,
inst20Krw,
smart5,
smart20,
avgTv20Krw,
smart5Norm,
breadth5,
flowRowsMin,
staleCount,
etfLiquidityScore,
etfNavRisk,
etfLiquidityStatus,
etfExecutionUse,
medianPE,
medianPBR,
score,
rank: 0,
alert,
quality,
routeUse,
reason: reasons.length ? reasons.join(" | ") : "OK",
asOfDate: today,
proxyRet5D: proxy.ok ? proxy.ret5D : null,
proxyRet10D: proxy.ok ? proxy.ret10D : null,
proxyRet20D: proxy.ok ? proxy.ret20D : null,
});
}
rows.sort((a, b) => Number(b.score) - Number(a.score));
rows.forEach((r, i) => { r.rank = i + 1; });
appendSectorFlowHistoryV2_(rows);
return rows;
}
function appendSectorFlowHistoryV2_(rows) {
// 주말(토·일)은 KRX 휴장 — 새 시장 데이터 없으므로 이력 저장 불필요
const dow = new Date().getDay(); // 0=일, 6=토
if (dow === 0 || dow === 6) {
Logger.log("appendSectorFlowHistoryV2_: 주말 스킵 (dow=" + dow + ")");
return;
}
const headers = [
"Snapshot_Date","Sector","Sector_Score","Sector_Rank","SmartMoney_5D_KRW","SmartMoney_20D_KRW",
"Flow_Breadth_5D","Alert_Level","Data_Quality","Decision_Use","ETF_Liquidity_Status","ETF_Execution_Use","Reason","Saved_At"
];
const ss = getSpreadsheet_();
let sheet = ss.getSheetByName("sector_flow_history");
if (!sheet) {
sheet = ss.insertSheet("sector_flow_history");
sheet.getRange(1, 1).setValue("updated: sector_flow_history cumulative snapshots");
sheet.getRange(2, 1, 1, headers.length).setValues([headers]);
}
const data = sheet.getDataRange().getValues();
const hdr = data[1] ?? headers;
const dateIdx = hdr.indexOf("Snapshot_Date");
const sectorIdx = hdr.indexOf("Sector");
const existing = [];
const byKey = {};
for (let i = 2; i < data.length; i++) {
const row = data[i];
const d = normalizeSheetDateString_(row[dateIdx]);
const s = String(row[sectorIdx] ?? "").trim();
if (!d || !s) continue;
byKey[`${d}|${s}`] = row;
existing.push(row);
}
const savedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
for (const r of rows) {
byKey[`${r.asOfDate}|${r.sector}`] = [
r.asOfDate, r.sector, r.score, r.rank, Math.round(r.smart5), Math.round(r.smart20),
roundNum(r.breadth5, 4), r.alert, r.quality, r.routeUse, r.etfLiquidityStatus, r.etfExecutionUse, r.reason, savedAt
];
}
const out = Object.values(byKey).sort((a, b) => {
const da = String(a[0]), db = String(b[0]);
if (da !== db) return da.localeCompare(db);
return String(a[1]).localeCompare(String(b[1]));
});
sheet.clearContents();
sheet.getRange(1, 1).setValue(`updated: ${savedAt} KST`);
sheet.getRange(2, 1, 1, headers.length).setValues([headers]);
if (out.length) sheet.getRange(3, 1, out.length, headers.length).setValues(out);
}
function normalizeSheetDateString_(value) {
if (value instanceof Date && !isNaN(value.getTime())) {
return Utilities.formatDate(value, "Asia/Seoul", "yyyy-MM-dd");
}
const raw = String(value ?? "").trim();
if (!raw) return "";
const normalized = raw.replace(/\./g, "-").replace(/\//g, "-");
const m = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (m) return `${m[1]}-${String(m[2]).padStart(2, "0")}-${String(m[3]).padStart(2, "0")}`;
const d = new Date(raw);
return isNaN(d.getTime()) ? "" : Utilities.formatDate(d, "Asia/Seoul", "yyyy-MM-dd");
}
function readSectorFlowHistoryPrev_(currentDate) {
const result = {};
try {
const sheet = getSpreadsheet_().getSheetByName("sector_flow_history");
if (!sheet) return result;
const data = sheet.getDataRange().getValues();
const hdr = data[1] ?? [];
const dIdx = hdr.indexOf("Snapshot_Date");
const sIdx = hdr.indexOf("Sector");
const rankIdx = hdr.indexOf("Sector_Rank");
const sm5Idx = hdr.indexOf("SmartMoney_5D_KRW");
const breadthIdx = hdr.indexOf("Flow_Breadth_5D");
if (dIdx < 0 || sIdx < 0) return result;
const grouped = {};
for (let i = 2; i < data.length; i++) {
const d = normalizeSheetDateString_(data[i][dIdx]);
const s = String(data[i][sIdx] ?? "").trim();
if (!d || !s || d === currentDate) continue;
if (!grouped[s]) grouped[s] = [];
grouped[s].push({
date: d,
rank: rankIdx >= 0 ? parseInt(data[i][rankIdx]) : null,
smart5: sm5Idx >= 0 ? parseFloat(data[i][sm5Idx]) : null,
breadth5: breadthIdx >= 0 ? parseFloat(data[i][breadthIdx]) : null,
});
}
for (const [sector, items] of Object.entries(grouped)) {
items.sort((a, b) => b.date.localeCompare(a.date));
result[sector] = { w1: items[0] ?? null, w2: items[1] ?? null };
}
} catch(e) { handleFetchError_("readSectorFlowHistoryPrev_", e, "WARN"); }
return result;
}
function readPrevLegacySectorFlow_() {
const result = {};
try {
const sfSheet = getSpreadsheet_().getSheetByName("sector_flow");
if (!sfSheet) return result;
const data = sfSheet.getDataRange().getValues();
const hdr = data[1] ?? [];
const sIdx = hdr.indexOf("Sector");
const rIdx = hdr.indexOf("Sector_Rank") >= 0 ? hdr.indexOf("Sector_Rank") : hdr.indexOf("Rotation_Rank");
const s5Idx = hdr.indexOf("SmartMoney_5D_KRW") >= 0 ? hdr.indexOf("SmartMoney_5D_KRW") : hdr.indexOf("Frg_5D_SUM");
const s20Idx = hdr.indexOf("SmartMoney_20D_KRW") >= 0 ? hdr.indexOf("SmartMoney_20D_KRW") : hdr.indexOf("Frg_20D_SUM");
if (sIdx < 0) return result;
for (let i = 2; i < data.length; i++) {
const s = String(data[i][sIdx]).trim();
if (!s || s === "Sector") continue;
const smart5 = s5Idx >= 0 ? parseFloat(data[i][s5Idx]) : null;
const smart20 = s20Idx >= 0 ? parseFloat(data[i][s20Idx]) : null;
result[s] = {
rank: rIdx >= 0 ? parseInt(data[i][rIdx]) : null,
smart5: Number.isFinite(smart5) ? smart5 : null,
smart20: Number.isFinite(smart20) ? smart20 : null,
frg5: Number.isFinite(smart5) ? smart5 : null,
inst5: Number.isFinite(smart5) ? smart5 : null,
};
}
} catch(e) { handleFetchError_("readPrevLegacySectorFlow_", e, "WARN"); }
return result;
}
function readW2LegacySectorFlow_() {
const result = {};
try {
const props = PropertiesService.getScriptProperties();
const w2Json = props.getProperty("sf_w2_ranks_json");
if (w2Json) Object.assign(result, JSON.parse(w2Json).data ?? {});
} catch(e) { handleFetchError_("readW2LegacySectorFlow_", e, "INFO"); }
return result;
}
function writeLegacySectorFlowFromStage2_(stage2Rows) {
const headers = [
"Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Coverage_Weight",
"Sector_Ret5D","Sector_Ret10D","Sector_Ret20D","Sector_RS_20D",
"SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW",
"SmartMoney_5D_Norm","SmartMoney_20D_Norm","Flow_Breadth_5D","Flow_Rows_Min","Stale_Count",
"ETF_Liquidity_Score","ETF_NAV_Risk","ETF_Liquidity_Status","ETF_Execution_Use",
"Sector_Median_PE","Sector_Median_PBR","Sector_Score","Sector_Rank",
"Alert_Level","Data_Quality","Decision_Use","Reason","RW1","RW3","AsOfDate",
"ETF_Code","Frg_5D_SUM","Inst_5D_SUM","Indiv_5D_SUM","Frg_20D_SUM","Inst_20D_SUM",
"ETF_Ret5D","ETF_Ret10D","ETF_Ret20D",
"Rotation_Score","Rotation_Rank","Prev_Rotation_Rank","Prev_Frg_5D_SUM","Prev_Inst_5D_SUM",
"Prev_Rotation_Rank_W2","Prev_Frg_5D_SUM_W2","Prev_Inst_5D_SUM_W2","Smart_Money"
];
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
const prev = readPrevLegacySectorFlow_();
const w2 = readW2LegacySectorFlow_();
const historyPrev = readSectorFlowHistoryPrev_(today);
try {
const props = PropertiesService.getScriptProperties();
if (Object.keys(prev).length > 0) props.setProperty("sf_w2_ranks_json", JSON.stringify({ saved_at: today, data: prev }));
} catch(e) { handleFetchError_("writeLegacySectorFlowFromStage2_:W2 save", e, "INFO"); }
const rows = stage2Rows.map(r => {
const p = prev[r.sector] ?? {};
const w = w2[r.sector] ?? {};
const hp = historyPrev[r.sector]?.w1 ?? null;
const hw = historyPrev[r.sector]?.w2 ?? null;
const w1Rank = Number.isFinite(hp?.rank) ? hp.rank : p.rank;
const w2Rank = Number.isFinite(hw?.rank) ? hw.rank : w.rank;
const rw1 = Number.isFinite(w1Rank) && Number.isFinite(w2Rank) && (r.rank - w1Rank >= 3) && (w1Rank - w2Rank >= 3) ? 1 : 0;
const curOutflow = r.smart5 < 0 && r.breadth5 < 0.40;
const prevOutflow = Number.isFinite(p.frg5) && p.frg5 < 0 && Number.isFinite(p.inst5) && p.inst5 < 0;
const histOutflow = Number.isFinite(hp?.smart5) && hp.smart5 < 0 && Number.isFinite(hp?.breadth5) && hp.breadth5 < 0.40;
const rw3 = curOutflow && (histOutflow || prevOutflow) ? 1 : 0;
const smart = r.smart5 > 0 && r.breadth5 >= 0.70 ? "STRONG" :
r.smart5 > 0 && r.breadth5 >= 0.40 ? "MODERATE" :
r.smart5 > 0 ? "WEAK" : "ABSENT";
const smartMoneyHalf = Number.isFinite(r.smart5) ? r.smart5 / 2 : "";
const frg5Alias = Number.isFinite(smartMoneyHalf) ? smartMoneyHalf : "";
const inst5Alias = Number.isFinite(smartMoneyHalf) ? smartMoneyHalf : "";
const frg20Alias = Number.isFinite(r.smart20) ? r.smart20 / 2 : "";
const inst20Alias = Number.isFinite(r.smart20) ? r.smart20 / 2 : "";
return [
r.sector, r.proxyTicker, r.proxyName, r.proxyType, r.coverage,
r.sectorRet5D, r.proxyRet10D, r.sectorRet20D, r.sectorRs20D,
r.smart5, r.smart20, r.avgTv20Krw,
r.smart5Norm, r.smart20Norm, r.breadth5, r.flowRowsMin, r.staleCount,
r.etfLiquidityScore, r.etfNavRisk, r.etfLiquidityStatus, r.etfExecutionUse,
r.medianPE != null ? r.medianPE.toFixed(1) : "",
r.medianPBR != null ? r.medianPBR.toFixed(2) : "",
r.score, r.rank,
r.alert, r.quality, r.routeUse, r.reason, rw1, rw3, r.asOfDate,
r.proxyTicker, frg5Alias, inst5Alias, 0, frg20Alias, inst20Alias,
Number.isFinite(r.proxyRet5D) ? r.proxyRet5D : "N/A",
Number.isFinite(r.proxyRet10D) ? r.proxyRet10D : "N/A",
Number.isFinite(r.proxyRet20D) ? r.proxyRet20D : "N/A",
r.score, r.rank, Number.isFinite(w1Rank) ? w1Rank : "",
Number.isFinite(p.frg5) ? p.frg5 : "", Number.isFinite(p.inst5) ? p.inst5 : "",
Number.isFinite(w2Rank) ? w2Rank : "", Number.isFinite(w.frg5) ? w.frg5 : "",
Number.isFinite(w.inst5) ? w.inst5 : "", smart
];
});
writeToSheet("sector_flow", headers, rows);
Logger.log(`sector_flow 완료: ${rows.length}섹터`);
}
// ── F4: Trailing Stop account_snapshot 일괄 갱신 ────────────────────────────
// _trailingStopUpdates_ 배열을 소비해 account_snapshot의 highest_price/stop_price/last_updated 갱신.
// 신규 최고가 경신 종목만 업데이트 — entry 없는 종목은 건드리지 않음.
function applyTrailingStopUpdates_() {
if (!_trailingStopUpdates_.length) return;
try {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName("account_snapshot");
if (!sheet) { Logger.log("applyTrailingStopUpdates_: account_snapshot 탭 없음"); return; }
const data = sheet.getDataRange().getValues();
const hdr = data[1] ?? []; // row2 = 헤더
const tkIdx = hdr.indexOf("ticker");
const highIdx= hdr.indexOf("highest_price_since_entry");
const stopIdx= hdr.indexOf("stop_price");
const updIdx = hdr.indexOf("last_updated");
if (tkIdx < 0 || highIdx < 0 || stopIdx < 0) {
Logger.log("applyTrailingStopUpdates_: account_snapshot 컬럼 미발견");
return;
}
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
const updateMap = {};
_trailingStopUpdates_.forEach(u => { updateMap[u.ticker] = u; });
for (let i = 2; i < data.length; i++) {
const tk = String(data[i][tkIdx] ?? "").trim();
if (!tk || !updateMap[tk]) continue;
const upd = updateMap[tk];
sheet.getRange(i + 1, highIdx + 1).setValue(upd.new_highest);
sheet.getRange(i + 1, stopIdx + 1).setValue(upd.new_stop);
if (updIdx >= 0) sheet.getRange(i + 1, updIdx + 1).setValue(today);
Logger.log(`TrailingStop 갱신: ${tk} highest=${upd.new_highest} stop=${upd.new_stop}`);
}
} catch(e) {
handleFetchError_("applyTrailingStopUpdates_", e, "WARN");
}
}
// ── 버킷 할당 상태 계산 ─────────────────────────────────────────────────────
// _bucketSnapshot_이 있어야 동작. runDataFeed() 실행 후 runMacro()에서 호출.
// 목표 범위: core 60-72%, satellite 10-25%, cash 10-22% (spec/risk)
function calcBucketStatus_() {
if (!_bucketSnapshot_) return null;
const { core_pct, satellite_pct } = _bucketSnapshot_;
const cash_pct = parseFloat(Math.max(0, 100 - core_pct - satellite_pct).toFixed(2));
const coreStatus = core_pct < THRESHOLDS.BUCKET_CORE_MIN ? "UNDERWEIGHT" : core_pct > THRESHOLDS.BUCKET_CORE_MAX ? "OVERWEIGHT" : "OK";
const satStatus = satellite_pct < THRESHOLDS.BUCKET_SAT_MIN ? "UNDERWEIGHT" : satellite_pct > THRESHOLDS.BUCKET_SAT_MAX ? "OVERWEIGHT" : "OK";
const cashStatus = cash_pct < THRESHOLDS.BUCKET_CASH_MIN ? "LOW" : cash_pct > THRESHOLDS.BUCKET_CASH_MAX ? "HIGH" : "OK";
const issues = [
coreStatus !== "OK" ? `core_${coreStatus}` : null,
satStatus !== "OK" ? `sat_${satStatus}` : null,
cashStatus !== "OK" ? `cash_${cashStatus}` : null,
].filter(Boolean);
return {
core_pct, satellite_pct, cash_pct,
core_status: coreStatus, satellite_status: satStatus, cash_status: cashStatus,
overall: issues.length === 0 ? "BALANCED" : issues.join("|"),
detail: `core=${core_pct}%(${coreStatus}) sat=${satellite_pct}%(${satStatus}) cash=${cash_pct}%(${cashStatus})`,
};
}
// ── 매크로 지표 수집 ─────────────────────────────────────────────────────────
function runMacro() {
const MACRO_TICKERS = [
{ sym: "^KS11", name: "KOSPI", category: "Index" },
{ sym: "^KQ11", name: "KOSDAQ", category: "Index" },
{ sym: "^VIX", name: "VIX", category: "Risk" },
{ sym: "KRW=X", name: "USD_KRW", category: "FX" },
{ sym: "JPY=X", name: "USD_JPY", category: "FX" },
{ sym: "DX-Y.NYB",name: "DXY", category: "FX" },
{ sym: "GC=F", name: "Gold", category: "Commodity" },
{ sym: "CL=F", name: "WTI_Oil", category: "Commodity" },
{ sym: "^TNX", name: "US10Y_Yield",category: "Bond" },
{ sym: "^TYX", name: "US30Y_Yield",category: "Bond" },
{ sym: "^GSPC", name: "SP500", category: "Index" },
{ sym: "^NDX", name: "NASDAQ100", category: "Index" },
// HYG: HY 회사채 ETF → Ret5D로 credit_stress_status 산출 (MRS 신용위험 입력값)
{ sym: "HYG", name: "HYG_HY_Bond",category: "CreditProxy" },
];
const headers = ["Symbol","Name","Category","Close","Ret1D","Ret2D","Ret5D","Ret10D","Ret20D","MA20","MA60","AsOfDate","Status"];
const rows = [];
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
for (const m of MACRO_TICKERS) {
const p = fetchYahooPrice(m.sym);
let ma20 = "", ma60 = "", ret10D = "", ret2D = "";
if (m.category === "Index") {
const ohlc = fetchYahooOhlcMetrics(m.sym);
if (ohlc?.ok) {
if (Number.isFinite(ohlc.ma20)) ma20 = ohlc.ma20.toFixed(2);
if (Number.isFinite(ohlc.ma60)) ma60 = ohlc.ma60.toFixed(2);
if (Number.isFinite(ohlc.ret10D)) ret10D = ohlc.ret10D.toFixed(2);
if (Number.isFinite(ohlc.ret2D)) ret2D = ohlc.ret2D.toFixed(2);
}
} else if (m.category === "FX" && m.name === "USD_JPY") {
// USD/JPY Ret2D: MRS usd_jpy_score 전용
if (p.ok && Number.isFinite(parseFloat(p.ret5D))) {
// 2일 변화율은 fetchYahooOhlcMetrics가 필요 — FX는 budget 여유 있으면 시도
const ohlc = fetchYahooOhlcMetrics(m.sym);
if (ohlc?.ok && Number.isFinite(ohlc.ret2D)) ret2D = ohlc.ret2D.toFixed(2);
}
}
if (p.ok) {
const p1d = fetchYahooPrice1D(m.sym);
rows.push([m.sym, m.name, m.category, p.close, p1d, ret2D, p.ret5D, ret10D !== "" ? ret10D : (p.ok ? p.ret10D ?? "" : ""), p.ret20D, ma20, ma60, today, "OK"]);
} else {
rows.push([m.sym, m.name, m.category, "N/A", "N/A", "", "N/A", "", "N/A", ma20, ma60, today, "FAIL"]);
}
Utilities.sleep(300);
}
// ── MRS(시장위험점수) 자동 계산 후 summary 행 추가 ────────────────────────
const byName = {};
rows.forEach(r => { byName[r[1]] = r; }); // Name 기준 인덱싱
const vixClose = parseFloat(byName["VIX"]?.[3]);
const kospiClose= parseFloat(byName["KOSPI"]?.[3]);
const kospiMA20 = parseFloat(byName["KOSPI"]?.[9]);
const usdKrw = parseFloat(byName["USD_KRW"]?.[3]);
const usdJpyR2D = parseFloat(byName["USD_JPY"]?.[5]); // Ret2D
const hygRet5D = parseFloat(byName["HYG_HY_Bond"]?.[6]); // Ret5D
// credit_stress_status 산출 (HYG Ret5D 기반 proxy)
const creditStress = Number.isFinite(hygRet5D)
? (hygRet5D < -2 ? "stress" : hygRet5D < -1 ? "caution" : "none")
: "DATA_MISSING";
// MARKET_RISK_SCORE_V1
let mrs = 0;
mrs += Number.isFinite(vixClose) ? (vixClose < 18 ? 0 : vixClose <= 25 ? 2 : vixClose <= 35 ? 3 : 4) : 4;
mrs += Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) ? (kospiClose >= kospiMA20 ? 0 : 2) : 2;
mrs += Number.isFinite(usdKrw) ? (usdKrw < 1400 ? 0 : usdKrw <= 1450 ? 1 : 2) : 2;
mrs += Number.isFinite(usdJpyR2D) ? (usdJpyR2D > -1 ? 0 : 1) : 1;
mrs += creditStress === "none" ? 0 : 1;
// kosdaq_regime_supplement: KOSDAQ < MA20 이고 KOSPI >= MA20이면 MRS +1
const kosdaqClose = parseFloat(byName["KOSDAQ"]?.[3]);
const kosdaqMA20 = parseFloat(byName["KOSDAQ"]?.[9]);
const kosdaqSupp = Number.isFinite(kosdaqClose) && Number.isFinite(kosdaqMA20)
&& kosdaqClose < kosdaqMA20
&& Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose >= kospiMA20
? 1 : 0;
mrs = Math.min(10, mrs + kosdaqSupp);
// TARGET_CASH_PCT_V1
const targetCashPct = (5 + (mrs / 10) * 15).toFixed(1);
// ── sector_flow 읽기 → 완전 국면 판정용 데이터 수집 ─────────────────────
// runSectorFlow()가 sector_flow 기록 완료 후 runMacro()가 실행되므로 최신값 읽기 가능
let sfTop1Score = 0, sfTop2Sum = 0, sfTop1AlertScore = 0, sfTop1Sector = "";
let sfSmart20Sum = 0;
try {
const sfSheet = getSpreadsheet_().getSheetByName("sector_flow");
if (sfSheet) {
const sfData = sfSheet.getDataRange().getValues();
const sfHdr = sfData[1] ?? [];
const sfRankIdx = sfHdr.indexOf("Sector_Rank") >= 0 ? sfHdr.indexOf("Sector_Rank") : sfHdr.indexOf("Rotation_Rank");
const sfScoreIdx = sfHdr.indexOf("Sector_Score") >= 0 ? sfHdr.indexOf("Sector_Score") : sfHdr.indexOf("Rotation_Score");
const sfAlertIdx = sfHdr.indexOf("Alert_Level");
const sfSmart20Idx= sfHdr.indexOf("SmartMoney_20D_KRW") >= 0 ? sfHdr.indexOf("SmartMoney_20D_KRW") : sfHdr.indexOf("Frg_20D_SUM");
const sfSectorIdx = sfHdr.indexOf("Sector");
const sfEntries = [];
for (let i = 2; i < sfData.length; i++) {
const row = sfData[i];
const sec = String(row[sfSectorIdx] ?? "").trim();
if (!sec || sec === "Sector") continue;
const score = parseFloat(row[sfScoreIdx]);
const rank = parseInt(row[sfRankIdx]);
const als = String(row[sfAlertIdx] ?? "");
const aScore = als === "INFLOW_STRONG" ? 3 : als === "INFLOW_MODERATE" ? 2 : als === "NEUTRAL" ? 1 : 0;
const smart20 = parseFloat(row[sfSmart20Idx]);
sfEntries.push({ rank, score, alertScore: aScore, sec, smart20 });
if (Number.isFinite(smart20)) sfSmart20Sum += smart20;
}
sfEntries.sort((a, b) => a.rank - b.rank);
if (sfEntries.length >= 1) {
sfTop1Score = sfEntries[0].score ?? 0;
sfTop1AlertScore = sfEntries[0].alertScore ?? 0;
sfTop1Sector = sfEntries[0].sec;
}
if (sfEntries.length >= 2) {
sfTop2Sum = (sfEntries[0].score ?? 0) + (sfEntries[1].score ?? 0);
}
}
} catch(e) { handleFetchError_("runMacro:sector_flow regime read", e, "WARN"); }
// KOSPI MA60·Ret20D — byName column index (행 구조: [sym,name,cat,close,ret1d,ret2d,ret5d,ret10d,ret20d,ma20,ma60,...])
const kospiMA60 = parseFloat(byName["KOSPI"]?.[10]);
const kospiRet20D = parseFloat(byName["KOSPI"]?.[8]);
// ── MARKET_REGIME_V1 완전 판정 (spec/11_market_regime.yaml) ─────────────
const leaderSectorFlag_ = SECTOR_TIER_MAP[sfTop1Sector] === "Tier_1" ? 1 : 0;
const isRiskOff_ = mrs >= 7
|| (Number.isFinite(vixClose) && vixClose >= 25
&& Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose < kospiMA20);
const riskOnBase_ = !isRiskOff_
&& Number.isFinite(vixClose) && vixClose < 18
&& Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose > kospiMA20
&& ((Number.isFinite(kospiMA60) && kospiMA20 >= kospiMA60)
|| (Number.isFinite(kospiRet20D) && kospiRet20D > 0));
const riskOnFlow_ = sfSmart20Sum > 0 || sfTop2Sum >= 100;
const isLeader_ = !isRiskOff_
&& sfTop2Sum >= 100 && sfTop1Score >= 55 && sfTop1AlertScore >= 2 && leaderSectorFlag_ === 1
&& Number.isFinite(kospiRet20D) && kospiRet20D > 0
&& Number.isFinite(vixClose) && vixClose < 25;
const isSecularLeader_ = isLeader_
&& sfTop1Sector === "반도체"
&& Number.isFinite(vixClose) && vixClose < 22
&& Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose > kospiMA20;
let marketRegime;
if (isRiskOff_) marketRegime = "RISK_OFF";
else if (isSecularLeader_) marketRegime = "SECULAR_LEADER_RISK_ON";
else if (isLeader_) marketRegime = "LEADER_CONCENTRATION";
else if (riskOnBase_ && riskOnFlow_) marketRegime = "RISK_ON";
else if (mrs <= 5) marketRegime = "NEUTRAL";
else marketRegime = "RISK_OFF_CANDIDATE";
const mrsDetail = `score=${mrs}/10 cash=${targetCashPct}% regime=${marketRegime}` +
`${kosdaqSupp ? " [KOSDAQ+1]" : ""} top1=${sfTop1Sector}(${sfTop1Score.toFixed(0)}) top2sum=${sfTop2Sum.toFixed(0)}`;
// ── Bayesian multiplier ────────────────────────────────────────────────────
const bayesianInfo = readPerformanceSheet_();
const bayesianDetail = `${bayesianInfo.bayesian_label} (${bayesianInfo.bayesian_multiplier}×)` +
(bayesianInfo.win_rate_30 != null ? ` wr=${(bayesianInfo.win_rate_30*100).toFixed(0)}%` : "") +
(bayesianInfo.net_expectancy_30 != null ? ` ne=${bayesianInfo.net_expectancy_30.toFixed(1)}%` : "") +
` trades=${bayesianInfo.trades_used}`;
// ── net_return_feedback 상태 (RISK_BUDGET_CASCADE_V1 입력) ────────────────
// spec/05_position_sizing.yaml:net_return_feedback
const neTrades_ = bayesianInfo.trades_used;
const ne30_ = bayesianInfo.net_expectancy_30; // %, e.g. 3.2 = 3.2% avg expectancy
const consLoss_ = bayesianInfo.consecutive_losses;
let netRF = "NORMAL", netRFDetail = "";
if (neTrades_ < 20) {
netRFDetail = `trades<20(${neTrades_}건) — 규칙 미적용`;
} else if (Number.isFinite(ne30_) && ne30_ <= -2) {
netRF = "REDUCED";
netRFDetail = `ne=${ne30_.toFixed(1)}% — base_risk 0.007→0.003 삭감 권고`;
} else if (Number.isFinite(ne30_) && ne30_ <= 0) {
netRF = "CAUTION";
netRFDetail = `ne=${ne30_.toFixed(1)}% — high_confidence 금지, multiplier 0.5× 강제`;
} else {
netRFDetail = `ne=${Number.isFinite(ne30_) ? ne30_.toFixed(1) : "N/A"}% — 정상`;
}
if (consLoss_ >= 5 && netRF === "NORMAL") {
netRF = "CAUTION";
netRFDetail = `연속손실 ${consLoss_}건 — high_confidence 금지`;
}
// ── TOTAL_HEAT_V1 계산 — account_snapshot 기반 ──────────────────────────
const macroSettings = readSettingsTab_();
const totalAssetKrw = Number.isFinite(parseFloat(macroSettings["total_asset_krw"]))
? parseFloat(macroSettings["total_asset_krw"]) : null;
const heatInfo = readAccountSnapshotHeat_(totalAssetKrw);
// ── FC(탐색) 손실 예산 월별 집계 ────────────────────────────────────────
const fcBudgetPct = Number.isFinite(parseFloat(macroSettings["fc_budget_pct_override"]))
? parseFloat(macroSettings["fc_budget_pct_override"]) : null;
const fcInfo = calcFcBudget_(totalAssetKrw, fcBudgetPct);
// ── orbit_gap 계산 (spec/01_objective_profile.yaml:orbit_monthly_tracker) ──
const orbitInfo = calcOrbitGap_(macroSettings);
// summary 행 8개 (MRS / REGIME / BAYESIAN / TOTAL_HEAT / FC_BUDGET / NET_RETURN_FEEDBACK / ORBIT_GAP / ORBIT_STATE)
rows.push(["MRS_COMPUTED", "Market_Risk_Score", "Computed", mrs, "", "", "", "", "", "", "", today, mrsDetail]);
rows.push(["REGIME_PRELIM", "Market_Regime_Prelim", "Computed", marketRegime, "", "", "", "", "", "", "", today, `credit_stress=${creditStress} smart20=${sfSmart20Sum.toFixed(0)}`]);
rows.push(["BAYESIAN_COMPUTED", "Bayesian_Multiplier", "Computed", bayesianInfo.bayesian_multiplier, "", "", "", "", "", "", "", today, bayesianDetail]);
rows.push(["TOTAL_HEAT", "Total_Heat_Pct", "Computed", heatInfo.total_heat_pct ?? "N/A", "", "", "", "", "", "", "", today,
`${heatInfo.hf005_status} account_snapshot=${heatInfo.positions_count}` +
(heatInfo.total_heat_krw != null ? ` heat_krw=${Math.round(heatInfo.total_heat_krw).toLocaleString()}` : "")]);
rows.push(["FC_BUDGET", "FC_Loss_Budget_Monthly", "Computed", fcInfo.fc_used_pct ?? "N/A", "", "", "", "", "", "", "", today, `${fcInfo.fc_status} trades=${fcInfo.trades}`]);
rows.push(["NET_RETURN_FEEDBACK", "Net_Return_Feedback", "Computed", netRF, "", "", "", "", "", "", "", today, netRFDetail]);
rows.push(["ORBIT_GAP", "Orbit_Gap_Pct", "Computed", orbitInfo.ok ? orbitInfo.orbit_gap_pct : "N/A", "", "", "", "", "", "", "", today, orbitInfo.detail]);
rows.push(["ORBIT_STATE", "Orbit_State", "Computed", orbitInfo.ok ? orbitInfo.orbit_state : "N/A", "", "", "", "", "", "", "", today,
orbitInfo.ok ? `slot_adj=${orbitInfo.offensive_slot_adj} cash_adj=${orbitInfo.cash_floor_adj} (${orbitInfo.elapsed_months}/${orbitInfo.total_months}개월)` : orbitInfo.detail]);
const bucketInfo = calcBucketStatus_();
rows.push(["BUCKET_STATUS", "Bucket_Allocation_Status","Computed",
bucketInfo ? bucketInfo.overall : "N/A", "", "", "", "", "", "", "", today,
bucketInfo ? bucketInfo.detail : "data_feed 미실행 OR account_snapshot 없음"]);
writeToSheet("macro", headers, rows);
Logger.log(`macro 완료: ${rows.length - 9}종목 + MRS/REGIME/BAYESIAN/TOTAL_HEAT/FC_BUDGET/NET_RETURN_FEEDBACK/ORBIT_GAP/ORBIT_STATE/BUCKET_STATUS`);
// orbit_gap 월별 이력 탭 갱신 (이미 계산된 macroSettings/orbitInfo 재사용)
runOrbitGap(macroSettings, orbitInfo);
// 개별 실행에서는 기존 연쇄를 유지하고, run_all() 모드에서는 상위 오케스트레이터가 다음 단계를 수행한다.
if (!isRunAllOrchestrated_()) {
runEventRisk();
}
}
// ── 이벤트 리스크 ─────────────────────────────────────────────────────────────
// event_calendar 탭을 source of truth로 읽어 event_risk 탭을 생성한다.
// 날짜는 GAS 코드에 hardcode하지 않는다 — 운영자가 event_calendar 탭을 직접 관리.
// 최초 실행 또는 탭이 비어 있으면 seedEventCalendar_()가 초기값을 채운다.
// 탭 업데이트: GAS 편집기 → seedEventCalendar_ 또는 직접 시트 편집.
// seed: FOMC / US_CPI / EARNINGS / EXPIRY / IPO 기준값 (빈 탭에만 기록)
function seedEventCalendar_() {
const ss = getSpreadsheet_();
let sheet = ss.getSheetByName("event_calendar");
if (!sheet) sheet = ss.insertSheet("event_calendar");
const SEED_HEADERS = ["Date", "Event", "Type", "Impact", "Alert"];
const SEED_ROWS = [
// FOMC — Federal Reserve 공식 일정 (연 8회). 업데이트: https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm
["2026-06-11", "FOMC 금리결정", "FOMC", "HIGH", "금리동결 시 KOSPI +1~2% 기대, 인상 시 원화 약세 압력"],
["2026-07-28", "FOMC 금리결정", "FOMC", "HIGH", ""],
["2026-09-16", "FOMC 금리결정", "FOMC", "HIGH", ""],
// US CPI — BLS 발표일 (매월 1회). 업데이트: https://www.bls.gov/schedule/news_release/cpi.htm
["2026-06-11", "미국 CPI 발표 (5월)", "US_CPI", "HIGH", "예상치 상회 시 금리인상 우려 → 원화 약세·KOSPI 하방 압력. 당일 신규매수 자제"],
["2026-07-15", "미국 CPI 발표 (6월)", "US_CPI", "HIGH", "FOMC 전 마지막 CPI — 금리 경로 재평가 촉매"],
["2026-08-12", "미국 CPI 발표 (7월)", "US_CPI", "HIGH", ""],
// EARNINGS
["2026-06-20", "삼성전자 1Q 잠정실적", "EARNINGS", "HIGH", "반도체 섹터 선행 지표"],
// EXPIRY
["2026-06-15", "옵션만기일", "EXPIRY", "MEDIUM", "변동성 확대 구간 주의"],
["2026-07-15", "선물·옵션 동시만기", "EXPIRY", "HIGH", "트리플위칭 — 포지션 줄이기"],
// IPO — 대형 IPO 확정 시 직접 추가. Type=IPO, Impact=HIGH
// 예: ["2026-MM-DD", "XXX 상장", "IPO", "HIGH", "공모자금 수급 쏠림 → 보유 소형주 매도 압력"]
];
const existingData = sheet.getDataRange().getValues();
// 헤더만 있거나 완전히 비어 있으면 seed 기록
const dataRowCount = existingData.filter((r, i) => i > 0 && r[0] && String(r[0]).trim()).length;
if (dataRowCount === 0) {
sheet.clearContents();
sheet.appendRow(SEED_HEADERS);
SEED_ROWS.forEach(r => sheet.appendRow(r));
Logger.log(`event_calendar seed 완료: ${SEED_ROWS.length}건`);
} else {
Logger.log(`event_calendar seed skip: 기존 데이터 ${dataRowCount}건 보존`);
}
}
// event_calendar 탭을 읽어 DaysLeft 계산 후 event_risk 탭에 기록
function runEventRisk() {
const ss = getSpreadsheet_();
let calSheet = ss.getSheetByName("event_calendar");
// 탭이 없거나 비어 있으면 seed 실행
if (!calSheet || calSheet.getLastRow() < 2) {
seedEventCalendar_();
calSheet = ss.getSheetByName("event_calendar");
}
const calData = calSheet.getDataRange().getValues();
if (!calData || calData.length < 2) {
Logger.log("event_calendar 데이터 없음 — event_risk 업데이트 skip");
return;
}
// 헤더 인덱스 매핑 (대소문자 무관)
const calHeaders = calData[0].map(h => String(h).trim().toLowerCase());
const idxDate = calHeaders.indexOf("date");
const idxEvent = calHeaders.indexOf("event");
const idxType = calHeaders.indexOf("type");
const idxImpact = calHeaders.indexOf("impact");
const idxAlert = calHeaders.indexOf("alert");
if (idxDate < 0 || idxEvent < 0) {
Logger.log("event_calendar 헤더 누락 (Date/Event 필수) — seed 재실행 필요");
return;
}
const todayStr = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
const todayParts = todayStr.split("-").map(Number);
const todayMs = Date.UTC(todayParts[0], todayParts[1]-1, todayParts[2]);
const outHeaders = ["Date","DaysLeft","Event","Type","Impact","Alert","AsOfDate"];
const rows = [];
for (let i = 1; i < calData.length; i++) {
const row = calData[i];
const rawDate = row[idxDate];
if (!rawDate || String(rawDate).trim() === "") continue;
// Date 셀이 Date 객체이거나 "YYYY-MM-DD" 문자열 모두 지원
let dateStr;
if (rawDate instanceof Date) {
dateStr = Utilities.formatDate(rawDate, "Asia/Seoul", "yyyy-MM-dd");
} else {
dateStr = String(rawDate).trim();
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue;
const ep = dateStr.split("-").map(Number);
const eventMs = Date.UTC(ep[0], ep[1]-1, ep[2]);
const daysLeft = Math.round((eventMs - todayMs) / (1000*60*60*24));
if (daysLeft < -3) continue; // 3일 이전 경과 이벤트 제외
rows.push([
dateStr,
daysLeft,
idxEvent >= 0 ? row[idxEvent] : "",
idxType >= 0 ? row[idxType] : "",
idxImpact >= 0 ? row[idxImpact] : "",
idxAlert >= 0 ? row[idxAlert] : "",
todayStr
]);
}
rows.sort((a, b) => a[1] - b[1]);
writeToSheet("event_risk", outHeaders, rows);
Logger.log(`event_risk 완료: ${rows.length}건 (event_calendar 탭에서 읽음)`);
// 매달 1일 실행 시 월별 자산 스냅샷 기록 (asset_history 탭)
const dayOfMonth = parseInt(Utilities.formatDate(new Date(), "Asia/Seoul", "d"), 10);
if (dayOfMonth === 1) runMonthlySnapshot();
// 하위 단계 연쇄는 개별 실행에서만 수행한다. run_all()에서는 최종 오케스트레이터가 한 번만 처리한다.
if (!isRunAllOrchestrated_()) {
runHarnessRefresh_();
cacheAllViews();
}
}
function runHarnessRefresh_() {
if (typeof buildHarnessContext_ !== "function") {
Logger.log("[HARNESS] buildHarnessContext_ missing - integrated code 손상 여부 확인 필요");
return;
}
try {
buildHarnessContext_();
Logger.log("[HARNESS] buildHarnessContext_ completed");
} catch (e) {
var msg = (e && e.message) ? e.message : String(e);
var stack = (e && e.stack) ? String(e.stack) : 'NO_STACK';
Logger.log("[HARNESS][ERROR] runHarnessRefresh_ message=" + msg);
Logger.log("[HARNESS][ERROR] runHarnessRefresh_ stack=" + stack);
handleFetchError_("runHarnessRefresh_", e, "CRITICAL");
}
}
// ── All-in-one orchestration ────────────────────────────────────────────────
// 원하는 최종 결과를 한 번에 갱신하는 진입점.
// 순서:
// 1) data_feed
// 2) sector_flow -> macro
// 3) core_satellite
// 4) event_risk
// 5) harness 재생성
// 6) cache 재생성
var __RUN_ALL_ORCHESTRATED__ = false;
function isRunAllOrchestrated_() {
return __RUN_ALL_ORCHESTRATED__ === true;
}
function setRunAllOrchestrated_(value) {
__RUN_ALL_ORCHESTRATED__ = value === true;
}
function clearRunAllState_() {
const props = PropertiesService.getScriptProperties();
props.deleteProperty("run_all_step");
props.deleteProperty("run_all_start_time");
if (typeof clearFetchCache === "function") {
try {
clearFetchCache();
} catch (e) {
Logger.log("[RUN_ALL] clearFetchCache failed: " + e.message);
}
}
}
function run_all() {
const props = PropertiesService.getScriptProperties();
const runAllInvocationMode = String(props.getProperty("run_all_invocation_mode") || "external_scheduler");
const invocationStartTime = new Date().getTime();
clearRunAllState_();
if (typeof beginFetchSession_ === "function") {
try {
beginFetchSession_("run_all");
} catch (e) {
Logger.log("[RUN_ALL] Failed to auto begin fetch session: " + e.message);
}
}
Logger.log("[RUN_ALL] invocation_mode=" + runAllInvocationMode);
const steps = [
{
name: "runDaily (Calendar Scraping)",
fn: function() {
if (typeof runDaily === "function") {
try {
runDaily();
} catch(e) {
Logger.log("[WARN] runDaily 실행 중 일부 단계 실패 (단, 스크래핑 및 정렬은 시도됨): " + e.message);
}
} else {
Logger.log("[WARN] runDaily 함수가 정의되어 있지 않아 캘린더 스크래핑을 건너뜁니다.");
}
}
},
{ name: "runSectorFlow", fn: runSectorFlow },
{ name: "runDataFeed", fn: runDataFeed },
{ name: "runCoreSatelliteFlow_", fn: runCoreSatelliteFlow_ },
{ name: "runEventRisk", fn: runEventRisk },
{ name: "runHarnessRefresh_", fn: runHarnessRefresh_ },
{
name: "runRebalanceSheet_",
fn: function() {
if (typeof runRebalanceSheet_ === "function") {
runRebalanceSheet_();
} else {
Logger.log("[WARN] runRebalanceSheet_ 함수가 정의되어 있지 않아 건너뜁니다. gdf_06_rebalance.gs 배포 여부 확인.");
}
}
},
];
Logger.log("[RUN_ALL] start");
setRunAllOrchestrated_(true);
try {
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const elapsedBefore = (new Date().getTime() - invocationStartTime) / 1000;
if (elapsedBefore > 240) {
Logger.log("[RUN_ALL] 단계 [" + step.name + "] 시작 전 실행 한도 도달 직전 종료 (경과: " + elapsedBefore.toFixed(1) + "초).");
return;
}
try {
Logger.log("[RUN_ALL] step=" + step.name + " start");
step.fn();
Logger.log("[RUN_ALL] step=" + step.name + " done");
} catch (e) {
if (e.message === "PARTIAL_SAVE_REQUESTED") {
Logger.log("[RUN_ALL] step=" + step.name + " partial save 요청 수신.");
return;
}
Logger.log("[RUN_ALL][ERROR] step=" + step.name + " message=" + ((e && e.message) ? e.message : String(e)));
handleFetchError_("run_all:" + step.name, e, "CRITICAL");
throw e;
}
}
scheduleCacheAllViews_();
// 완료 시 Properties 정리 및 예약 트리거 청소
props.deleteProperty("run_all_invocation_mode");
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === "run_all")
.forEach(t => ScriptApp.deleteTrigger(t));
} finally {
setRunAllOrchestrated_(false);
}
Logger.log("[RUN_ALL] done");
}
function scheduleCacheAllViews_() {
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === "cacheAllViews")
.forEach(t => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger("cacheAllViews").timeBased().after(60 * 1000).create();
Logger.log("[RUN_ALL] step=cacheAllViews scheduled (1min trigger)");
}
function runCoreSatelliteFlow_() {
const props = PropertiesService.getScriptProperties();
const universe = getCoreSatelliteUniverse();
const totalChunks = Math.max(1, Math.ceil(universe.length / CHUNK_SIZE));
const startTime = new Date().getTime();
for (let i = 0; i < totalChunks; i++) {
let chunkIdx = parseInt(props.getProperty("cs_chunk_idx") ?? "0", 10);
if (chunkIdx >= totalChunks) {
break;
}
const elapsed = (new Date().getTime() - startTime) / 1000;
if (elapsed > 120) {
Logger.log("[RUN_ALL] core_satellite 청크 " + chunkIdx + " 실행 전 한도 도달 직전 종료 (경과: " + elapsed.toFixed(1) + "초).");
throw new Error("PARTIAL_SAVE_REQUESTED");
}
runCoreSatelliteBatch();
const statusRaw = props.getProperty("cs_status") || "{}";
let status = {};
try {
status = JSON.parse(statusRaw);
} catch (e) {
status = {};
}
const state = String(status.status || "").toUpperCase();
if (state === "COMPLETE" || state === "FINALIZED") {
break;
}
}
}
// ── JSON 캐시 업데이트 ────────────────────────────────────────────────────────
// 매일 runEventRisk() 완료 후 호출. doGet()이 Sheets를 다시 읽지 않고
// CacheService 캐시만 반환하므로 응답 시간이 2~8s → <300ms로 단축됨.
function cacheAllViews() {
// one-shot 트리거로 실행된 경우 자신을 삭제 (누적 방지)
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === "cacheAllViews")
.forEach(t => ScriptApp.deleteTrigger(t));
const cache = CacheService.getScriptCache();
const generatedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST";
const TTL = 3600; // 1시간
const MAX_CACHE_BYTES = 95 * 1024; // CacheService 실효 한계(100KB) 대비 여유
const sellPriorityView = runSellPriority();
const views = {
health: getHealthJson_(),
meta: getWorkbookMetaJson_(),
data_feed: getDataFeedJson(),
// backdata_feature_bank는 누적 운영으로 대용량이므로 캐시 제외 (요청 시 doGet에서 실시간 조회)
backdata_feature_bank_compact: getBackdataFeatureBankJsonCompact(),
portfolio: getPortfolioJson(),
sectors: getSectorFlowJson(),
macro: getMacroJson(),
events: getEventRiskJson(),
orbit_gap: getOrbitGapJson(),
asset_history: getAssetHistoryJson(),
brief: getDailyBrief(sellPriorityView),
sell_priority: sellPriorityView,
};
// summary는 위 뷰들을 조합 — 개별 결과 재활용
const port = views.portfolio;
const sectors = views.sectors;
const macro = views.macro;
const events = views.events;
const orbit = views.orbit_gap;
const holdings = port.holdings;
const totalFrg5 = holdings.reduce((s,h) => s + (parseFloat(h.Frg_5D) || 0), 0);
const totalInst5 = holdings.reduce((s,h) => s + (parseFloat(h.Inst_5D) || 0), 0);
const flowOkCount = holdings.filter(h => h.Flow_OK === "Y").length;
const ss001Dist = { A: 0, B: 0, C: 0, D: 0 };
const actionDist = {};
holdings.forEach(h => {
const g = h["SS001_Grade"]; if (g in ss001Dist) ss001Dist[g]++;
const a = h["Allowed_Action"] || "UNKNOWN"; actionDist[a] = (actionDist[a] ?? 0) + 1;
});
views.summary = {
portfolio_flow_summary: {
total_holdings: holdings.length,
data_ok_count: flowOkCount,
portfolio_frg_5d_total: roundNum(totalFrg5, 0),
portfolio_inst_5d_total: roundNum(totalInst5, 0),
portfolio_indiv_5d_total: roundNum(-(totalFrg5 + totalInst5), 0),
},
ss001_grade_distribution: ss001Dist,
action_distribution: actionDist,
sector_summary: {
total_sectors: sectors.count,
top_inflow_sectors: sectors.top_inflow,
outflow_warning_sectors: sectors.outflow_warning,
strong_smart_money_sectors:sectors.strong_smart_money,
},
macro_snapshot: {
vix: macro.vix,
usd_krw: macro.usd_krw,
kospi: macro.kospi,
sp500_5d_ret: macro.sp500_ret5d,
market_regime: macro.market_regime,
mrs_score: macro.mrs_score,
bayesian_multiplier:macro.bayesian_multiplier,
total_heat_pct: macro.total_heat_pct,
fc_budget_pct: macro.fc_budget_pct,
net_return_feedback:macro.net_return_feedback,
orbit_gap_pct: macro.orbit_gap_pct,
orbit_state: macro.orbit_state,
orbit_slot_adj: macro.orbit_slot_adj,
},
event_alerts: events.upcoming_7d,
holdings_detail: holdings,
sector_detail: sectors.sectors,
macro_computed: macro.computed_summary,
orbit_current: orbit.current,
};
// 각 뷰를 CacheService에 저장 (최대 100KB/키)
for (const [view, payload] of Object.entries(views)) {
payload.view = view;
payload.generated_at = generatedAt;
try {
const serialized = JSON.stringify(payload, null, 2);
if (serialized.length > MAX_CACHE_BYTES) {
Logger.log(`캐시 스킵 (${view}): payload too large ${serialized.length} bytes`);
continue;
}
cache.put(`view_${view}`, serialized, TTL);
} catch(e) {
Logger.log(`캐시 저장 실패 (${view}): ${e.message}`);
}
}
Logger.log(`cacheAllViews 완료 (TTL: ${TTL}s)`);
}
// ────────────────────────────────────────────────────────────────────────────
// Phase 3: Web App API (doGet) — Custom GPT Action 엔드포인트
//
// 배포: script.google.com → 배포 → 웹 앱 → 실행 권한: "모든 사용자"
// URL: https://script.google.com/macros/s/{DEPLOYMENT_ID}/exec
//
// Custom GPT에서 ?view=summary 로 호출 → 포트폴리오 분석 JSON 반환
// ────────────────────────────────────────────────────────────────────────────
const VIEW_GID_MAP = {
"1835496032": "macro",
"361215520": "events",
"857909836": "sectors",
"1266919040": "data_feed",
"1490216937": "core_satellite",
};
function doGet(e) {
const rawView = String(e?.parameter?.view ?? "").trim().toLowerCase();
const rawGid = String(e?.parameter?.gid ?? "").trim();
const compactFlag_ = parseCompactFlag_(e?.parameter?.compact);
const view = rawView || VIEW_GID_MAP[rawGid] || "summary";
// ① 캐시 우선 반환 — 매일 runEventRisk() 완료 시 cacheAllViews()가 채워 둠
// 캐시 HIT: <300ms, 캐시 MISS(만료·첫 호출): Sheets 직접 읽기(2~5s)
const cache = CacheService.getScriptCache();
const cached = cache.get(`view_${view}`);
if (cached) {
return ContentService
.createTextOutput(cached)
.setMimeType(ContentService.MimeType.JSON);
}
// ② 캐시 MISS → Sheets에서 직접 읽어 반환 (기존 동작 유지)
let payload;
try {
switch(view) {
case "health": payload = getHealthJson_(); break;
case "meta": payload = getWorkbookMetaJson_(); break;
case "all": payload = getAllJson_(compactFlag_); break;
case "raw_all": payload = getRawAllJson_(compactFlag_); break;
case "data_feed": payload = getDataFeedJson(); break;
case "backdata_feature_bank": payload = compactFlag_ ? getBackdataFeatureBankJsonCompact() : getBackdataFeatureBankJson(); break;
case "backdata_feature_bank_compact": payload = getBackdataFeatureBankJsonCompact(); break;
case "sectors": payload = getSectorFlowJson(); break;
case "portfolio": payload = getPortfolioJson(); break;
case "core_satellite": payload = getCoreSatelliteJson(compactFlag_); break;
case "macro": payload = getMacroJson(); break;
case "events": payload = getEventRiskJson(); break;
case "orbit_gap": payload = getOrbitGapJson(); break;
case "brief": payload = getDailyBrief(null); break;
case "sell_priority": payload = runSellPriority(); break;
case "asset_history": payload = getAssetHistoryJson(); break;
case "source_health": payload = checkDataSourceHealth(); break;
case "trade_template":
payload = getTradeTemplate(String(e?.parameter?.ticker ?? "").trim()); break;
case "init_account_snapshot":
payload = initAccountSnapshotTemplate_(); break;
case "summary":
default: payload = getSummaryJson(); break;
}
payload.view = view;
payload.generated_at = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST";
} catch(err) {
payload = { error: err.message, view };
}
return ContentService
.createTextOutput(JSON.stringify(payload, null, 2))
.setMimeType(ContentService.MimeType.JSON);
}
// ── Sheets → JSON 변환 헬퍼 ───────────────────────────────────────────────
function parseCompactFlag_(value) {
const raw = String(value ?? "").trim().toLowerCase();
return raw === "1" || raw === "true" || raw === "yes" || raw === "y";
}
function getHealthJson_() {
return {
status: "OK",
mode: "health",
app: "gas_data_feed",
schema_version: SCHEMA_VERSION,
spreadsheet_id: SPREADSHEET_ID,
timezone: "Asia/Seoul",
available_views: ["health","summary","brief","data_feed","backdata_feature_bank","backdata_feature_bank_compact","core_satellite","sell_priority","macro","events","sectors","portfolio","orbit_gap","asset_history","trade_template","all","raw_all"],
transport_policy: {
canonical_transport: "HTTP GET",
canonical_client: "Invoke-WebRequest / curl / script fetch",
direct_open: "may be blocked by session policy",
},
};
}
function getWorkbookMetaJson_() {
const ss = getSpreadsheet_();
const sheets = ss.getSheets().map(sheet => {
const data = sheet.getDataRange().getValues();
const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim();
const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null;
const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : [];
const rowCount = data.length >= 3 ? data.slice(2).filter(r => r.some(c => c !== "")).length : 0;
return {
sheet: sheet.getName(),
gid: sheet.getSheetId(),
hidden: sheet.isSheetHidden(),
updated_at: updatedAt,
count: rowCount,
header_count: headers.length,
};
});
return {
mode: "meta",
schema_version: SCHEMA_VERSION,
sheet_count: sheets.length,
sheets,
};
}
function getSheetEnvelopeJson_(sheetName, gid, options) {
const compact = Boolean(options?.compact);
const maxRows = Number.isFinite(Number(options?.maxRows)) ? Math.max(0, Number(options.maxRows)) : null;
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName(sheetName);
if (!sheet) {
return {
sheet: sheetName,
gid: gid ?? null,
schema_version: SCHEMA_VERSION,
updated_at: null,
count: 0,
headers: [],
rows: [],
compact: false,
truncated: false,
};
}
const data = sheet.getDataRange().getValues();
const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim();
const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null;
const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : [];
const rowsFull = sheetToJson(sheetName);
const rows = compact && Number.isFinite(maxRows) ? rowsFull.slice(0, maxRows) : rowsFull;
return {
sheet: sheetName,
gid: gid ?? null,
schema_version: SCHEMA_VERSION,
updated_at: updatedAt,
count: rowsFull.length,
headers,
rows,
compact,
truncated: rows.length < rowsFull.length,
};
}
function sheetToJson(sheetName) {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName(sheetName);
if (!sheet) return [];
const data = sheet.getDataRange().getValues();
// row[0] = updated 메타, row[1] = 헤더, row[2..] = 데이터
if (data.length < 3) return [];
const headers = data[1].map(h => String(h).trim());
// 날짜 컬럼 식별 (AsOfDate, Updated_At, Date, Price_Date)
const dateCols = new Set(["AsOfDate","Updated_At","Date","Price_Date"]);
return data.slice(2).filter(r => r.some(c => c !== "")).map(r => {
const obj = {};
headers.forEach((h, i) => {
const v = r[i];
// Date 객체 → "yyyy-MM-dd" 문자열로 직렬화
if (v instanceof Date && !isNaN(v)) {
obj[h] = Utilities.formatDate(v, "Asia/Seoul", "yyyy-MM-dd");
} else {
obj[h] = v;
}
});
return obj;
});
}
function getSectorFlowJson() {
const sectors = sheetToJson("sector_flow");
return {
sectors,
top_inflow: sectors.filter(s => s.Alert_Level === "INFLOW_STRONG").map(s => s.Sector),
outflow_warning: sectors.filter(s => ["OUTFLOW_ALERT","OUTFLOW_CAUTION"].includes(s.Alert_Level)).map(s => s.Sector),
strong_smart_money: sectors.filter(s => s.Smart_Money === "STRONG").map(s => s.Sector),
count: sectors.length
};
}
function getPortfolioJson() {
const holdings = sheetToJson("data_feed");
return { holdings, count: holdings.length };
}
function getDataFeedJson() {
return getSheetEnvelopeJson_("data_feed", 1266919040, { compact: false });
}
function getBackdataFeatureBankJson() {
return getSheetEnvelopeJson_("backdata_feature_bank", null, { compact: false });
}
function getBackdataFeatureBankJsonCompact() {
return getSheetEnvelopeJson_("backdata_feature_bank", null, { compact: true, maxRows: 50 });
}
function getCoreSatelliteJson(compact) {
return getSheetEnvelopeJson_("core_satellite", 1490216937, {
compact: Boolean(compact),
maxRows: compact ? 20 : null,
});
}
function getAllJson_(compact) {
return {
data_feed: getDataFeedJson(),
backdata_feature_bank: getBackdataFeatureBankJson(),
core_satellite: getCoreSatelliteJson(compact),
sector_flow: getSectorFlowJson(),
macro: getMacroJson(),
event_risk: getEventRiskJson(),
summary: getSummaryJson(),
};
}
function getRawAllJson_(compact) {
const ss = getSpreadsheet_();
const sheets = ss.getSheets();
const maxRows = compact ? 20 : null;
const payloadSheets = sheets.map(sheet => {
const name = sheet.getName();
const gid = sheet.getSheetId();
const data = sheet.getDataRange().getValues();
const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim();
const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null;
const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : [];
const rowsFull = data.length >= 3 ? data.slice(2).filter(r => r.some(c => c !== "")).map(r => {
const obj = {};
headers.forEach((h, i) => {
const v = r[i];
if (v instanceof Date && !isNaN(v)) {
obj[h] = Utilities.formatDate(v, "Asia/Seoul", "yyyy-MM-dd");
} else {
obj[h] = v;
}
});
return obj;
}) : [];
const rows = compact && Number.isFinite(maxRows) ? rowsFull.slice(0, maxRows) : rowsFull;
return {
sheet: name,
gid,
sheet_id: gid,
hidden: sheet.isSheetHidden(),
updated_at: updatedAt,
count: rowsFull.length,
headers,
rows,
compact: Boolean(compact),
truncated: rows.length < rowsFull.length,
};
});
return {
mode: "raw_all",
schema_version: SCHEMA_VERSION,
sheet_count: payloadSheets.length,
compact: Boolean(compact),
sheets: payloadSheets,
};
}
// 숫자 배열의 중앙값 (양수만, 빈 배열이면 null)
function calcMedian_(arr) {
const nums = arr.filter(v => Number.isFinite(v) && v > 0);
if (!nums.length) return null;
nums.sort((a, b) => a - b);
const mid = Math.floor(nums.length / 2);
return nums.length % 2 === 0 ? (nums[mid - 1] + nums[mid]) / 2 : nums[mid];
}
// float32 → float64 노이즈 제거: 숫자 값을 소수점 4자리로 정리
function roundNum(v, digits) {
if (typeof v !== "number" || isNaN(v)) return v;
return parseFloat(v.toFixed(digits ?? 4));
}
function getMacroJson() {
const macro = sheetToJson("macro").map(m => ({
...m,
Close: roundNum(m.Close, 4),
Ret1D: roundNum(m.Ret1D, 2),
Ret5D: roundNum(m.Ret5D, 2),
Ret20D: roundNum(m.Ret20D, 2),
}));
const byName = {};
macro.forEach(m => { byName[m.Name] = m; });
// MRS 요약 추출
const mrsRow = byName["Market_Risk_Score"] ?? {};
const regimeRow = byName["Market_Regime_Prelim"] ?? {};
const bayesRow = byName["Bayesian_Multiplier"] ?? {};
const heatRow = byName["Total_Heat_Pct"] ?? {};
const fcRow = byName["FC_Loss_Budget_Monthly"] ?? {};
const netRFRow = byName["Net_Return_Feedback"] ?? {};
const orbitGapRow = byName["Orbit_Gap_Pct"] ?? {};
const orbitStRow = byName["Orbit_State"] ?? {};
const bucketRow = byName["Bucket_Allocation_Status"] ?? {};
return {
indicators: macro.filter(m => m.Category !== "Computed"),
computed_summary: macro.filter(m => m.Category === "Computed"),
vix: roundNum(byName["VIX"]?.Close, 2) ?? "N/A",
usd_krw: roundNum(byName["USD_KRW"]?.Close, 2) ?? "N/A",
kospi: roundNum(byName["KOSPI"]?.Close, 2) ?? "N/A",
kospi_ma20: roundNum(byName["KOSPI"]?.MA20, 2) ?? "N/A",
kospi_ma60: roundNum(byName["KOSPI"]?.MA60, 2) ?? "N/A",
usd_jpy_ret2d: roundNum(byName["USD_JPY"]?.Ret2D, 2) ?? "N/A",
hyg_ret5d: roundNum(byName["HYG_HY_Bond"]?.Ret5D, 2) ?? "N/A",
sp500_ret5d: roundNum(byName["SP500"]?.Ret5D, 2) ?? "N/A",
mrs_score: mrsRow.Close ?? "N/A",
mrs_status: mrsRow.Status ?? "N/A",
market_regime: regimeRow.Close ?? "N/A",
credit_stress: String(regimeRow.Status ?? "").replace("credit_stress=", "") || "N/A",
bayesian_multiplier: bayesRow.Close ?? "N/A",
bayesian_label: bayesRow.Status ?? "N/A",
// trades=0 이면 performance 탭 데이터 없는 기본값; 1건 이상이면 실제 거래 기반
bayesian_data_source: (String(bayesRow.Status ?? "").match(/trades=(\d+)/)?.[1] ?? "0") !== "0" ? "actual" : "default",
total_heat_pct: heatRow.Close ?? "N/A",
total_heat_gate: heatRow.Status ?? "N/A",
fc_budget_pct: fcRow.Close ?? "N/A",
fc_budget_status: fcRow.Status ?? "N/A",
net_return_feedback: netRFRow.Close ?? "N/A",
net_return_detail: netRFRow.Status ?? "N/A",
orbit_gap_pct: orbitGapRow.Close ?? "N/A",
orbit_gap_detail: orbitGapRow.Status ?? "N/A",
orbit_state: orbitStRow.Close ?? "N/A",
orbit_slot_adj: String(orbitStRow.Status ?? "").match(/slot_adj=(-?\d+)/)?.[1] ?? "N/A",
orbit_cash_adj: String(orbitStRow.Status ?? "").match(/cash_adj=(-?\d+)/)?.[1] ?? "N/A",
bucket_status: bucketRow.Close ?? "N/A",
bucket_detail: bucketRow.Status ?? "N/A",
};
}
function getEventRiskJson() {
const events = sheetToJson("event_risk");
const urgent = events.filter(e => +e.DaysLeft >= 0 && +e.DaysLeft <= 7);
return { events, upcoming_7d: urgent };
}
function getOrbitGapJson() {
const history = sheetToJson("monthly_history");
if (!history.length) return { history: [], current: null };
const latest = history[history.length - 1];
return {
history,
current: {
month: latest.Month,
orbit_gap_pct: latest.Orbit_Gap_Pct,
orbit_state: latest.Orbit_State,
offensive_slot_adj: latest.Slot_Adj,
cash_floor_adj: latest.Cash_Floor_Adj,
target_return_pct: latest.Target_Return_Pct,
actual_return_pct: latest.Actual_Return_Pct,
},
};
}
// ── [2026-05-21_AFL_V1] ALPHA_FEEDBACK_LOOP_V1 -- monthly grade analysis ────────
function runAlphaFeedbackLoop_() {
var ss = getSpreadsheet_();
var sheet = ss.getSheetByName("alpha_history");
var today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
var monthKey = today.substring(0, 7);
var defaultPayload = {
formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
as_of: today,
analysis_period: monthKey,
status: 'DATA_MISSING',
cases_analyzed: 0,
grade_count: 0,
eligible_t20_fail_rate: null,
eligible_t60_fail_rate: null,
recommended_filter_adjustments: [],
grade_summary: []
};
if (!sheet) {
writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(defaultPayload));
Logger.log("[AFL] alpha_history sheet not found");
return defaultPayload;
}
var data = sheet.getDataRange().getValues();
if (data.length < 2) {
writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(defaultPayload));
Logger.log("[AFL] alpha_history has no data");
return defaultPayload;
}
var hdrRow = data[0];
var hdrMap = {};
hdrRow.forEach(function(h, i) { hdrMap[h] = i; });
var gradeStats = {};
var analyzedCases = 0;
for (var i = 1; i < data.length; i++) {
var row = data[i];
var grade = String(row[hdrMap['SAQG_Grade_At_Entry']] || '').trim();
var t20g = String(row[hdrMap['T20_Alpha_Gate']] || '').trim();
var t60g = String(row[hdrMap['T60_Alpha_Gate']] || '').trim();
if (!grade) continue;
if (!gradeStats[grade]) gradeStats[grade] = { t20_total: 0, t20_pass: 0, t60_total: 0, t60_pass: 0 };
var s = gradeStats[grade];
var skipVals = { 'NOT_YET': 1, 'EXEMPT': 1, 'DATA_MISSING': 1, '': 1 };
var hasT20 = t20g && !skipVals[t20g];
var hasT60 = t60g && !skipVals[t60g];
if (hasT20) { s.t20_total++; if (t20g === 'T20_ALPHA_PASS') s.t20_pass++; }
if (hasT60) { s.t60_total++; if (t60g === 'T60_ALPHA_PASS') s.t60_pass++; }
if (hasT20 || hasT60) analyzedCases++;
}
var gradeSummary = [];
Object.keys(gradeStats).sort().forEach(function(grade) {
var s = gradeStats[grade];
var t20FailRate = s.t20_total > 0 ? parseFloat((((s.t20_total - s.t20_pass) / s.t20_total) * 100).toFixed(2)) : null;
var t60FailRate = s.t60_total > 0 ? parseFloat((((s.t60_total - s.t60_pass) / s.t60_total) * 100).toFixed(2)) : null;
var t20PassRate = s.t20_total > 0 ? parseFloat(((s.t20_pass / s.t20_total) * 100).toFixed(2)) : null;
var t60PassRate = s.t60_total > 0 ? parseFloat(((s.t60_pass / s.t60_total) * 100).toFixed(2)) : null;
gradeSummary.push({
grade: grade,
t20_total: s.t20_total,
t20_pass: s.t20_pass,
t20_pass_rate: t20PassRate,
t20_fail_rate: t20FailRate,
t60_total: s.t60_total,
t60_pass: s.t60_pass,
t60_pass_rate: t60PassRate,
t60_fail_rate: t60FailRate,
status: (s.t20_total >= 10 || s.t60_total >= 10) ? 'ANALYZED' : 'DATA_INSUFFICIENT'
});
});
var eligibleRow = gradeStats['ELIGIBLE'] || { t20_total: 0, t20_pass: 0, t60_total: 0, t60_pass: 0 };
var eligibleT20FailRate = eligibleRow.t20_total > 0
? parseFloat((((eligibleRow.t20_total - eligibleRow.t20_pass) / eligibleRow.t20_total) * 100).toFixed(2))
: null;
var eligibleT60FailRate = eligibleRow.t60_total > 0
? parseFloat((((eligibleRow.t60_total - eligibleRow.t60_pass) / eligibleRow.t60_total) * 100).toFixed(2))
: null;
var eligibleT20PassRate = eligibleRow.t20_total > 0
? parseFloat(((eligibleRow.t20_pass / eligibleRow.t20_total) * 100).toFixed(2))
: null;
var recommendations = [];
if (analyzedCases >= 10) {
if (eligibleT20FailRate !== null && eligibleT20FailRate > 50) {
recommendations.push({
filter_id: 'SAQG_F2_RECOVERY_RATIO',
current: '1.20',
recommended: '1.35',
rationale: 'ELIGIBLE T+20 fail rate > 50%',
action: 'TIGHTEN'
});
recommendations.push({
filter_id: 'SAQG_F3_EXCESS_DRAWDOWN',
current: '5%p',
recommended: '4%p',
rationale: 'ELIGIBLE T+20 fail rate > 50%',
action: 'TIGHTEN'
});
} else if (eligibleT20PassRate !== null && eligibleT20PassRate > 70 && eligibleRow.t20_total >= 12) {
recommendations.push({
filter_id: 'SAQG_F3_EXCESS_DRAWDOWN',
current: '5%p',
recommended: '7%p',
rationale: 'ELIGIBLE T+20 success rate > 70% and cases >= 12',
action: 'RELAX_REVIEW'
});
} else {
recommendations.push({
filter_id: 'SAQG_F1_F2_F3',
current: 'UNCHANGED',
recommended: 'HOLD',
rationale: 'No threshold change supported by current sample',
action: 'HOLD'
});
}
}
var payload = {
formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
as_of: today,
analysis_period: monthKey,
status: analyzedCases >= 10 ? 'ANALYZED' : 'DATA_INSUFFICIENT',
cases_analyzed: analyzedCases,
grade_count: Object.keys(gradeStats).length,
eligible_t20_fail_rate: eligibleT20FailRate,
eligible_t60_fail_rate: eligibleT60FailRate,
recommended_filter_adjustments: analyzedCases >= 10 ? recommendations : [],
grade_summary: gradeSummary
};
writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(payload));
Logger.log('[AFL] done - ' + payload.grade_count + ' grades analyzed, cases=' + analyzedCases);
return payload;
}
// ── E2: 월말 자산 스냅샷 → monthly_history 기록 ─────────────────────────────
// 트리거: 매달 마지막 영업일 16:30 독립 실행 OR runDataFeed 완료 후 호출.
function runMonthlySnapshot() {
const settings = readSettingsTab_();
const totalAsset = parseFloat(settings["total_asset_krw"]);
if (!Number.isFinite(totalAsset) || totalAsset <= 0) {
Logger.log("runMonthlySnapshot 스킵: total_asset_krw 미설정");
return;
}
const month = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM");
// macro에서 버킷·orbit 읽기
const macro = getMacroJson();
const bDetail = String(macro.bucket_detail ?? "");
const corePct = parseFloat(bDetail.match(/core=([\d.]+)%/)?.[1] ?? "") || "";
const satPct = parseFloat(bDetail.match(/sat=([\d.]+)%/)?.[1] ?? "") || "";
const cashPct = parseFloat(bDetail.match(/cash=([\d.]+)%/)?.[1] ?? "") || "";
const orbitGap = macro.orbit_gap_pct !== "N/A" ? macro.orbit_gap_pct : "";
const orbitState = macro.orbit_state !== "N/A" ? macro.orbit_state : "";
// MoM/YTD: monthly_history에서 이전 자산 읽기
const ss = getSpreadsheet_();
const histSheet = ss.getSheetByName("monthly_history");
let prevAsset = null, jan1Asset = null;
const thisYear = month.substring(0, 4);
if (histSheet) {
const hd = histSheet.getDataRange().getValues();
const hdr = hd[0] ?? [];
const mIdx = hdr.indexOf("Month");
const aIdx = hdr.indexOf("Total_Asset");
if (mIdx >= 0 && aIdx >= 0) {
for (let i = 1; i < hd.length; i++) {
const raw = hd[i][mIdx];
const mStr = raw instanceof Date && !isNaN(raw.getTime())
? Utilities.formatDate(raw, "Asia/Seoul", "yyyy-MM")
: String(raw ?? "").trim().substring(0, 7);
if (mStr === month) continue;
const a = parseFloat(hd[i][aIdx]);
if (mStr && Number.isFinite(a)) {
prevAsset = a;
if (mStr === `${thisYear}-01`) jan1Asset = a;
}
}
}
}
const momRet = (prevAsset && prevAsset > 0)
? parseFloat(((totalAsset / prevAsset - 1) * 100).toFixed(2)) : "";
const ytdRet = (jan1Asset && jan1Asset > 0)
? parseFloat(((totalAsset / jan1Asset - 1) * 100).toFixed(2)) : "";
// AEW aggregate: T+20/T+60 outcomes this month from alpha_history
var satT20PassN = 0, satT20FailN = 0, satT60PassN = 0;
var satT20AlphaSum = 0, satT20AlphaCount = 0;
var alphaSheet = ss.getSheetByName("alpha_history");
if (alphaSheet) {
var aData = alphaSheet.getDataRange().getValues();
if (aData.length > 1) {
var aHdr = aData[0];
var aMap = {};
aHdr.forEach(function(h, i) { aMap[String(h)] = i; });
var skipSet = { 'NOT_YET': 1, 'EXEMPT': 1, 'DATA_MISSING': 1, '': 1 };
for (var ai = 1; ai < aData.length; ai++) {
var ar = aData[ai];
var t20cd = String(ar[aMap['T20_Check_Date']] || '');
if (!t20cd || t20cd.substring(0, 7) !== month) continue;
var t20g = String(ar[aMap['T20_Alpha_Gate']] || '');
var t60g = String(ar[aMap['T60_Alpha_Gate']] || '');
var t20v = parseFloat(ar[aMap['T20_Vs_Core_Pctp']]);
if (t20g === 'T20_ALPHA_PASS') satT20PassN++;
else if (t20g === 'T20_ALPHA_FAIL') satT20FailN++;
if (t60g === 'T60_ALPHA_PASS') satT60PassN++;
if (!skipSet[t20g] && Number.isFinite(t20v)) {
satT20AlphaSum += t20v;
satT20AlphaCount++;
}
}
}
}
var satAvgT20Alpha = satT20AlphaCount > 0
? parseFloat((satT20AlphaSum / satT20AlphaCount).toFixed(2)) : '';
try {
runAlphaFeedbackLoop_();
} catch (e) {
Logger.log('[AFL] runAlphaFeedbackLoop_ in runMonthlySnapshot error: ' + e.message);
}
upsertMonthlyRow_(month, {
Total_Asset: totalAsset,
Core_Pct: corePct,
Satellite_Pct: satPct,
Cash_Pct: cashPct,
MoM_Return_Pct: momRet,
YTD_Return_Pct: ytdRet,
Orbit_Gap_Pct: orbitGap,
Orbit_State: orbitState,
Sat_T20_Pass_N: satT20PassN || '',
Sat_T20_Fail_N: satT20FailN || '',
Sat_T60_Pass_N: satT60PassN || '',
Sat_Avg_T20_Alpha_Pct: satAvgT20Alpha,
});
Logger.log(`monthly_history(snapshot): ${month} asset=${totalAsset.toLocaleString()} MoM=${momRet}% YTD=${ytdRet}%`);
}
// ── E4: 데이터 소스 정합성 주 1회 헬스체크 ──────────────────────────────────
// 트리거: 주 1회 (매주 월요일 09:00) 독립 실행.
// Naver 가격/수급 스크래핑 패턴 정상 여부를 확인하고 Logger에 리포트를 남긴다.
// doGet(?view=source_health) 로도 조회 가능.
function checkDataSourceHealth() {
const PROBE_TICKER = Object.keys(TICKER_SECTOR_MAP)[0] ?? "005930"; // 첫 번째 종목(기본 삼성전자)
const results = { checked_at: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm"), probe_ticker: PROBE_TICKER, checks: [] };
const ok = (name, detail) => { results.checks.push({ name, status: "OK", detail: detail ?? "" }); };
const fail = (name, detail) => { results.checks.push({ name, status: "FAIL", detail: detail ?? "" }); };
// 1. Naver 종목 시세 (Close 패턴)
try {
beginFetchSession_();
const url = `https://finance.naver.com/item/main.nhn?code=${PROBE_TICKER}`;
const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
const html = resp.getContentText("EUC-KR");
const closeMatch = html.match(/<p id="nowVal"[^>]*>([\d,]+)<\/p>/i)
|| html.match(/현재가\s+([\d,]+)/i);
if (closeMatch) {
const price = parseKrNum_(closeMatch[1]);
price > 0 ? ok("naver_close", `${price.toLocaleString()}원`) : fail("naver_close", "값 0 또는 음수");
} else {
fail("naver_close", "정규식 미매칭 — DOM 변경 가능성");
}
// 2. Naver PER 패턴
const perMatch = html.match(/<em id="_per">([\d,.]+)<\/em>/);
perMatch ? ok("naver_per", `PER ${parseKrNum_(perMatch[1])}`) : fail("naver_per", "_per 패턴 미매칭");
// 3. Naver 52주 고저 패턴
const highMatch = html.match(/52주\s+최고\s*[:\s]*([\d,]+)/i);
highMatch ? ok("naver_52w", "52주 고저 패턴 정상") : fail("naver_52w", "52주 패턴 미매칭");
} catch(e) {
fail("naver_fetch", String(e));
} finally {
endFetchSession_();
}
// 4. Naver 수급 탭 패턴
try {
beginFetchSession_();
const furl = `https://finance.naver.com/item/frgn.nhn?code=${PROBE_TICKER}`;
const fhtml = UrlFetchApp.fetch(furl, { muteHttpExceptions: true }).getContentText("EUC-KR");
const trMatch = fhtml.match(/<tr[^>]*class="[^"]*"[^>]*>[\s\S]{0,300}?<\/tr>/g);
trMatch && trMatch.length >= 5 ? ok("naver_flow", `tr행 ${trMatch.length}개`) : fail("naver_flow", "수급 테이블 구조 변경 가능성");
} catch(e) {
fail("naver_flow_fetch", String(e));
} finally {
endFetchSession_();
}
// 5. Yahoo Finance 패턴 (EPS 성장률)
try {
beginFetchSession_();
const ysym = normalizeYahooSymbol(PROBE_TICKER);
const yurl = `https://finance.yahoo.com/quote/${ysym}/analysis`;
const yresp = UrlFetchApp.fetch(yurl, { muteHttpExceptions: true });
yresp.getResponseCode() < 400 ? ok("yahoo_analysis", `HTTP ${yresp.getResponseCode()}`) : fail("yahoo_analysis", `HTTP ${yresp.getResponseCode()}`);
} catch(e) {
fail("yahoo_fetch", String(e));
} finally {
endFetchSession_();
}
const failCount = results.checks.filter(c => c.status === "FAIL").length;
results.overall = failCount === 0 ? "HEALTHY" : failCount <= 1 ? "DEGRADED" : "CRITICAL";
results.summary = `${results.checks.length}개 체크 중 ${failCount}개 실패 → ${results.overall}`;
Logger.log(`[DataSourceHealth] ${results.summary}`);
results.checks.forEach(c => Logger.log(` [${c.status}] ${c.name}: ${c.detail}`));
return results;
}
// ── E2: asset_history JSON 뷰 ────────────────────────────────────────────────
function getAssetHistoryJson() {
const history = sheetToJson("monthly_history");
if (!history.length) return { history: [], current: null, mom_series: [] };
const latest = history[history.length - 1];
const momSeries = history
.filter(r => r.MoM_Return_Pct !== "" && r.MoM_Return_Pct != null)
.map(r => ({ month: r.Month, mom_ret: r.MoM_Return_Pct, ytd_ret: r.YTD_Return_Pct }));
return { history, current: latest, mom_series: momSeries };
}
function readSettings_(ss) {
var result = {};
var sheet = ss.getSheetByName(SETTINGS_SHEET_NAME);
if (!sheet) return result;
var data = sheet.getDataRange().getValues();
data.forEach(function(row) {
var key = String(row[0] || '').trim();
if (key) result[key] = row[1];
});
return result;
}
/**
* settings 시트에서 특정 키의 값을 갱신하거나 신규 추가한다.
* O3 PORTFOLIO_DRAWDOWN_GATE_V1의 portfolio_peak_krw 자동 갱신에 사용.
*/
function writeSettingValue_(ss, key, value) {
var sheet = ss.getSheetByName(SETTINGS_SHEET_NAME);
if (!sheet) return false;
var data = sheet.getDataRange().getValues();
for (var i = 0; i < data.length; i++) {
if (String(data[i][0] || '').trim() === key) {
sheet.getRange(i + 1, 2).setValue(value);
return true;
}
}
sheet.appendRow([key, value]);
return true;
}
// ── 유틸리티 ─────────────────────────────────────────────────────────────────
/**
* KRX 호가단위 정규화 — floor(raw / tick) * tick
* spec/13_formula_registry.yaml:TICK_NORMALIZER_V1
*/
function tickNormalize_(rawPrice) {
var tick = getTickSize_(rawPrice);
return Math.floor(rawPrice / tick) * tick;
}
function getTickSize_(price) {
for (var k = 0; k < TICK_TABLE.length; k++) {
if (price < TICK_TABLE[k].maxPrice) return TICK_TABLE[k].tick;
}
return 1000; // >= 500000원
}
function writeHarnessSheet_(ss, rows, now) {
var sheet = ss.getSheetByName(HARNESS_SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(HARNESS_SHEET_NAME);
} else {
sheet.clearContents();
}
sheet.getRange(1, 1).setValue(
HARNESS_SHEET_NAME + ' — GAS computed guard values (HARNESS_AUTHORITATIVE)');
sheet.getRange(1, 2).setValue(formatIso_(now));
sheet.getRange(2, 1).setValue('key');
sheet.getRange(2, 2).setValue('value');
if (rows.length > 0) {
var MAX_CELL = 49000;
var safeRows = rows.map(function(r) {
var v = r[1];
if (typeof v === 'string' && v.length > MAX_CELL) {
Logger.log('[HARNESS] CELL_OVERSIZED key=' + r[0] + ' len=' + v.length + ' → trimmed placeholder');
return [r[0], JSON.stringify({ status: 'OVERSIZED', original_len: v.length, key: String(r[0]) })];
}
return r;
});
sheet.getRange(3, 1, safeRows.length, 2).setValues(safeRows);
}
}
function buildColIdx_(headers) {
var idx = {};
headers.forEach(function(h, i) {
var key = String(h || '').trim();
if (key) idx[key] = i;
});
return idx;
}
/** row[c[colName]] 숫자 읽기 — 컬럼 없거나 NaN이면 0 */
function numCol_(row, c, colName) {
return c[colName] !== undefined ? toNumber_(row[c[colName]]) : 0;
}
/** row[c[colName]] 문자열 읽기 — 컬럼 없으면 '' */
function strCol_(row, c, colName) {
return c[colName] !== undefined ? String(row[c[colName]] || '').trim() : '';
}
/**
* ticker 정규화 — 숫자 코드는 6자리 zero-pad
* convert_xlsx_to_json.py:normalize_code 와 동일 로직
*/
function normTicker_(raw) {
var s = String(raw || '').trim();
if (!s) return '';
if (s.slice(-2) === '.0') s = s.slice(0, -2);
var digits = s.replace('.', '');
if (/^\d+$/.test(digits) && digits.length <= 6) {
var n = parseInt(digits, 10);
var ns = String(n);
while (ns.length < 6) ns = '0' + ns;
return ns;
}
return s;
}
/** Array.prototype.indexOf 폴리필 래퍼 (GAS 호환) */
function indexOfArr_(arr, val) {
for (var k = 0; k < arr.length; k++) {
if (arr[k] === val) return k;
}
return -1;
}
function toNumber_(v) {
if (v === null || v === undefined || v === '') return 0;
var n = Number(v);
return isNaN(n) ? 0 : n;
}
function round2_(v) { return Math.round(v * 100) / 100; }
// ══════════════════════════════════════════════════════════════════════════════
// Alpha-Shield 선행 레이더 (2026-05-19-X1W1)
// X1: MEAN_REVERSION_GATE_V1 | X3: RS_RATIO_V1
// W1: DIVERGENCE_SCORE_V1 | W2: OVERHANG_PRESSURE_V1
// W3: SECTOR_ROTATION_RADAR_V1 | W4: FLOW_ACCELERATION_V1
// ══════════════════════════════════════════════════════════════════════════════
/**
* numColN_ — nullable 버전: 컬럼 없으면 null 반환 (numCol_ 은 0 반환)
* Alpha-Shield 레이더는 0(값 없음)과 0(값=0)을 구분해야 한다.
*/
function numColN_(row, c, colName) {
return c[colName] !== undefined ? toNumber_(row[c[colName]]) : null;
}
/**
* macro 시트에서 KOSPI 5D 수익률 읽기
* RS_RATIO_V1 분모: kospi_5d_return
*/
function readKospiRet5d_(ss) {
try {
var macroSheet = ss.getSheetByName('macro');
if (!macroSheet) return null;
var mData = macroSheet.getDataRange().getValues();
if (mData.length < 3) return null;
var mHdr = mData[1] || [];
var nameIdx = mHdr.indexOf('Name');
var r5dIdx = mHdr.indexOf('Ret5D');
if (nameIdx < 0 || r5dIdx < 0) return null;
for (var i = 2; i < mData.length; i++) {
if (String(mData[i][nameIdx] || '').trim() === 'KOSPI') {
var v = parseFloat(mData[i][r5dIdx]);
return Number.isFinite(v) ? v : null;
}
}
} catch(e) { Logger.log('[HARNESS] readKospiRet5d_ error: ' + e); }
return null;
}
/**
* macro 시트에서 KOSPI 20D 수익률 읽기
* 상대 손절 베타 프록시 분모: kospi_20d_return
*/
function readKospiRet20d_(ss) {
try {
var macroSheet = ss.getSheetByName('macro');
if (!macroSheet) return null;
var mData = macroSheet.getDataRange().getValues();
if (mData.length < 3) return null;
var mHdr = mData[1] || [];
var nameIdx = mHdr.indexOf('Name');
var r20dIdx = mHdr.indexOf('Ret20D');
if (nameIdx < 0 || r20dIdx < 0) return null;
for (var i = 2; i < mData.length; i++) {
if (String(mData[i][nameIdx] || '').trim() === 'KOSPI') {
var v = parseFloat(mData[i][r20dIdx]);
return Number.isFinite(v) ? v : null;
}
}
} catch(e) { Logger.log('[HARNESS] readKospiRet20d_ error: ' + e); }
return null;
}
/**
* sector_flow 시트에서 W3 레이더용 데이터 읽기
* 반환: { sector_name → { rank, prevRank, prevRankW2, smart5, smart20 } }
*/
function readSectorFlowForRadar_(ss) {
var result = {};
try {
var sfSheet = ss.getSheetByName('sector_flow');
if (!sfSheet) return result;
var sfData = sfSheet.getDataRange().getValues();
if (sfData.length < 3) return result;
var sfHdr = sfData[1] || [];
var sNameIdx = sfHdr.indexOf('Sector');
var rankIdx = sfHdr.indexOf('Sector_Rank') >= 0
? sfHdr.indexOf('Sector_Rank') : sfHdr.indexOf('Rotation_Rank');
var prevRkIdx = sfHdr.indexOf('Prev_Rotation_Rank');
var prevRkW2Idx = sfHdr.indexOf('Prev_Rotation_Rank_W2');
var sm5Idx = sfHdr.indexOf('SmartMoney_5D_KRW') >= 0
? sfHdr.indexOf('SmartMoney_5D_KRW') : sfHdr.indexOf('Frg_5D_SUM');
var sm20Idx = sfHdr.indexOf('SmartMoney_20D_KRW') >= 0
? sfHdr.indexOf('SmartMoney_20D_KRW') : sfHdr.indexOf('Frg_20D_SUM');
if (sNameIdx < 0) return result;
for (var i = 2; i < sfData.length; i++) {
var sName = String(sfData[i][sNameIdx] || '').trim();
if (!sName || sName === 'Sector') continue;
result[sName] = {
rank: rankIdx >= 0 ? parseInt(sfData[i][rankIdx]) : null,
prevRank: prevRkIdx >= 0 ? parseInt(sfData[i][prevRkIdx]) : null,
prevRankW2: prevRkW2Idx >= 0 ? parseInt(sfData[i][prevRkW2Idx]) : null,
smart5: sm5Idx >= 0 ? parseFloat(sfData[i][sm5Idx]) : null,
smart20: sm20Idx >= 0 ? parseFloat(sfData[i][sm20Idx]) : null
};
}
} catch(e) { Logger.log('[HARNESS] readSectorFlowForRadar_ error: ' + e); }
return result;
}
function formatIso_(d) {
try { return d instanceof Date ? d.toISOString() : String(d); }
catch (e) { return String(d); }
}
// ---- TASK-003: RAW_VS_ADJUSTED_DISCLOSURE_V1 ----
// [GAS_STUB_ONLY: requires Google Sheets deployment]
function formatRawAdjustedPair_(rawVal, adjVal) {
// raw 병기 없는 adjusted 단독 표시 금지 (RC3 수정)
if (rawVal === null || rawVal === undefined) {
return '[RAW_MISSING: adjusted=' + adjVal + ' — raw 없이 adjusted 단독 표시 금지]';
}
return 'raw ' + rawVal + '% / adj ' + adjVal + '%';
}