af1236202d
- F14: late_chase_risk_score 검증 * GAS가 유일한 생산처 (Python canonical 없음) * migration_action: KEEP_IN_GAS로 정정, status: DONE - F02/F03/F04/F06: priceBasis 로직 포팅 * formulas/price_basis_v1.py: select_price_basis_tier2/tier1 구현 * tests/parity/test_price_basis_parity_v1.py: 8 parity 테스트 (모두 PASS) * GAS Number.isFinite() 의미론 정확히 재현 (math.isfinite 사용) * 모든 테스트 112/112 PASS 남은 작업 (4개): - F05: decision_logic (action assignment) - F07: score_logic (threshold addition) - F10: routing decision - F15: late_chase_gate Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
3377 lines
147 KiB
JavaScript
3377 lines
147 KiB
JavaScript
// =========================================================================
|
||
// GENERATED BUNDLE - DO NOT EDIT THIS FILE MANUALLY
|
||
// Generated At: 2026-06-22 02:21:03 KST
|
||
// Source Files: src/gas/core/gas_lib.gs
|
||
// Source Hash: 966792cb99e2f85967c51295b063703fd4f7f279a90c841b5f11757f48df88b1
|
||
// =========================================================================
|
||
|
||
// --- Source: src/gas/core/gas_lib.gs ---
|
||
// gas_lib.gs - Common utilities & static features
|
||
// Last Updated: 2026-06-16 00:41:17 KST
|
||
// 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);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── 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: "491820", proxyName: "HANARO 전력설비투자", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||
{ code: "010120", name: "LS ELECTRIC", weight: 0.28 },
|
||
{ code: "267260", name: "HD현대일렉트릭", weight: 0.28 },
|
||
{ code: "298040", name: "효성중공업", weight: 0.18 },
|
||
{ code: "006260", name: "LS", weight: 0.14 },
|
||
{ code: "099440", name: "두산에너빌리티", weight: 0.12 },
|
||
]},
|
||
{ sector: "방산", proxyTicker: "463250", proxyName: "TIGER K방산&우주", proxyType: "ETF", 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: "건설", proxyTicker: "117700", proxyName: "KODEX 건설", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||
{ code: "000720", name: "현대건설", weight: 0.35 },
|
||
{ code: "006360", name: "GS건설", weight: 0.25 },
|
||
{ code: "047040", name: "대우건설", weight: 0.20 },
|
||
{ code: "294870", name: "HDC현대산업개발", weight: 0.20 },
|
||
]},
|
||
{ sector: "플랜트/EPC", proxyTicker: "454320", proxyName: "HANARO CAPEX설비투자iSelect", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||
{ code: "028050", name: "삼성E&A", weight: 0.35 },
|
||
{ code: "010120", name: "LS ELECTRIC", weight: 0.20 },
|
||
{ code: "267260", name: "HD현대일렉트릭", weight: 0.20 },
|
||
{ code: "298040", name: "효성중공업", weight: 0.15 },
|
||
{ code: "099440", 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: "024110", name: "기업은행", weight: 0.10 },
|
||
]},
|
||
{ sector: "증권", proxyTicker: "0111J0", proxyName: "HANARO 증권고배당TOP3플러스", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||
{ code: "071050", name: "한국금융지주", weight: 0.2135 },
|
||
{ code: "006800", name: "미래에셋증권", weight: 0.1934 },
|
||
{ code: "005940", name: "NH투자증권", weight: 0.1911 },
|
||
{ code: "016360", name: "삼성증권", weight: 0.1434 },
|
||
{ code: "039490", name: "키움증권", weight: 0.1373 },
|
||
]},
|
||
{ sector: "지주회사", proxyTicker: "307520", proxyName: "TIGER 지주회사", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||
{ code: "180640", name: "한진칼", weight: 0.1535 },
|
||
{ code: "267250", name: "HD현대", weight: 0.0943 },
|
||
{ code: "034730", name: "SK", weight: 0.0884 },
|
||
{ code: "000150", name: "두산", weight: 0.0878 },
|
||
{ code: "005490", name: "POSCO홀딩스", weight: 0.0763 },
|
||
{ code: "003550", name: "LG", weight: 0.0752 },
|
||
{ code: "006260", name: "LS", weight: 0.0705 },
|
||
{ code: "078930", name: "GS", weight: 0.0498 },
|
||
{ code: "001040", name: "CJ", weight: 0.0477 },
|
||
{ code: "010060", name: "OCI홀딩스", weight: 0.0240 },
|
||
]},
|
||
{ 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: "434730", proxyName: "HANARO 원자력iSelect", proxyType: "ETF", 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: "0190C0", proxyName: "RISE 현대차고정피지컬AI", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||
{ code: "005380", name: "현대차", weight: 0.2402 },
|
||
{ code: "012330", name: "현대모비스", weight: 0.1588 },
|
||
{ code: "011070", name: "LG이노텍", weight: 0.1450 },
|
||
{ code: "000270", name: "기아", weight: 0.1234 },
|
||
{ code: "307950", name: "현대오토에버", weight: 0.0899 },
|
||
{ code: "277810", name: "레인보우로보틱스", weight: 0.0673 },
|
||
{ code: "064400", name: "LG씨엔에스", weight: 0.0519 },
|
||
{ code: "454910", name: "두산로보틱스", weight: 0.0367 },
|
||
{ code: "108490", name: "로보티즈", weight: 0.0240 },
|
||
{ code: "058610", name: "에스피지", weight: 0.0173 },
|
||
{ code: "010620", name: "현대미포", weight: 0.0135 },
|
||
{ code: "009540", name: "HD한국조선해양", weight: 0.0135 },
|
||
{ code: "011210", name: "현대위아", weight: 0.0109 },
|
||
{ code: "121600", name: "나노신소재", weight: 0.0040 },
|
||
{ code: "028050", name: "삼성E&A", weight: 0.0034 },
|
||
]},
|
||
{ 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 "소비재";
|
||
if (s === "건설/EPC") return "플랜트/EPC";
|
||
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.map(sector => ({
|
||
...sector,
|
||
source: sector.source || "DEFAULT_TEMPLATE",
|
||
sourceUrl: sector.sourceUrl || "",
|
||
sourceAsOf: sector.sourceAsOf || "",
|
||
constituents: sector.constituents.map(c => ({
|
||
...c,
|
||
source: c.source || sector.source || "DEFAULT_TEMPLATE",
|
||
sourceUrl: c.sourceUrl || sector.sourceUrl || "",
|
||
sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "",
|
||
})),
|
||
}));
|
||
}
|
||
const data = sheet.getDataRange().getValues();
|
||
if (data.length < 3) {
|
||
writeDefaultSectorUniverseSheet_();
|
||
return DEFAULT_SECTOR_UNIVERSE_V2.map(sector => ({
|
||
...sector,
|
||
source: sector.source || "DEFAULT_TEMPLATE",
|
||
sourceUrl: sector.sourceUrl || "",
|
||
sourceAsOf: sector.sourceAsOf || "",
|
||
constituents: sector.constituents.map(c => ({
|
||
...c,
|
||
source: c.source || sector.source || "DEFAULT_TEMPLATE",
|
||
sourceUrl: c.sourceUrl || sector.sourceUrl || "",
|
||
sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "",
|
||
})),
|
||
}));
|
||
}
|
||
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.map(sector => ({
|
||
...sector,
|
||
source: sector.source || "DEFAULT_TEMPLATE",
|
||
sourceUrl: sector.sourceUrl || "",
|
||
sourceAsOf: sector.sourceAsOf || "",
|
||
constituents: sector.constituents.map(c => ({
|
||
...c,
|
||
source: c.source || sector.source || "DEFAULT_TEMPLATE",
|
||
sourceUrl: c.sourceUrl || sector.sourceUrl || "",
|
||
sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "",
|
||
})),
|
||
}));
|
||
}
|
||
|
||
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",
|
||
source: idx("Source") >= 0 ? String(data[i][idx("Source")] ?? "").trim() : "SHEET_INPUT",
|
||
sourceUrl: idx("Source_URL") >= 0 ? String(data[i][idx("Source_URL")] ?? "").trim() : "",
|
||
sourceAsOf: idx("Source_AsOf") >= 0 ? String(data[i][idx("Source_AsOf")] ?? "").trim() : "",
|
||
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,
|
||
source: idx("Source") >= 0 ? String(data[i][idx("Source")] ?? "").trim() : "SHEET_INPUT",
|
||
transportMode: idx("Transport_Mode") >= 0 ? String(data[i][idx("Transport_Mode")] ?? "").trim() : "",
|
||
sourceUrl: idx("Source_URL") >= 0 ? String(data[i][idx("Source_URL")] ?? "").trim() : "",
|
||
sourceAsOf: idx("Source_AsOf") >= 0 ? String(data[i][idx("Source_AsOf")] ?? "").trim() : "",
|
||
});
|
||
}
|
||
const sectors = Object.values(map).filter(s => s.proxyTicker && s.constituents.length > 0);
|
||
const sectorSet = new Set(sectors.map(s => s.sector));
|
||
for (const fallback of DEFAULT_SECTOR_UNIVERSE_V2) {
|
||
if (!fallback || !fallback.sector || sectorSet.has(fallback.sector)) continue;
|
||
sectors.push({
|
||
sector: fallback.sector,
|
||
proxyTicker: fallback.proxyTicker,
|
||
proxyName: fallback.proxyName,
|
||
proxyType: fallback.proxyType,
|
||
baseTicker: fallback.baseTicker || "069500",
|
||
source: fallback.source || "DEFAULT_TEMPLATE",
|
||
transportMode: fallback.transportMode || ((fallback.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (fallback.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"),
|
||
sourceUrl: fallback.sourceUrl || "",
|
||
sourceAsOf: fallback.sourceAsOf || "",
|
||
constituents: fallback.constituents.map(c => ({
|
||
code: c.code,
|
||
name: c.name || "",
|
||
weight: c.weight,
|
||
isEtf: Boolean(c.isEtf),
|
||
source: c.source || fallback.source || "DEFAULT_TEMPLATE",
|
||
transportMode: c.transportMode || ((c.source || fallback.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (c.source || fallback.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"),
|
||
sourceUrl: c.sourceUrl || fallback.sourceUrl || "",
|
||
sourceAsOf: c.sourceAsOf || fallback.sourceAsOf || "",
|
||
})),
|
||
});
|
||
}
|
||
return sectors.length ? sectors : DEFAULT_SECTOR_UNIVERSE_V2.map(sector => ({
|
||
...sector,
|
||
source: sector.source || "DEFAULT_TEMPLATE",
|
||
transportMode: sector.transportMode || ((sector.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (sector.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"),
|
||
sourceUrl: sector.sourceUrl || "",
|
||
sourceAsOf: sector.sourceAsOf || "",
|
||
constituents: sector.constituents.map(c => ({
|
||
...c,
|
||
source: c.source || sector.source || "DEFAULT_TEMPLATE",
|
||
transportMode: c.transportMode || ((c.source || sector.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (c.source || sector.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"),
|
||
sourceUrl: c.sourceUrl || sector.sourceUrl || "",
|
||
sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "",
|
||
})),
|
||
}));
|
||
}
|
||
|
||
function writeDefaultSectorUniverseSheet_() {
|
||
const headers = [
|
||
"Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Base_Ticker",
|
||
"Constituent_Code","Constituent_Name","Weight","Is_ETF","Enabled","Effective_Date","Source","Transport_Mode",
|
||
"Source_URL","Source_AsOf"
|
||
];
|
||
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.source || c.source || "DEFAULT_TEMPLATE",
|
||
sector.transportMode || c.transportMode || (((sector.source || c.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (sector.source || c.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY") ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"),
|
||
sector.sourceUrl || c.sourceUrl || "",
|
||
sector.sourceAsOf || c.sourceAsOf || "",
|
||
]);
|
||
}
|
||
}
|
||
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 parseDateOnly_(value) {
|
||
const text = String(value ?? "").trim();
|
||
if (!text) return null;
|
||
const norm = text.replace(/\./g, "-").slice(0, 10);
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(norm)) return null;
|
||
const parsed = new Date(norm + "T00:00:00+09:00");
|
||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||
}
|
||
|
||
function calcSectorUniverseRefreshAudit_(universe) {
|
||
const today = new Date();
|
||
const rows = [];
|
||
const sourceKindCounts = { NAVER_ETF_PAGE: 0, NAVER_ETF_PAGE_FAIL_LAYOUT_CHANGED: 0, NAVER_ETF_PAGE_FAIL: 0, REPRESENTATIVE_STOCK_PROXY: 0, SHEET_INPUT: 0, DEFAULT_TEMPLATE: 0, OTHER: 0 };
|
||
const transportModeCounts = { HTML_SERVER_RENDERED: 0, MANUAL_OR_TEMPLATE: 0, LAYOUT_CHANGED: 0, UNKNOWN: 0 };
|
||
let currentCount = 0;
|
||
let dueCount = 0;
|
||
let overdueCount = 0;
|
||
let missingCount = 0;
|
||
let templateCount = 0;
|
||
let sheetInputCount = 0;
|
||
let naverSourceCount = 0;
|
||
let layoutChangedCount = 0;
|
||
let missingSourceUrlCount = 0;
|
||
let staleSectorCount = 0;
|
||
let oldestSourceAsOf = null;
|
||
let newestSourceAsOf = null;
|
||
|
||
for (const sector of universe || []) {
|
||
const sectorRows = Array.isArray(sector?.constituents) ? sector.constituents : [];
|
||
const sourceKind = String(sector?.source || "SHEET_INPUT").trim() || "SHEET_INPUT";
|
||
if (Object.prototype.hasOwnProperty.call(sourceKindCounts, sourceKind)) {
|
||
sourceKindCounts[sourceKind] += 1;
|
||
} else {
|
||
sourceKindCounts.OTHER += 1;
|
||
}
|
||
const transportMode = String(sector?.transportMode || "").trim() ||
|
||
(sourceKind === "NAVER_ETF_PAGE" || sourceKind === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" :
|
||
sourceKind === "NAVER_ETF_PAGE_FAIL_LAYOUT_CHANGED" ? "LAYOUT_CHANGED" :
|
||
(sourceKind === "DEFAULT_TEMPLATE" || sourceKind === "SHEET_INPUT" ? "MANUAL_OR_TEMPLATE" : "UNKNOWN"));
|
||
if (Object.prototype.hasOwnProperty.call(transportModeCounts, transportMode)) {
|
||
transportModeCounts[transportMode] += 1;
|
||
} else {
|
||
transportModeCounts.UNKNOWN += 1;
|
||
}
|
||
|
||
const sourceUrl = String(sector?.sourceUrl || "").trim();
|
||
const sourceAsOf = String(sector?.sourceAsOf || "").trim();
|
||
const parsed = parseDateOnly_(sourceAsOf);
|
||
const ageDays = parsed ? Math.floor((today.getTime() - parsed.getTime()) / 86400000) : null;
|
||
if (parsed) {
|
||
oldestSourceAsOf = oldestSourceAsOf && oldestSourceAsOf < parsed ? oldestSourceAsOf : parsed;
|
||
newestSourceAsOf = newestSourceAsOf && newestSourceAsOf > parsed ? newestSourceAsOf : parsed;
|
||
}
|
||
|
||
let status = "INVALID";
|
||
const reasons = [];
|
||
if (sourceKind === "DEFAULT_TEMPLATE") {
|
||
status = "TEMPLATE";
|
||
templateCount += 1;
|
||
reasons.push("DEFAULT_TEMPLATE");
|
||
} else if (sourceKind === "REPRESENTATIVE_STOCK_PROXY") {
|
||
if (!sourceUrl) {
|
||
status = "MISSING";
|
||
missingCount += 1;
|
||
missingSourceUrlCount += 1;
|
||
reasons.push("Source_URL_MISSING");
|
||
} else if (ageDays === null) {
|
||
status = "MISSING";
|
||
missingCount += 1;
|
||
reasons.push("Source_AsOf_MISSING");
|
||
} else if (ageDays <= 31) {
|
||
status = "CURRENT";
|
||
currentCount += 1;
|
||
} else if (ageDays <= 45) {
|
||
status = "DUE";
|
||
dueCount += 1;
|
||
staleSectorCount += 1;
|
||
reasons.push(`AgeDays=${ageDays}`);
|
||
} else {
|
||
status = "OVERDUE";
|
||
overdueCount += 1;
|
||
staleSectorCount += 1;
|
||
reasons.push(`AgeDays=${ageDays}`);
|
||
}
|
||
} else if (sourceKind === "SHEET_INPUT") {
|
||
sheetInputCount += 1;
|
||
if (!sourceUrl) {
|
||
status = "MISSING";
|
||
missingCount += 1;
|
||
missingSourceUrlCount += 1;
|
||
reasons.push("Source_URL_MISSING");
|
||
} else if (ageDays === null) {
|
||
status = "MISSING";
|
||
missingCount += 1;
|
||
reasons.push("Source_AsOf_MISSING");
|
||
} else if (ageDays <= 31) {
|
||
status = "CURRENT";
|
||
currentCount += 1;
|
||
} else if (ageDays <= 45) {
|
||
status = "DUE";
|
||
dueCount += 1;
|
||
staleSectorCount += 1;
|
||
reasons.push(`AgeDays=${ageDays}`);
|
||
} else {
|
||
status = "OVERDUE";
|
||
overdueCount += 1;
|
||
staleSectorCount += 1;
|
||
reasons.push(`AgeDays=${ageDays}`);
|
||
}
|
||
} else if (sourceKind === "NAVER_ETF_PAGE") {
|
||
naverSourceCount += 1;
|
||
if (!sourceUrl) {
|
||
status = "MISSING";
|
||
missingCount += 1;
|
||
missingSourceUrlCount += 1;
|
||
reasons.push("Source_URL_MISSING");
|
||
} else if (ageDays === null) {
|
||
status = "MISSING";
|
||
missingCount += 1;
|
||
reasons.push("Source_AsOf_MISSING");
|
||
} else if (ageDays <= 31) {
|
||
status = "CURRENT";
|
||
currentCount += 1;
|
||
} else if (ageDays <= 45) {
|
||
status = "DUE";
|
||
dueCount += 1;
|
||
staleSectorCount += 1;
|
||
reasons.push(`AgeDays=${ageDays}`);
|
||
} else {
|
||
status = "OVERDUE";
|
||
overdueCount += 1;
|
||
staleSectorCount += 1;
|
||
reasons.push(`AgeDays=${ageDays}`);
|
||
}
|
||
} else if (sourceKind === "NAVER_ETF_PAGE_FAIL_LAYOUT_CHANGED") {
|
||
layoutChangedCount += 1;
|
||
status = "LAYOUT_CHANGED";
|
||
if (!sourceUrl) {
|
||
missingSourceUrlCount += 1;
|
||
reasons.push("Source_URL_MISSING");
|
||
}
|
||
if (ageDays === null) {
|
||
reasons.push("Source_AsOf_MISSING");
|
||
} else {
|
||
staleSectorCount += 1;
|
||
reasons.push(`AgeDays=${ageDays}`);
|
||
}
|
||
} else {
|
||
status = "INVALID";
|
||
reasons.push("SOURCE_KIND_UNKNOWN");
|
||
if (!sourceUrl) missingSourceUrlCount += 1;
|
||
}
|
||
if (!sourceUrl) reasons.push("Source_URL_MISSING");
|
||
if (ageDays !== null && ageDays < 0) reasons.push("FUTURE_DATE");
|
||
|
||
rows.push({
|
||
sector: sector.sector || "",
|
||
proxy_ticker: sector.proxyTicker || "",
|
||
proxy_name: sector.proxyName || "",
|
||
proxy_type: sector.proxyType || "",
|
||
source_kind: sourceKind,
|
||
transport_mode: transportMode,
|
||
source_url: sourceUrl,
|
||
source_asof: sourceAsOf,
|
||
age_days: ageDays === null ? "" : ageDays,
|
||
constituent_count: sectorRows.length,
|
||
stock_count: sectorRows.filter(c => !c.isEtf).length,
|
||
etf_count: sectorRows.filter(c => c.isEtf).length,
|
||
weight_sum: sectorRows.reduce((a, c) => a + (Number(c.weight) || 0), 0),
|
||
status: status,
|
||
refresh_reason: reasons.length ? reasons.join(";") : "OK",
|
||
});
|
||
}
|
||
|
||
rows.sort((a, b) => {
|
||
if (a.status === "CURRENT" && b.status !== "CURRENT") return -1;
|
||
if (a.status !== "CURRENT" && b.status === "CURRENT") return 1;
|
||
return String(a.sector || "").localeCompare(String(b.sector || ""));
|
||
});
|
||
|
||
return {
|
||
formula_id: "sector_universe_refresh_audit_v1",
|
||
gate: (templateCount > 0 || missingSourceUrlCount > 0 || overdueCount > 0 || staleSectorCount > 0) ? "FAIL" : (sheetInputCount > 0 ? "WARN" : "PASS"),
|
||
summary: {
|
||
sector_count: (universe || []).length,
|
||
current_count: currentCount,
|
||
due_count: dueCount,
|
||
overdue_count: overdueCount,
|
||
missing_count: missingCount,
|
||
template_count: templateCount,
|
||
sheet_input_count: sheetInputCount,
|
||
naver_source_count: naverSourceCount,
|
||
layout_changed_count: layoutChangedCount,
|
||
missing_source_url_count: missingSourceUrlCount,
|
||
stale_sector_count: staleSectorCount,
|
||
oldest_source_asof: oldestSourceAsOf ? Utilities.formatDate(oldestSourceAsOf, "Asia/Seoul", "yyyy-MM-dd") : "",
|
||
newest_source_asof: newestSourceAsOf ? Utilities.formatDate(newestSourceAsOf, "Asia/Seoul", "yyyy-MM-dd") : "",
|
||
source_kind_counts: sourceKindCounts,
|
||
transport_mode_counts: transportModeCounts,
|
||
ajax_mode: "NO",
|
||
transport_model: "HTML_SERVER_RENDERED",
|
||
},
|
||
rows: rows,
|
||
};
|
||
}
|
||
|
||
function writeSectorUniverseRefreshAuditSheet_(audit) {
|
||
if (!audit || typeof audit !== "object") return 0;
|
||
const headers = [
|
||
"sector", "proxy_ticker", "proxy_name", "proxy_type", "source_kind", "transport_mode",
|
||
"source_url", "source_asof", "age_days", "constituent_count",
|
||
"stock_count", "etf_count", "weight_sum", "status", "refresh_reason",
|
||
];
|
||
const rows = Array.isArray(audit.rows)
|
||
? audit.rows.map(function(r) {
|
||
return headers.map(function(h) { return r[h] ?? ""; });
|
||
})
|
||
: [];
|
||
writeToSheet("sector_universe_refresh_audit", headers, rows);
|
||
return rows.length;
|
||
}
|
||
|
||
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","Universe_Source","Transport_Mode","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 transportMode = sector.source === "NAVER_ETF_PAGE" ? "HTML_SERVER_RENDERED"
|
||
: (sector.source === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED"
|
||
: (sector.source === "DEFAULT_TEMPLATE" ? "MANUAL_OR_TEMPLATE" : "UNKNOWN"));
|
||
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.source || "DEFAULT_TEMPLATE") === "DEFAULT_TEMPLATE") reasons.push("Universe_Source=DEFAULT_TEMPLATE");
|
||
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 || "대표주",
|
||
universeSource: sector.source || "DEFAULT_TEMPLATE",
|
||
transportMode: transportMode,
|
||
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","Transport_Mode","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 normalizeRow_ = (row) => {
|
||
const outRow = Array.isArray(row) ? row.slice(0, headers.length) : [];
|
||
while (outRow.length < headers.length) outRow.push("");
|
||
return outRow;
|
||
};
|
||
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}`] = normalizeRow_(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}`] = normalizeRow_([
|
||
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.transportMode || "", 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.map(normalizeRow_));
|
||
}
|
||
|
||
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","Universe_Source","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.universeSource, 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: "runSectorUniverseRefreshAudit",
|
||
fn: function() {
|
||
const universe = readSectorUniverse_();
|
||
const audit = calcSectorUniverseRefreshAudit_(universe);
|
||
writeSectorUniverseRefreshAuditSheet_(audit);
|
||
Logger.log("[RUN_ALL] sector_universe_refresh_audit gate=" + audit.gate + " rows=" + (audit.rows || []).length);
|
||
}
|
||
},
|
||
{ name: "runDataFeed", fn: runDataFeed },
|
||
{ name: "runSellPriority", fn: runSellPriority },
|
||
{ 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 배포 여부 확인.");
|
||
}
|
||
}
|
||
},
|
||
{
|
||
name: "updateEvaluationDashboard_",
|
||
fn: function() {
|
||
if (typeof updateEvaluationDashboard_ === "function") {
|
||
updateEvaluationDashboard_();
|
||
} else {
|
||
Logger.log("[WARN] updateEvaluationDashboard_ 미정의 — gdf_04_execution_quality.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);
|
||
}
|
||
|
||
function doPost(e) {
|
||
const payload = parseJsonPostBody_(e);
|
||
const action = String(payload.action || payload.view || "").trim().toLowerCase();
|
||
try {
|
||
if (action === "sync_sector_insights") {
|
||
const result = syncSectorInsightSheets_(payload);
|
||
return ContentService
|
||
.createTextOutput(JSON.stringify(result, null, 2))
|
||
.setMimeType(ContentService.MimeType.JSON);
|
||
}
|
||
if (action === "trigger_run_all") {
|
||
// 외부(Gitea CI) 스케줄러가 run_all()을 원격 트리거할 수 있게 하는 진입점.
|
||
// run_all은 매수/매도 주문을 실행하지 않는다(데이터 갱신·분석 전용) — governance
|
||
// 06/07과 동일한 "조회/분석만, 주문 없음" 원칙을 따른다. 공유 비밀키로 무단 호출 차단.
|
||
const expectedSecret = String(PropertiesService.getScriptProperties().getProperty("RUN_ALL_TRIGGER_SECRET") || "");
|
||
const providedSecret = String(payload.secret || "");
|
||
if (!expectedSecret || providedSecret !== expectedSecret) {
|
||
return ContentService
|
||
.createTextOutput(JSON.stringify({ status: "ERROR", message: "unauthorized" }, null, 2))
|
||
.setMimeType(ContentService.MimeType.JSON);
|
||
}
|
||
const startedAt = new Date().toISOString();
|
||
try {
|
||
run_all();
|
||
return ContentService
|
||
.createTextOutput(JSON.stringify({ status: "OK", started_at: startedAt, finished_at: new Date().toISOString() }, null, 2))
|
||
.setMimeType(ContentService.MimeType.JSON);
|
||
} catch (runErr) {
|
||
return ContentService
|
||
.createTextOutput(JSON.stringify({ status: "ERROR", message: String(runErr && runErr.message ? runErr.message : runErr) }, null, 2))
|
||
.setMimeType(ContentService.MimeType.JSON);
|
||
}
|
||
}
|
||
return ContentService
|
||
.createTextOutput(JSON.stringify({
|
||
status: "ERROR",
|
||
message: `unsupported action: ${action || "missing"}`,
|
||
}, null, 2))
|
||
.setMimeType(ContentService.MimeType.JSON);
|
||
} catch (err) {
|
||
return ContentService
|
||
.createTextOutput(JSON.stringify({
|
||
status: "ERROR",
|
||
message: String(err && err.message ? err.message : err),
|
||
}, null, 2))
|
||
.setMimeType(ContentService.MimeType.JSON);
|
||
}
|
||
}
|
||
|
||
function parseJsonPostBody_(e) {
|
||
try {
|
||
const raw = String(e?.postData?.contents ?? "").trim();
|
||
if (!raw) return {};
|
||
const parsed = JSON.parse(raw);
|
||
return parsed && typeof parsed === "object" ? parsed : {};
|
||
} catch (err) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function rowFromObject_(headers, obj) {
|
||
return headers.map(function(h) {
|
||
const v = obj && Object.prototype.hasOwnProperty.call(obj, h) ? obj[h] : "";
|
||
if (v === null || v === undefined) return "";
|
||
if (typeof v === "object") return JSON.stringify(v);
|
||
return v;
|
||
});
|
||
}
|
||
|
||
function writeSummarySheet_(sheetName, rows) {
|
||
const headers = ["section", "key", "value"];
|
||
const tableRows = (rows || []).map(function(r) {
|
||
return [r.section || "", r.key || "", r.value || ""];
|
||
});
|
||
writeToSheet(sheetName, headers, tableRows);
|
||
return tableRows.length;
|
||
}
|
||
|
||
function writeSectorTrendAnalysisSheet_(analysis) {
|
||
if (!analysis || typeof analysis !== "object") return 0;
|
||
const summary = analysis.summary || {};
|
||
const concentration = analysis.concentration || {};
|
||
const detailHeaders = [
|
||
"sector", "proxy_ticker", "proxy_name", "proxy_type", "etf_code",
|
||
"etf_execution_use", "etf_liquidity_score", "etf_liquidity_status", "etf_nav_risk",
|
||
"proxy_confidence", "rank", "rank_delta_w1", "rank_delta_w2", "sector_score",
|
||
"score_delta", "sector_ret5d", "sector_ret20d", "etf_return_5d", "etf_return_20d",
|
||
"sector_etf_ret_gap_5d", "sector_etf_ret_gap_20d", "smart_money_5d_krw_raw",
|
||
"smart_money_20d_krw_raw", "smart_money_direction", "liquidity_direction",
|
||
"flow_alignment_state", "momentum_state", "concentration_weight_pct"
|
||
];
|
||
const detailRows = Array.isArray(analysis.rows)
|
||
? analysis.rows.map(function(r) { return rowFromObject_(detailHeaders, r); })
|
||
: [];
|
||
writeSummarySheet_("sector_trend_summary", [
|
||
{ section: "summary", key: "formula_id", value: analysis.formula_id || "" },
|
||
{ section: "summary", key: "gate", value: analysis.gate || "" },
|
||
{ section: "summary", key: "latest_snapshot_date", value: analysis.latest_snapshot_date || "" },
|
||
{ section: "summary", key: "previous_snapshot_date", value: analysis.previous_snapshot_date || "" },
|
||
{ section: "summary", key: "sector_count", value: analysis.sector_count || 0 },
|
||
{ section: "summary", key: "trend_posture", value: summary.trend_posture || "" },
|
||
{ section: "summary", key: "rising_count", value: summary.rising_count || 0 },
|
||
{ section: "summary", key: "fading_count", value: summary.fading_count || 0 },
|
||
{ section: "summary", key: "stable_count", value: summary.stable_count || 0 },
|
||
{ section: "summary", key: "etf_proxy_count", value: summary.etf_proxy_count || 0 },
|
||
{ section: "summary", key: "smart_money_inflow_count", value: summary.smart_money_inflow_count || 0 },
|
||
{ section: "summary", key: "smart_money_outflow_count", value: summary.smart_money_outflow_count || 0 },
|
||
{ section: "concentration", key: "top_sector", value: concentration.top_sector || "" },
|
||
{ section: "concentration", key: "top_sector_weight_pct", value: concentration.top_sector_weight_pct || 0 },
|
||
{ section: "concentration", key: "top2_weight_pct", value: concentration.top2_weight_pct || 0 },
|
||
{ section: "concentration", key: "concentration_gate", value: concentration.concentration_gate || "" },
|
||
]);
|
||
writeToSheet("sector_trend_analysis", detailHeaders, detailRows);
|
||
const timelineHeaders = [
|
||
"snapshot_date", "sector_count", "avg_sector_score", "top_sector", "top_sector_score",
|
||
"positive_breadth_count", "liquidity_warn_count", "net_smart_money_5d_krw"
|
||
];
|
||
const timelineRows = Array.isArray(analysis.timeline)
|
||
? analysis.timeline.map(function(r) { return rowFromObject_(timelineHeaders, r); })
|
||
: [];
|
||
writeToSheet("sector_trend_timeline", timelineHeaders, timelineRows);
|
||
return detailRows.length;
|
||
}
|
||
|
||
function writeEtfRepresentativeMonitorSheet_(monitor) {
|
||
if (!monitor || typeof monitor !== "object") return 0;
|
||
const summary = monitor.summary || {};
|
||
const detailHeaders = [
|
||
"sector", "etf_proxy_ticker", "etf_proxy_name", "etf_proxy_type", "sector_rank",
|
||
"sector_score", "sector_smart_money_5d_krw", "sector_ret20d", "representative_count",
|
||
"representative_ticker", "representative_name", "representative_basis",
|
||
"representative_basis_detail", "constituent_weight", "basket_quality_state",
|
||
"basket_coverage_pct", "basket_state", "basket_buy_review_count",
|
||
"basket_track_count", "basket_watch_count", "basket_caution_count",
|
||
"basket_aligned_count", "basket_missing_count", "basket_real_count",
|
||
"selection_source", "selection_score", "monitor_reason", "representatives_json"
|
||
];
|
||
const detailRows = Array.isArray(monitor.rows)
|
||
? monitor.rows.map(function(r) {
|
||
const repJson = Array.isArray(r.representatives) ? JSON.stringify(r.representatives) : "";
|
||
const base = Object.assign({}, r, { representatives_json: repJson });
|
||
return rowFromObject_(detailHeaders, base);
|
||
})
|
||
: [];
|
||
writeSummarySheet_("etf_representative_summary", [
|
||
{ section: "summary", key: "formula_id", value: monitor.formula_id || "" },
|
||
{ section: "summary", key: "gate", value: monitor.gate || "" },
|
||
{ section: "summary", key: "etf_sector_count", value: monitor.etf_sector_count || 0 },
|
||
{ section: "summary", key: "tracked_count", value: monitor.tracked_count || 0 },
|
||
{ section: "summary", key: "buy_review_count", value: summary.buy_review_count || 0 },
|
||
{ section: "summary", key: "track_count", value: summary.track_count || 0 },
|
||
{ section: "summary", key: "watch_count", value: summary.watch_count || 0 },
|
||
{ section: "summary", key: "caution_count", value: summary.caution_count || 0 },
|
||
{ section: "summary", key: "aligned_count", value: summary.aligned_count || 0 },
|
||
{ section: "summary", key: "weighted_basis_count", value: summary.weighted_basis_count || 0 },
|
||
{ section: "summary", key: "fallback_basis_count", value: summary.fallback_basis_count || 0 },
|
||
{ section: "summary", key: "complete_basket_count", value: summary.complete_basket_count || 0 },
|
||
{ section: "summary", key: "partial_basket_count", value: summary.partial_basket_count || 0 },
|
||
{ section: "summary", key: "basket_missing_total", value: summary.basket_missing_total || 0 },
|
||
]);
|
||
writeToSheet("etf_representative_monitor", detailHeaders, detailRows);
|
||
return detailRows.length;
|
||
}
|
||
|
||
function syncSectorInsightSheets_(payload) {
|
||
const trend = payload.sector_trend_analysis || payload.sectorTrendAnalysis || null;
|
||
const etf = payload.etf_representative_monitor || payload.etfRepresentativeMonitor || null;
|
||
const written = {};
|
||
if (trend) written.sector_trend_analysis = writeSectorTrendAnalysisSheet_(trend);
|
||
if (etf) written.etf_representative_monitor = writeEtfRepresentativeMonitorSheet_(etf);
|
||
return {
|
||
status: "OK",
|
||
action: "sync_sector_insights",
|
||
written,
|
||
generated_at: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST",
|
||
};
|
||
}
|
||
|
||
// ── 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,
|
||
},
|
||
};
|
||
}
|
||
|
||
// ── 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 + '%';
|
||
}
|
||
|