([\s\S]*?)<\/span>/i;
-
- let match;
- while ((match = subjectPattern.exec(html)) !== null) {
- const link = match[1].trim();
- const titleRaw = match[2].trim();
-
- // HTML 태그 제거 및 공백 정규화
- const eventName = titleRaw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
- if (eventName.length < 5 || /^\d+$/.test(eventName)) continue;
-
- // 이 매치 직후 최대 1000자 범위 내에서 가장 가까운 wdate 매칭
- const pos = subjectPattern.lastIndex;
- const subHtml = html.substring(pos, pos + 1000);
- const dateMatch = wdatePattern.exec(subHtml);
-
- if (dateMatch) {
- const dateStrRaw = dateMatch[1].trim();
- const dateOnly = dateStrRaw.split(' ')[0]; // YYYY-MM-DD
- const eventDate = coerceDate_(dateOnly);
-
- if (eventDate && eventName) {
- const type = guessEventType_(eventName, region);
- events.push({
- Date: eventDate,
- Event: eventName,
- Type: type,
- Impact: guessImpact_(type, eventName),
- Alert: '',
- Source: sourceName,
- SourceUrl: 'https://finance.naver.com' + link,
- });
- }
- }
- }
-
- return events;
-}
-
-
-/* ── 외부 URL 소스 (기존 유지) ───────────────────────────────────────────── */
-
-function refreshFromSources() {
- const props = PropertiesService.getScriptProperties();
- const jsonUrl = props.getProperty(CFG.JSON_SOURCE_PROPERTY);
- const csvUrl = props.getProperty(CFG.CSV_SOURCE_PROPERTY);
- if (!jsonUrl && !csvUrl) { toast_('외부 URL 없음 — Script Properties 확인', 4); return; }
-
- const events = [];
- if (jsonUrl) events.push(...fetchEventsFromJson_(jsonUrl));
- if (csvUrl) events.push(...fetchEventsFromCsv_(csvUrl));
- if (!events.length) { toast_('외부 소스 이벤트 없음', 3); return; }
-
- upsertEvents_(events);
- validateAndSort();
- toast_(`외부 이벤트 갱신: ${events.length}건`, 5);
-}
-
-function fetchEventsFromJson_(url) {
- const text = fetchWithCache_(url);
- if (!text) return [];
- const parsed = JSON.parse(text);
- if (!Array.isArray(parsed)) throw new Error('JSON source는 배열이어야 합니다.');
- return parsed.map(normalizeEvent_);
-}
-
-function fetchEventsFromCsv_(url) {
- const text = fetchWithCache_(url);
- if (!text) return [];
- const csv = Utilities.parseCsv(text);
- if (csv.length < 2) return [];
- const headers = csv[0].map(h => String(h || '').trim());
- return csv.slice(1).map(row => {
- const obj = {};
- headers.forEach((h, i) => { obj[h] = row[i]; });
- return normalizeEvent_(obj);
- });
-}
-
-
-/* ═══════════════════════════════════════════════════════════════════════════ *
- * fetchWithCache_ — CacheService + 재시도 + stale fallback
- *
- * signature: fetchWithCache_(url, ttlSec?, extraHeaders?, encoding?)
- * - ttlSec: 캐시 유효기간 (기본 CFG.CACHE_TTL_SEC)
- * - extraHeaders: 추가 HTTP 헤더 (스크래핑 시 UA/Referer 주입용)
- * - encoding: 응답 문자셋 인코딩 (기본 'UTF-8')
- * ══════════════════════════════════════════════════════════════════════════ */
-function fetchWithCache_(url, ttlSec, extraHeaders, encoding) {
- const cache = CacheService.getScriptCache();
- const cacheKey = 'url:' + md5_(url);
-
- // 1. Cache HIT
- const hit = cache.get(cacheKey);
- if (hit !== null) return hit;
-
- // 2. Fetch with retry
- const opts = {
- muteHttpExceptions: true,
- followRedirects: true,
- headers: Object.assign({ 'User-Agent': CFG.CHROME_UA }, extraHeaders || {}),
- };
-
- const charset = encoding || 'UTF-8';
-
- for (let attempt = 0; attempt <= CFG.MAX_RETRIES; attempt++) {
- if (attempt > 0) Utilities.sleep(CFG.RETRY_BASE_MS * attempt);
- let resp;
- try { resp = UrlFetchApp.fetch(url, opts); }
- catch(e) { Logger.log(`[fetch] 예외 (${attempt}): ${e.message}`); continue; }
-
- const code = resp.getResponseCode();
- if (code === 429 || code === 503) { Utilities.sleep(2500 * (attempt+1)); continue; } // 일시 블록
- if (code === 403 || code === 401) { Logger.log(`[fetch] ${code} 영구 블록: ${url}`); break; }
- if (code < 200 || code >= 300) { Logger.log(`[fetch] HTTP ${code} (${attempt}): ${url}`); continue; }
-
- const text = resp.getContentText(charset);
- try {
- cache.put(cacheKey, text, ttlSec || CFG.CACHE_TTL_SEC);
- } catch(e) {
- // 100KB 초과 HTML은 캐싱 크기 제한으로 실패하는 것이 정상이므로 로그 남기지 않고 패스
- }
- return text;
- }
-
- Logger.log(`[fetch] 실패: ${url}`);
- return null;
-}
-
-
-/* ── Upsert / Sample ─────────────────────────────────────────────────────── */
-
-function upsertEvents_(events) {
- if (!events.length) return;
- const sheet = ensureSheetAndHeaders_();
- const hmap = getHeaderMap_(sheet);
- const rowByKey = {};
- getDataObjects_(sheet, hmap).forEach(item => { if (item.Key) rowByKey[item.Key] = item.__row; });
-
- events.forEach(ev => {
- const d = coerceDate_(ev.Date);
- if (!d || !ev.Event) return;
- const type = String(ev.Type || 'CUSTOM').toUpperCase();
- const key = ev.Key || buildKey_(d, ev.Event, type);
- const vals = { Date:d, Event:ev.Event, Type:type, Impact:String(ev.Impact||'MEDIUM').toUpperCase(),
- Alert:ev.Alert||'', Source:ev.Source||'', SourceUrl:ev.SourceUrl||'', Key:key };
-
- if (rowByKey[key]) {
- Object.keys(vals).forEach(h => { if (hmap[h]) sheet.getRange(rowByKey[key], hmap[h]).setValue(vals[h]); });
- } else {
- const row = new Array(sheet.getLastColumn()).fill('');
- Object.keys(vals).forEach(h => { if (hmap[h]) row[hmap[h]-1] = vals[h]; });
- sheet.appendRow(row);
- }
- });
-}
-
-function loadSampleDataIfEmpty() {
- const sheet = ensureSheetAndHeaders_();
- if (sheet.getLastRow() > 1) { toast_('이미 데이터 있음 — 삽입 생략', 4); return; }
- sheet.getRange(2,1,6,5).setValues([
- ['2026-06-17','FOMC 금리결정','FOMC','HIGH','금리동결 시 KOSPI +1~2% 기대'],
- ['2026-07-28','FOMC 금리결정','FOMC','HIGH',''],
- ['2026-06-11','미국 CPI (5월)','US_CPI','HIGH','예상치 상회 시 당일 신규매수 자제'],
- ['2026-07-15','미국 CPI (6월)','US_CPI','HIGH','FOMC 전 마지막 CPI'],
- ['2026-06-20','삼성전자 1Q 잠정실적','EARNINGS','HIGH','반도체 섹터 선행 지표'],
- ['2026-06-15','옵션만기일','EXPIRY','MEDIUM','변동성 확대 구간 주의'],
- ]);
- validateAndSort();
-}
-
-
-/* ── 트리거 ──────────────────────────────────────────────────────────────── */
-
-function createDailyTrigger() {
- const fn = 'runDaily';
- ScriptApp.getProjectTriggers().filter(t => t.getHandlerFunction() === fn).forEach(t => ScriptApp.deleteTrigger(t));
- ScriptApp.newTrigger(fn).timeBased().everyDays(1).atHour(8).create();
- toast_('매일 오전 8시 트리거 설치 완료', 4);
-}
-
-function deleteProjectTriggers() {
- ScriptApp.getProjectTriggers().forEach(t => ScriptApp.deleteTrigger(t));
- toast_('트리거 삭제 완료', 4);
-}
-
-function setJsonSourceUrl() { _saveUrlProp_(CFG.JSON_SOURCE_PROPERTY, 'EVENT_JSON_URL'); }
-function setCsvSourceUrl() { _saveUrlProp_(CFG.CSV_SOURCE_PROPERTY, 'EVENT_CSV_URL'); }
-function _saveUrlProp_(k, label) {
- const v = Browser.inputBox(label + '를 입력하세요.');
- if (v && v !== 'cancel') PropertiesService.getScriptProperties().setProperty(k, v);
-}
-
-
-/* ── 이벤트 타입·임팩트 추론 헬퍼 ───────────────────────────────────────── */
-
-/**
- * 이벤트 이름으로 타입을 추론.
- * region: 'US' | 'KR' (기본 'US')
- */
-const TYPE_MAP_ = [
- { keys: ['FOMC','연준','Federal Open Market','Fed Rate'], type: 'FOMC' },
- { keys: ['CPI','소비자물가','Consumer Price'], type: null }, // region 분기
- { keys: ['PPI','생산자물가','Producer Price'], type: 'US_PPI' },
- { keys: ['PCE','개인소비지출','Personal Consumption'], type: 'US_PCE' },
- { keys: ['NFP','비농업','Nonfarm','Payroll'], type: 'US_NFP' },
- { keys: ['실적','잠정실적','Earnings','EPS','Revenue'], type: 'EARNINGS' },
- { keys: ['옵션만기','선물만기','만기일','Expiry','Triple Witching'], type: 'EXPIRY' },
- { keys: ['한국은행','금통위','BOK','Bank of Korea'], type: 'BOK' },
- { keys: ['환율','FX','Dollar','달러'], type: 'FX' },
- { keys: ['국채','채권','Bond','Treasury'], type: 'BOND' },
- { keys: ['BOJ','일본은행','Bank of Japan','BOJ Rate','BOJ Interest'], type: 'BOJ' },
-];
-
-function guessEventType_(eventName, region) {
- const upper = String(eventName || '').toUpperCase();
- const reg = String(region || '').toUpperCase().trim();
-
- for (const rule of TYPE_MAP_) {
- if (rule.keys.some(k => upper.includes(k.toUpperCase()))) {
- if (rule.type === null) {
- // CPI 분기: 한국 CPI vs 미국 CPI (타국 CPI는 CUSTOM 처리하여 오인 방지)
- if (reg === 'KR' || upper.includes('한국') || upper.includes('KR')) return 'KR_CPI';
- if (reg === 'US' || upper.includes('미국') || upper.includes('US')) return 'US_CPI';
- return 'CUSTOM';
- }
-
- // PPI, PCE, NFP, FOMC 등 미국 전용 타입들은 국가 코드가 US인 경우에만 해당 타입 할당, 타국은 CUSTOM 처리
- const usOnlyTypes = ['US_PPI', 'US_PCE', 'US_NFP', 'FOMC'];
- if (usOnlyTypes.includes(rule.type) && reg !== 'US' && reg !== '') {
- return 'CUSTOM';
- }
-
- // BOJ 일본은행 전용 타입은 국가 코드가 JP인 경우에만 해당 타입 할당, 타국은 CUSTOM 처리
- if (rule.type === 'BOJ' && reg !== 'JP' && reg !== '') {
- return 'CUSTOM';
- }
-
- return rule.type;
- }
- }
- return 'CUSTOM';
-}
-
-/** 타입 기반 기본 임팩트 */
-function guessImpact_(type, eventName) {
- const highTypes = ['FOMC','US_CPI','US_NFP','BOK','KR_CPI','BOJ'];
- const medTypes = ['US_PPI','US_PCE','EARNINGS','EXPIRY'];
- if (highTypes.includes(type)) return 'HIGH';
- if (medTypes.includes(type)) return 'MEDIUM';
- return 'LOW';
-}
-
-
-/* ── 내부 헬퍼 (compact) ─────────────────────────────────────────────────── */
-
-function safeGet_(obj, keys) {
- return keys.reduce((o, k) => (o && o[k] !== undefined ? o[k] : null), obj);
-}
-
-function getSpreadsheet_() {
- return CFG.SPREADSHEET_ID ? SpreadsheetApp.openById(CFG.SPREADSHEET_ID) : SpreadsheetApp.getActiveSpreadsheet();
-}
-
-function ensureSheetAndHeaders_() {
- const ss = getSpreadsheet_();
- const sheet = ss.getSheetByName(CFG.SHEET_NAME) || ss.insertSheet(CFG.SHEET_NAME);
- const lastCol = Math.max(sheet.getLastColumn(), 1);
- const existing = sheet.getRange(1,1,1,lastCol).getValues()[0].map(h => String(h||'').trim());
- if (!existing.some(Boolean)) {
- sheet.getRange(1,1,1,CFG.ALL_HEADERS.length).setValues([CFG.ALL_HEADERS]);
- return sheet;
- }
- const missing = CFG.ALL_HEADERS.filter(h => !existing.includes(h));
- if (missing.length) sheet.getRange(1, sheet.getLastColumn()+1, 1, missing.length).setValues([missing]);
- const hmap = getHeaderMap_(sheet);
- CFG.REQUIRED_HEADERS.forEach(h => { if (!hmap[h]) throw new Error(`필수 헤더 없음: ${h}`); });
- return sheet;
-}
-
-function getHeaderMap_(sheet) {
- const map = {};
- sheet.getRange(1,1,1,sheet.getLastColumn()).getValues()[0]
- .forEach((h,i) => { const k=String(h||'').trim(); if(k) map[k]=i+1; });
- return map;
-}
-
-function getDataObjects_(sheet, hmap) {
- const lastRow = sheet.getLastRow();
- if (lastRow < 2) return [];
- const headers = Object.keys(hmap);
- const lastCol = sheet.getLastColumn();
- return sheet.getRange(2,1,lastRow-1,lastCol).getValues().map((row,r) => {
- const obj = { __row: r+2 };
- headers.forEach(h => { obj[h] = row[hmap[h]-1]; });
- return obj;
- });
-}
-
-function normalizeEvent_(obj) {
- return {
- Date: obj.Date || obj.date,
- Event: obj.Event || obj.event || obj.title || obj.name,
- Type: obj.Type || obj.type || 'CUSTOM',
- Impact: obj.Impact || obj.impact || 'MEDIUM',
- Alert: obj.Alert || obj.alert || '',
- Source: obj.Source || obj.source || '',
- SourceUrl: obj.SourceUrl || obj.sourceUrl || obj.url || '',
- Key: obj.Key || obj.key || '',
- };
-}
-
-function coerceDate_(v) {
- if (v instanceof Date && !isNaN(v)) return new Date(v.getFullYear(), v.getMonth(), v.getDate());
- if (typeof v === 'string') {
- const m = v.trim().match(/^(\d{4})[-./](\d{1,2})[-./](\d{1,2})/);
- if (m) return new Date(+m[1], +m[2]-1, +m[3]);
- }
- return null;
-}
-
-function todayKst_() {
- return coerceDate_(Utilities.formatDate(new Date(), CFG.TIME_ZONE, CFG.DATE_FORMAT));
-}
-
-function daysBetween_(a, b) {
- return Math.round(
- (new Date(b.getFullYear(),b.getMonth(),b.getDate()) -
- new Date(a.getFullYear(),a.getMonth(),a.getDate())) / 86400000
- );
-}
-
-function buildKey_(dateObj, eventName, type) {
- return md5_([Utilities.formatDate(dateObj,CFG.TIME_ZONE,CFG.DATE_FORMAT),
- String(type||'').toUpperCase(), String(eventName||'').trim()].join('|'));
-}
-
-function md5_(text) {
- return Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, text, Utilities.Charset.UTF_8)
- .map(b => ('0'+(b<0?b+256:b).toString(16)).slice(-2)).join('');
-}
-
-function buildEmailBody_(events) {
- const fmt = d => d instanceof Date ? Utilities.formatDate(d,CFG.TIME_ZONE,CFG.DATE_FORMAT) : String(d);
- return [
- '시장 이벤트 임박 알림','',
- '기준: '+Utilities.formatDate(new Date(),CFG.TIME_ZONE,'yyyy-MM-dd HH:mm:ss'),'',
- ...events.flatMap((item,i) => [
- `${i+1}. [${item.Impact}] ${fmt(item.Date)} / D-${item.DaysLeft}`,
- ` Event: ${item.Event}`, ` Type: ${item.Type}`,
- ...(item.Alert?[` Alert: ${item.Alert}`]:[]),'',
- ]),
- '이 알림은 자동 알림이며 투자 판단의 최종 근거가 아닙니다.',
- ].join('\n');
-}
-
-function applyFormatting_(sheet, hmap) {
- const lastRow = Math.max(sheet.getLastRow(),1), lastCol = Math.max(sheet.getLastColumn(),1);
- sheet.getRange(1,1,1,lastCol).setFontWeight('bold');
- sheet.setFrozenRows(1);
- for (let c=1;c<=lastCol;c++) sheet.autoResizeColumn(c);
- if (lastRow >= 2) {
- if (hmap.Impact) sheet.getRange(2,hmap.Impact, lastRow-1,1).setFontWeight('bold');
- if (hmap.DaysLeft) sheet.getRange(2,hmap.DaysLeft,lastRow-1,1).setNumberFormat('0');
- }
-}
-
-function toast_(msg, sec) {
- try {
- const activeSs = SpreadsheetApp.getActive();
- if (activeSs) {
- activeSs.toast(msg, 'Market Calendar', sec);
- } else {
- Logger.log('[TOAST] ' + msg);
- }
- } catch (e) {
- Logger.log('[TOAST] ' + msg);
- }
-}
-
-/**
- * 용량을 극도로 많이 소모하는 Script Properties의 캐시성 데이터(stale_url, cal_parsed 등)를 청소.
- * SPREADSHEET_ID 나 sf_w2_ranks_json 같은 중요 설정/운영 데이터는 보호합니다.
- */
-function cleanUpProperties() {
- const props = PropertiesService.getScriptProperties();
- const keys = props.getKeys();
- let deleteCount = 0;
-
- // SPREADSHEET_ID, sf_w2_ranks_json, EVENT_JSON_URL, EVENT_CSV_URL, HARNESS_VERBOSE_LOG 등 설정은 제외
- const protectedKeys = ['SPREADSHEET_ID', 'sf_w2_ranks_json', 'EVENT_JSON_URL', 'EVENT_CSV_URL', 'HARNESS_VERBOSE_LOG'];
-
- keys.forEach(k => {
- if (protectedKeys.includes(k)) {
- return;
- }
-
- // 캐시 관련 접두사를 가진 항목 및 임시 런타임 상태값 삭제
- const shouldDelete =
- k.indexOf('stale_url:') === 0 ||
- k.indexOf('yahoo_cal_parsed:') === 0 ||
- k.indexOf('te_cal_parsed:') === 0 ||
- k.indexOf('url:') === 0 ||
- k.indexOf('fetch_budget_') === 0 ||
- k.indexOf('fetch_fail_') === 0 ||
- k.indexOf('fetch_circuit_') === 0 ||
- k.indexOf('fetch_session_') === 0 ||
- k.indexOf('cs_') === 0;
-
- if (shouldDelete) {
- props.deleteProperty(k);
- deleteCount++;
- }
- });
-
- toast_(`프로퍼티 캐시 청소 완료: ${deleteCount}건 삭제`, 5);
-}
diff --git a/gas_harness_rows.gs b/gas_harness_rows.gs
deleted file mode 100644
index 5aa9be5..0000000
--- a/gas_harness_rows.gs
+++ /dev/null
@@ -1,1456 +0,0 @@
-// gas_harness_rows.gs - Harness output serialization
-// buildHarnessRows_, assertHarnessRowsComplete_, checksum functions
-// Pure output assembly - no decision logic. Rarely changes after V stabilizes.
-// GAS global scope: functions in gas_data_feed.gs callable directly
-
-
-/**
- * computeBlueprintChecksum_
- * order_blueprint_json의 위변조 탐지용 체크섬 (CRC32_V1).
- * ticker + order_type + quantity + limit_price_krw + validation_status 를
- * 행 순서대로 연결한 문자열의 char-code sum을 반환한다.
- * Python converter는 이 값과 자신이 재계산한 값이 다르면 HARNESS_INTEGRITY_FAIL 처리.
- */
-function computeBlueprintChecksum_(blueprint) {
- var s = '';
- blueprint = blueprint || [];
- for (var i = 0; i < blueprint.length; i++) {
- var r = blueprint[i];
- s += String(r.ticker || '') + '|'
- + String(r.order_type || '') + '|'
- + String(r.quantity != null ? r.quantity : '') + '|'
- + String(r.limit_price_krw != null ? r.limit_price_krw : '') + '|'
- + String(r.validation_status || '') + ';';
- }
- var sum = 0;
- for (var j = 0; j < s.length; j++) {
- sum = (sum + s.charCodeAt(j)) & 0xFFFFFFFF;
- }
- return sum;
-}
-
-
-/**
- * [2026-05-20_HARNESS_V5] computeInputSnapshotChecksum_
- * 계좌 스냅샷 원장(보유수량·평단·종가·현금·기준시각)의 CRC32_V1 해시.
- * 동일 입력 재호출 시 이 값이 달라지면 데이터 소스가 갱신된 것이다.
- * Python 검증기가 이전 실행값과 비교하여 non_deterministic_flag 를 set 한다.
- */
-function computeInputSnapshotChecksum_(asResult, capturedAtIso) {
- var s = String(capturedAtIso || '') + '|'
- + String((asResult || {}).settlementCashD2Krw != null
- ? asResult.settlementCashD2Krw : '') + '|';
- ((asResult || {}).holdings || []).forEach(function(h) {
- s += String(h.ticker || '') + '|'
- + String(h.holdingQty != null ? h.holdingQty : '') + '|'
- + String(h.avgCost != null ? h.avgCost : '') + '|'
- + String(h.close != null ? h.close : '') + ';';
- });
- var sum = 0;
- for (var i = 0; i < s.length; i++) {
- sum = (sum + s.charCodeAt(i)) & 0xFFFFFFFF;
- }
- return sum;
-}
-
-
-/**
- * I3: computeStringChecksum_
- * 임의 문자열의 char-code sum 체크섬 (CRC32_V1 방식).
- * source_manifest_json, decision_trace_json 등에 사용.
- */
-function computeStringChecksum_(str) {
- var s = typeof str === 'string' ? str : JSON.stringify(str);
- if (s === undefined || s === null) s = '';
- var sum = 0;
- for (var i = 0; i < s.length; i++) {
- sum = (sum + s.charCodeAt(i)) & 0xFFFFFFFF;
- }
- return sum;
-}
-
-
-// ── 출력 행 빌더 ─────────────────────────────────────────────────────────────
-
-function buildHarnessRows_(
- now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo, heatGate, heatThresholds, mrsScore,
- asResult, dfMap, settlementCashPct, totalHeatPct, buyPowerKrw, totalAsset, actions,
- performance, h2, h3, h4, h5, orderBlueprint, hAlpha, regimeTrimGuidance,
- cashShortfallInfo, hApex, sectorMomentumRows,
- drawdownGuard, portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows,
- regimeSizeScale, regimeCashMinPct, stopAdequacyRows, staleRows,
- singlePositionWeightCap, semiconductorClusterGate, portfolioDrawdownGate,
- winLossStreakGuard, positionCountLimit,
- stopBreachAlert, tpTriggerAlert, heatConcentrationAlert,
- regimeTransitionAlert, portfolioHealthScore
-) {
- var sourceManifest = [
- { name: 'GatherTradingData.json', type: 'JSON', status: 'PENDING_EXPORT' },
- { name: 'data_feed', type: 'GOOGLE_SHEETS', status: 'OK' },
- { name: 'sector_flow', type: 'GOOGLE_SHEETS', status: 'OK' },
- { name: 'macro', type: 'GOOGLE_SHEETS', status: 'OK' },
- { name: 'event_risk', type: 'GOOGLE_SHEETS', status: 'OK' },
- { name: 'account_snapshot', type: 'GOOGLE_SHEETS', status: 'OK' },
- { name: 'backdata_feature_bank', type: 'GOOGLE_SHEETS', status: 'OK' },
- { name: 'harness_context', type: 'GOOGLE_SHEETS', status: 'OK' }
- ];
-
- // ── G1: CASH_SHORTFALL_V1 사전 계산 ─────────────────────────────────────
- // LLM이 "약 N원 필요" 즉석 계산 금지 — GAS 결정론적 산출 후 잠금
- var g1TargetCashPct = cashShortfallInfo.cash_target_pct;
- var g1ShortfallMin = cashShortfallInfo.cash_shortfall_min_krw;
- var g1ShortfallTgt = cashShortfallInfo.cash_shortfall_target_krw;
- var g1CashCurrentPct = cashShortfallInfo.cash_current_pct_d2;
-
- // ── G2: TRIM_PLAN_MIN_CASH_V1 사전 계산 ──────────────────────────────────
- // 현금 회복용 종목별 TRIM 계획 — LLM 즉석 선택 금지, GAS 우선순위 기반 확정
- var g2SellQtyMap = {};
- h3.sellQty.forEach(function(sq) { g2SellQtyMap[sq.ticker] = sq; });
- var g2CloseMap = {};
- asResult.holdings.forEach(function(h) {
- var df = dfMap[h.ticker] || {};
- g2CloseMap[h.ticker] = h.close || df.close || 0;
- });
- var g2TrimPlan = [];
- var g2Accum = 0;
- var g2Shortfall = g1ShortfallMin;
- h2.candidates.forEach(function(cand) {
- var sqRow = g2SellQtyMap[cand.ticker] || {};
- var sellQty = sqRow.sell_qty;
- var close = g2CloseMap[cand.ticker] || 0;
- var estKrw = 0;
- if (typeof sellQty === 'number' && sellQty > 0 && close > 0) {
- estKrw = Math.round(sellQty * close);
- }
- g2Accum += estKrw;
- g2TrimPlan.push({
- rank: cand.rank,
- ticker: cand.ticker,
- name: cand.name || '',
- tier: cand.tier,
- sell_qty: typeof sellQty === 'number' ? sellQty : (sellQty || null),
- estimated_sell_krw: estKrw,
- accumulated_krw: g2Accum,
- covers_shortfall: g2Shortfall > 0 ? g2Accum >= g2Shortfall : true
- });
- });
-
- // ── M4: 5억원 목표 자산 추적 사전 계산 ────────────────────────────────────
- var M4_GOAL_KRW = 500000000;
- var m4Asset = Number.isFinite(totalAsset) ? totalAsset : 0;
- var m4Achieve = m4Asset > 0 ? Math.round(m4Asset / M4_GOAL_KRW * 1000) / 10 : 0;
- var m4Remain = Math.max(0, M4_GOAL_KRW - m4Asset);
- var m4NetExp30 = (performance && Number.isFinite(performance.net_expectancy_30))
- ? performance.net_expectancy_30 : null;
- var m4EtaMonths = null;
- var m4EtaLabel = 'DATA_MISSING';
- if (m4Asset >= M4_GOAL_KRW) {
- m4EtaMonths = 0;
- m4EtaLabel = 'ACHIEVED';
- } else if (m4Asset > 0 && m4NetExp30 !== null && m4NetExp30 > 0) {
- m4EtaMonths = Math.ceil(Math.log(M4_GOAL_KRW / m4Asset) / Math.log(1 + m4NetExp30 / 100));
- var m4EtaDate = new Date(now.getTime());
- m4EtaDate.setMonth(m4EtaDate.getMonth() + m4EtaMonths);
- m4EtaLabel = m4EtaDate.getFullYear() + '-'
- + String(m4EtaDate.getMonth() + 1).padStart(2, '0');
- }
-
- // ── P6: 사용자 판단용 제안표 확정값 (PROPOSAL_REFERENCE_V1) ────────────────
- // 보고서가 WATCH/BLOCKED 행을 복원 추론하지 않도록 하네스가 제안 레이어를 직접 잠금
- var p6PriceMap = {};
- (h4.prices || []).forEach(function(row) { p6PriceMap[row.ticker] = row; });
- var p6SellQtyMap = {};
- (h3.sellQty || []).forEach(function(row) { p6SellQtyMap[row.ticker] = row; });
- var p6BuyQtyMap = {};
- (h3.buyQtyInputs || []).forEach(function(row) { p6BuyQtyMap[row.ticker] = row; });
- var p6DecisionMap = {};
- (h5.decisions || []).forEach(function(row) { p6DecisionMap[row.ticker] = row; });
- var p6BlueprintMap = {};
- (orderBlueprint || []).forEach(function(row) { p6BlueprintMap[row.ticker] = row; });
- var p6TpLadderMap = {};
- (tpLadderRows || []).forEach(function(row) { p6TpLadderMap[row.ticker] = row; });
- var p6ProfitMap = {};
- (((hApex || {}).profit_preservation_json) || []).forEach(function(row) { p6ProfitMap[row.ticker] = row; });
- var p6BuyPermissionMap = {};
- (((hApex || {}).buy_permission_json) || []).forEach(function(row) { p6BuyPermissionMap[row.ticker] = row; });
- var p6AlphaLeadMap = {};
- (((hApex || {}).alpha_lead_json) || []).forEach(function(row) { p6AlphaLeadMap[row.ticker] = row; });
- var p6SellRankMap = {};
- var p6Candidates_ = (h2 && h2.candidates) ? h2.candidates : [];
- for (var sr = 0; sr < p6Candidates_.length; sr++) {
- p6SellRankMap[p6Candidates_[sr].ticker] = p6Candidates_[sr].rank;
- }
- var p6Tickers = {};
- Object.keys(p6PriceMap).forEach(function(t) { p6Tickers[t] = true; });
- Object.keys(p6SellQtyMap).forEach(function(t) { p6Tickers[t] = true; });
- Object.keys(p6BuyQtyMap).forEach(function(t) { p6Tickers[t] = true; });
- Object.keys(p6DecisionMap).forEach(function(t) { p6Tickers[t] = true; });
- Object.keys(p6BlueprintMap).forEach(function(t) { p6Tickers[t] = true; });
- var p6Rows = [];
- Object.keys(p6Tickers).sort(function(a, b) {
- var ra = p6SellRankMap[a] != null ? p6SellRankMap[a] : 9999;
- var rb = p6SellRankMap[b] != null ? p6SellRankMap[b] : 9999;
- var da = p6DecisionMap[a] || {};
- var db = p6DecisionMap[b] || {};
- var oa = p6BlueprintMap[a] || {};
- var ob = p6BlueprintMap[b] || {};
- var actionA = String(da.final_action || oa.order_type || 'WATCH').toUpperCase();
- var actionB = String(db.final_action || ob.order_type || 'WATCH').toUpperCase();
- function bucket_(action) {
- if (action.indexOf('SELL') >= 0 || action.indexOf('TRIM') >= 0 || action.indexOf('EXIT') >= 0 || action.indexOf('STOP_LOSS') >= 0 || action.indexOf('TAKE_PROFIT') >= 0 || action.indexOf('TRAILING_STOP') >= 0) return 0;
- if (action.indexOf('BUY') >= 0 || action.indexOf('ADD_ON') >= 0 || action.indexOf('PILOT') >= 0 || action.indexOf('STAGED') >= 0) return 1;
- if (action.indexOf('WATCH') >= 0 || action.indexOf('HOLD') >= 0) return 2;
- return 3;
- }
- var ba = bucket_(actionA);
- var bb = bucket_(actionB);
- if (ba !== bb) return ba - bb;
- if (ra !== rb) return ra - rb;
- if (ba === 1 || ba === 2) {
- var buyStateOrder_ = { ALLOW_ADD_ON: 0, ALLOW_PILOT: 1, WATCH: 2, BLOCKED: 3 };
- var buyA = p6BuyPermissionMap[a] || {};
- var buyB = p6BuyPermissionMap[b] || {};
- var alphaA = p6AlphaLeadMap[a] || {};
- var alphaB = p6AlphaLeadMap[b] || {};
- var sa = buyStateOrder_[String(buyA.buy_permission_state || '').toUpperCase()] || 99;
- var sb = buyStateOrder_[String(buyB.buy_permission_state || '').toUpperCase()] || 99;
- if (sa !== sb) return sa - sb;
- var aa = -(typeof alphaA.alpha_lead_score === 'number' ? alphaA.alpha_lead_score : 0);
- var ab = -(typeof alphaB.alpha_lead_score === 'number' ? alphaB.alpha_lead_score : 0);
- if (aa !== ab) return aa - ab;
- }
- return a < b ? -1 : (a > b ? 1 : 0);
- }).forEach(function(ticker) {
- var p = p6PriceMap[ticker] || {};
- var s = p6SellQtyMap[ticker] || {};
- var b = p6BuyQtyMap[ticker] || {};
- var d = p6DecisionMap[ticker] || {};
- var o = p6BlueprintMap[ticker] || {};
- var t = p6TpLadderMap[ticker] || {};
- var pp = p6ProfitMap[ticker] || {};
- var finalAction = String(d.final_action || o.order_type || 'WATCH').toUpperCase();
- var orderType = String(o.order_type || '').toUpperCase();
- var priorityGroup = 3;
- var priorityRank = 9999;
- if (finalAction.indexOf('SELL') >= 0 || finalAction.indexOf('TRIM') >= 0 || finalAction.indexOf('EXIT') >= 0 || finalAction.indexOf('STOP_LOSS') >= 0 || finalAction.indexOf('TAKE_PROFIT') >= 0 || finalAction.indexOf('TRAILING_STOP') >= 0) {
- priorityGroup = 0;
- priorityRank = p6SellRankMap[ticker] != null ? p6SellRankMap[ticker] : priorityRank;
- } else if (finalAction.indexOf('BUY') >= 0 || finalAction.indexOf('ADD_ON') >= 0 || finalAction.indexOf('PILOT') >= 0 || finalAction.indexOf('STAGED') >= 0) {
- priorityGroup = 1;
- var bp = p6BuyPermissionMap[ticker] || {};
- var ap = p6AlphaLeadMap[ticker] || {};
- var buyStateOrder_ = { ALLOW_ADD_ON: 0, ALLOW_PILOT: 1, WATCH: 2, BLOCKED: 3 };
- priorityRank = 10000 + ((buyStateOrder_[String(bp.buy_permission_state || '').toUpperCase()] || 99) * 1000)
- + (100 - (typeof ap.alpha_lead_score === 'number' ? ap.alpha_lead_score : 0));
- } else if (finalAction.indexOf('WATCH') >= 0 || finalAction.indexOf('HOLD') >= 0) {
- priorityGroup = 2;
- var hp = p6BuyPermissionMap[ticker] || {};
- var ha = p6AlphaLeadMap[ticker] || {};
- var holdStateOrder_ = { ALLOW_ADD_ON: 0, ALLOW_PILOT: 1, WATCH: 2, BLOCKED: 3 };
- priorityRank = 20000 + ((holdStateOrder_[String(hp.buy_permission_state || '').toUpperCase()] || 99) * 1000)
- + (100 - (typeof ha.alpha_lead_score === 'number' ? ha.alpha_lead_score : 0));
- }
- var proposalType = '관찰 제안';
- var priceBasis = '하네스 기준 참고가';
- var qtyBasis = '수량 입력 없음';
- var proposedLimit = null;
- var proposedTp = p.tp1_price || p.tp2_price || null;
- var proposedQty = null;
- if (finalAction.indexOf('BUY') >= 0 || orderType.indexOf('BUY') >= 0 || b.final_qty != null) {
- proposalType = '매수 제안';
- proposedLimit = o.limit_price_krw != null ? o.limit_price_krw : (b.entry_price_hint || null);
- priceBasis = '매수 제안가 우선';
- proposedQty = b.final_qty != null ? b.final_qty : null;
- qtyBasis = '매수 수량 우선';
- } else if (finalAction.indexOf('TAKE_PROFIT') >= 0 || orderType.indexOf('TAKE_PROFIT') >= 0) {
- proposalType = '익절 제안';
- proposedLimit = p.tp1_price || p.tp2_price || null;
- priceBasis = '익절가 우선';
- proposedQty = s.sell_qty != null ? s.sell_qty : null;
- qtyBasis = '매도 수량 우선';
- } else if (s.sell_qty != null || ['SELL_READY', 'SELL', 'TRIM', 'EXIT_100', 'EXIT_FULL'].indexOf(finalAction) >= 0) {
- proposalType = (finalAction === 'WATCH' || finalAction === 'HOLD') ? '관찰 제안' : '매도 제안';
- proposedLimit = p.stop_price != null ? p.stop_price : null;
- priceBasis = (finalAction === 'WATCH' || finalAction === 'HOLD') ? '주문가 아님: 참고 방어가' : '방어가 우선';
- proposedQty = s.sell_qty != null ? s.sell_qty : null;
- qtyBasis = (finalAction === 'WATCH' || finalAction === 'HOLD') ? '주문 수량 아님: 참고 수량' : '매도 수량 우선';
- } else if (finalAction === 'WATCH' || finalAction === 'HOLD' || orderType === 'WATCH') {
- proposalType = '관찰 제안';
- proposedLimit = p.stop_price != null ? p.stop_price : null;
- priceBasis = '주문가 아님: 참고 방어가';
- proposedQty = s.sell_qty != null ? s.sell_qty : null;
- qtyBasis = '주문 수량 아님: 참고 수량';
- }
- if (proposedLimit == null && proposedQty == null && p.stop_price == null && proposedTp == null) return;
- var executionStatus = 'EXECUTION_WAIT';
- if (String(o.validation_status || '') === 'PASS') {
- executionStatus = 'EXECUTION_READY';
- } else if (finalAction === 'WATCH' || finalAction === 'HOLD' || orderType === 'WATCH') {
- executionStatus = 'PROPOSAL_ONLY';
- }
- var blockReason = o.rationale_code || '하네스 기준 제안 유지';
- if (!o.rationale_code && Array.isArray(d.gate_trace) && d.gate_trace.length) {
- blockReason = d.gate_trace[d.gate_trace.length - 1].reason || blockReason;
- }
- var positionClass = String(p.position_class || '').toLowerCase();
- var baseStopQty = s.holding_qty != null ? s.holding_qty : proposedQty;
- var stop1Qty = null;
- var stop2Qty = null;
- if (typeof baseStopQty === 'number' && baseStopQty > 0) {
- var stop1Ratio = positionClass === 'core' ? 0.50 : 0.70;
- stop1Qty = Math.floor(baseStopQty * stop1Ratio);
- if (stop1Qty <= 0) stop1Qty = 1;
- if (stop1Qty > baseStopQty) stop1Qty = baseStopQty;
- stop2Qty = baseStopQty - stop1Qty;
- if (stop2Qty <= 0) stop2Qty = null;
- }
- var stop3Price = null;
- if (pp.auto_trailing_stop != null) {
- stop3Price = pp.auto_trailing_stop;
- } else if (String(p.profit_lock_stage || 'NORMAL') !== 'NORMAL' && pp.protected_stop_price != null) {
- stop3Price = pp.protected_stop_price;
- }
- var stop3Qty = null;
- if (stop3Price != null) {
- stop3Qty = t.tp3_qty != null ? t.tp3_qty : (p.tp3_qty != null ? p.tp3_qty : null);
- if (stop3Qty == null && typeof baseStopQty === 'number' && baseStopQty > 0) {
- var tp1Qty = t.tp1_qty != null ? t.tp1_qty : (p.tp1_qty != null ? p.tp1_qty : 0);
- var tp2Qty = t.tp2_qty != null ? t.tp2_qty : (p.tp2_qty != null ? p.tp2_qty : 0);
- var residualQty = baseStopQty - tp1Qty - tp2Qty;
- stop3Qty = residualQty > 0 ? residualQty : null;
- }
- }
- p6Rows.push({
- account: o.account || s.account || b.account || p.account || d.account || '',
- ticker: ticker,
- name: o.name || s.name || b.name || p.name || d.name || '',
- proposal_type: proposalType,
- proposed_limit_price_krw: proposedLimit,
- proposed_price_basis: priceBasis,
- proposed_quantity: proposedQty,
- proposed_quantity_basis: qtyBasis,
- priority_group: priorityGroup,
- priority_rank: priorityRank,
- proposed_stop_price_krw: p.stop_price != null ? p.stop_price : null,
- stop1_price_krw: p.stop_price != null ? p.stop_price : null,
- stop1_quantity: stop1Qty,
- stop2_price_krw: stop2Qty != null ? p.stop_price : null,
- stop2_quantity: stop2Qty,
- stop3_price_krw: stop3Price,
- stop3_quantity: stop3Qty,
- tp1_price_krw: t.tp1_price != null ? t.tp1_price : (p.tp1_price != null ? p.tp1_price : null),
- tp1_quantity: t.tp1_qty != null ? t.tp1_qty : (p.tp1_qty != null ? p.tp1_qty : null),
- tp2_price_krw: t.tp2_price != null ? t.tp2_price : (p.tp2_price != null ? p.tp2_price : null),
- tp2_quantity: t.tp2_qty != null ? t.tp2_qty : (p.tp2_qty != null ? p.tp2_qty : null),
- tp3_price_krw: null,
- tp3_quantity: t.tp3_qty != null ? t.tp3_qty : (p.tp3_qty != null ? p.tp3_qty : null),
- execution_status: executionStatus,
- block_reason: blockReason
- });
- });
-
- return [
- // ── 메타 ─────────────────────────────────────────────────────────
- ['harness_version', HARNESS_VERSION],
- ['computed_at', formatIso_(now)],
- // [PROPOSAL50] P0-2: ROUTING_TRACE_V1 동적값 — 정적 하드코딩 제거
- ['request_route', ((hApex || {}).routing_trace_json || {}).request_route || 'PIPELINE_EOD_BATCH'],
- ['route_reason_code', 'RUN_EVENT_RISK_CHAIN'],
- ['bundle_selected', ((hApex || {}).routing_trace_json || {}).bundle_selected || 'retirement_portfolio_ultra_compact'],
- ['prompt_entrypoint', ((hApex || {}).routing_trace_json || {}).prompt_entrypoint || 'prompts/analysis_prompt.md'],
- // [PROPOSAL50] P0-1: EXPORT_GATE_V1 동적값 — PENDING_EXPORT 정적 하드코딩 제거
- ['json_validation_status', (hApex || {}).json_validation_status || 'PENDING_EXPORT'],
- ['capture_required', String(((hApex || {}).routing_trace_json || {}).capture_required != null
- ? (hApex.routing_trace_json.capture_required) : true)],
- ['cash_ledger_basis', ((hApex || {}).routing_trace_json || {}).cash_ledger_basis || 'D2_ONLY'],
- ['source_manifest_json', JSON.stringify(sourceManifest)],
-
- // ── H1: P4 가드 ───────────────────────────────────────────────
- ['captured_at', capturedAtIso],
- ['intraday_lock', intradayLock],
- ['snapshot_execution_gate', snapshotGate.status],
- ['snapshot_execution_reason', snapshotGate.reason],
- ['account_snapshot_freshness_json', JSON.stringify(snapshotFreshness || {})],
- ['intraday_lock_reason',
- intradayLock
- ? 'captured_at < 15:30 KST — P4 적용: EXIT_100/SELL_FULL/BUY 차단'
- : 'captured_at >= 15:30 KST — 정상 장마감 데이터'],
- ['p4_guard', intradayLock ? 'ACTIVE' : 'INACTIVE'],
-
- // ── H1: 현금 (P3 가드) ────────────────────────────────────────
- ['immediate_cash_krw', asResult.immediateCashKrw],
- ['settlement_cash_d2_krw', asResult.settlementCashD2Krw],
- ['open_order_amount_krw', asResult.openOrderAmountKrw],
- ['buy_power_krw', buyPowerKrw],
- ['total_asset_krw', totalAsset],
- ['settlement_cash_pct', settlementCashPct],
- ['p3_guard',
- 'ACTIVE — settlement_cash_d2_krw only. '
- + 'cash_floor 및 buy_power_krw 는 D+2 정산현금 단독 기준. '
- + 'immediate_cash_krw 는 참고값이며 cash ledger 합산 금지.'],
-
- // ── H1: cash_floor ────────────────────────────────────────────
- ['cash_floor_min_pct', cashFloorInfo.minPct],
- ['cash_floor_regime', cashFloorInfo.regime],
- ['cash_floor_status', cashFloorInfo.status],
-
- // ── G1: 현금 부족액 / 목표현금 확정값 (CASH_SHORTFALL_V1) ─────────────────
- // LLM 즉석 계산 금지: "약 N원 필요" 는 이 필드 복사만 허용
- ['cash_current_pct_d2', g1CashCurrentPct],
- ['cash_target_pct', g1TargetCashPct],
- ['cash_shortfall_min_krw', g1ShortfallMin],
- ['cash_shortfall_target_krw', g1ShortfallTgt],
-
- // ── G2: 현금 회복 TRIM 계획 (TRIM_PLAN_MIN_CASH_V1) ──────────────────────
- // 매도우선순위(H2) 기반 종목별 TRIM 순서·예상금액 하네스 확정 — LLM 임의 선택 금지
- ['trim_plan_to_min_cash_json', JSON.stringify(g2TrimPlan)],
-
- ['mrs_score', mrsScore],
- ['performance_multiplier', performance.bayesian_multiplier],
- ['performance_label', performance.bayesian_label],
- ['performance_win_rate_30', performance.win_rate_30],
- ['performance_net_expectancy_30', performance.net_expectancy_30],
- ['performance_consecutive_losses', performance.consecutive_losses],
- ['performance_trades_used', performance.trades_used],
-
- // ── H1: Total Heat ────────────────────────────────────────────
- ['total_heat_krw', Math.round(asResult.totalHeatKrw)],
- ['total_heat_pct', totalHeatPct],
- ['total_heat_atr_estimated', asResult.heatAtrEstimated],
- ['total_heat_rows_counted', asResult.heatRowsCount],
- ['heat_gate_status', heatGate],
- ['heat_gate_threshold_pct', heatThresholds ? heatThresholds.hardBlock : HEAT_HARD_BLOCK_PCT],
-
- // ── H1: 허용/차단 액션 ────────────────────────────────────────
- ['allowed_actions', JSON.stringify(actions.allowed)],
- ['blocked_actions', JSON.stringify(actions.blocked)],
-
- // ── H2: 매도후보 순위 ─────────────────────────────────────────
- ['sell_candidates_json', JSON.stringify(h2.candidates)],
- ['sell_priority_checksum', computeStringChecksum_(JSON.stringify(((h2 && h2.candidates) || []).map(function(c) {
- return {
- rank: c.rank,
- ticker: c.ticker,
- tier: c.tier,
- score: (typeof c.sell_priority_score === 'number') ? c.sell_priority_score : c.score
- };
- })))],
- ['sell_priority_lock', 'true'],
- ['sell_priority_computed_at', formatIso_(now)],
- ['sell_candidates_count', ((h2 && h2.candidates) ? h2.candidates.length : 0)],
- ['sell_priority_leader_holdback', JSON.stringify(((h2 && h2.candidates) || []).map(function(c) {
- return {
- ticker: c.ticker,
- rank: c.rank,
- tier: c.tier,
- sell_priority_score: c.sell_priority_score,
- rebound_holdback_score: c.rebound_holdback_score || 0,
- trim_style: c.trim_style || '',
- cash_preserve_style: c.cash_preserve_style || '',
- cash_preserve_ratio: c.cash_preserve_ratio || 0,
- };
- }))],
-
- // ── H3: 수량 ─────────────────────────────────────────────────
- ['sell_quantities_json', JSON.stringify(h3.sellQty)],
- ['buy_qty_inputs_json', JSON.stringify(h3.buyQtyInputs)],
- ['quantities_lock', 'true'],
-
- // ── H4: 가격 ─────────────────────────────────────────────────
- ['prices_json', JSON.stringify(h4.prices)],
- ['prices_lock', 'true'],
-
- // ── H5: 결정 상태머신 ─────────────────────────────────────────
- ['decisions_json', JSON.stringify(h5.decisions)],
- ['decision_trace_json', (function() {
- var full = JSON.stringify(h5.traces || []);
- if (full.length <= 45000) return full;
- // blocked_actions / inputs_used 는 전 항목 공통값 반복 → 제거해 압축
- var slim = (h5.traces || []).map(function(t) {
- return { ticker: t.ticker, state: t.state, result: t.result,
- selected_action: t.selected_action, reason: t.reason };
- });
- return JSON.stringify(slim);
- })()],
- ['decision_lock', 'true'],
-
- // ── H6: HTS 주문 렌더링 + Blueprint 무결성 해시 ─────────────────
- ['order_blueprint_json', JSON.stringify(orderBlueprint)],
- ['blueprint_row_count', (orderBlueprint || []).length],
- ['blueprint_checksum', computeBlueprintChecksum_(orderBlueprint)],
- ['blueprint_hash_algo', 'CRC32_V1'],
- ['render_validation_status', 'READY'],
- ['proposal_reference_json', JSON.stringify(p6Rows)],
- ['proposal_reference_lock', 'true'],
-
- // ── I3: CHECKSUM_V2 — 결정론적 체크섬 강화 ──────────────────────────────
- // 동일 입력/기준시각에서 네 체크섬이 모두 일치해야 NON_DETERMINISTIC_OUTPUT 방지
- ['source_manifest_checksum', computeStringChecksum_(JSON.stringify(sourceManifest))],
- ['decision_trace_checksum', computeStringChecksum_(JSON.stringify(h5.traces))],
- // ── [2026-05-20_HARNESS_V5] 신규 체크섬 ─────────────────────────────────
- // input_snapshot_checksum: 계좌 캡처 원장(보유수량·평단·현금)의 스냅샷 해시.
- // 동일 입력 재호출 시 이 값이 변하면 데이터 소스가 갱신된 것이다.
- ['input_snapshot_checksum', computeInputSnapshotChecksum_(asResult, capturedAtIso)],
- // rendered_output_checksum: blueprint와 동일한 주문 행 해시 (canonical).
- ['rendered_output_checksum', computeBlueprintChecksum_(orderBlueprint)],
- // rendered_report_checksum: legacy alias. 신규 검증은 rendered_output_checksum 우선.
- ['rendered_report_checksum', computeBlueprintChecksum_(orderBlueprint)],
- // non_deterministic_flag: Python 검증기가 이전 실행값과 비교 후 설정. GAS는 항상 false.
- ['non_deterministic_flag', 'false'],
- ['checksum_hash_algo', 'CRC32_V1'],
-
- // ── Alpha-Shield: X1/X3/W1~W4 선행 레이더 ───────────────────
- ['alpha_shield_json',
- JSON.stringify((hAlpha || {}).per_holding || [])],
- ['alpha_shield_lock', 'true'],
- ['alpha_shield_critical_alert_count',
- String((hAlpha || {}).critical_alert_count || 0)],
- ['alpha_shield_critical_alert_flag',
- ((hAlpha || {}).critical_alert_count || 0) > 0 ? 'CRITICAL' : 'OK'],
- ['alpha_shield_computed_at', formatIso_(now)],
- ['alpha_shield_formula_ids',
- 'MRG001[X1],RS001[X3],W1_DIVERGENCE,W2_OVERHANG,W3_ROTATION,W4_FLOW_ACCEL'],
-
- // ── APEX V1: 판단 자료 생성 시점 로직 하네스 ─────────────────────────────
- // 텍스트 가이드라인이 아니라 GAS가 직접 산출한 매수/매도/현금확보 실행 판단 자료
- ['alpha_lead_json', JSON.stringify((hApex || {}).alpha_lead_json || [])],
- ['alpha_lead_lock', 'true'],
- ['backdata_feature_bank_json', JSON.stringify(((hApex || {}).backdata_feature_bank_json || []).slice(-50))],
- ['backdata_learning_lock', 'true'],
- ['follow_through_json', JSON.stringify((hApex || {}).follow_through_json || [])],
- ['follow_through_lock', 'true'],
- ['distribution_risk_json', JSON.stringify((hApex || {}).distribution_risk_json || [])],
- ['distribution_lock', 'true'],
- ['profit_preservation_json', JSON.stringify((hApex || {}).profit_preservation_json || [])],
- ['profit_preservation_lock', 'true'],
- ['cash_raise_plan_json', JSON.stringify((hApex || {}).cash_raise_plan_json || [])],
- ['rebound_sell_trigger_json', JSON.stringify((hApex || {}).rebound_sell_trigger_json || [])],
- ['smart_sell_quantities_json', JSON.stringify((hApex || {}).smart_sell_quantities_json || [])],
- ['smart_cash_raise_lock', 'true'],
- ['execution_quality_json', JSON.stringify((hApex || {}).execution_quality_json || [])],
- ['execution_quality_lock', 'true'],
- ['buy_permission_json', JSON.stringify((hApex || {}).buy_permission_json || [])],
- ['limit_price_policy_json', JSON.stringify((hApex || {}).limit_price_policy_json || [])],
- ['regime_adjusted_sell_priority_json', JSON.stringify((hApex || {}).regime_adjusted_sell_priority_json || [])],
- ['benchmark_relative_timeseries_json', JSON.stringify((hApex || {}).benchmark_relative_timeseries_json || [])],
- ['index_relative_health_json', JSON.stringify((hApex || {}).index_relative_health_json || [])],
- ['saqg_json', JSON.stringify((hApex || {}).saqg_json || [])],
- ['cash_creation_purpose_lock_json', JSON.stringify((hApex || {}).cash_creation_purpose_lock_json || [])],
- ['alpha_feedback_json', JSON.stringify((hApex || {}).alpha_feedback_json || {})],
- ['alpha_evaluation_window_json', JSON.stringify((hApex || {}).alpha_evaluation_window_json || [])],
- ['entry_freshness_json', JSON.stringify((hApex || {}).entry_freshness_json || [])],
- ['sell_value_preservation_json', JSON.stringify((hApex || {}).sell_value_preservation_json || [])],
- // ── [2026-05-20_HARNESS_V5] Gate 4b: FTD 확인 상태 잠금
- ['follow_through_confirm_json', JSON.stringify((hApex || {}).follow_through_confirm_json || [])],
- ['follow_through_confirm_lock', 'true'],
- // L1: 섹터 로테이션 모멘텀
- ['sector_rotation_momentum_json', JSON.stringify(sectorMomentumRows || [])],
- ['sector_rotation_momentum_lock', 'true'],
-
- // ── M1: DRAWDOWN_GUARD_V1 ────────────────────────────────────
- ['drawdown_guard_state', (drawdownGuard || {}).state || 'NORMAL'],
- ['drawdown_buy_scale', (drawdownGuard || {}).buy_scale !== undefined
- ? (drawdownGuard || {}).buy_scale : 1.0],
- ['drawdown_consecutive_losses', (drawdownGuard || {}).consecutive_losses || 0],
-
- // ── M2: PORTFOLIO_BETA_GATE_V1 ──────────────────────────────
- ['portfolio_beta', (portfolioBetaGate || {}).portfolio_beta !== null
- ? (portfolioBetaGate || {}).portfolio_beta : 'N/A'],
- ['portfolio_beta_gate', (portfolioBetaGate || {}).gate_status || 'INSUFFICIENT_DATA'],
- ['portfolio_beta_gate_json', JSON.stringify(portfolioBetaGate || {})],
-
- // ── M3: TP_QUANTITY_LADDER_V1 ───────────────────────────────
- ['tp_quantity_ladder_json', JSON.stringify(tpLadderRows || [])],
- ['tp_quantity_ladder_lock', 'true'],
-
- // ── M4: EVENT_RISK_HOLD_GATE_V1 ─────────────────────────────
- ['event_risk_json', JSON.stringify(eventRiskRows || [])],
- ['event_risk_lock', 'true'],
-
- // ── M5: SECTOR_CONCENTRATION_LIMIT_V1 ───────────────────────
- ['sector_concentration_gate', (sectorConcentration || {}).gate_status || 'PASS'],
- ['sector_concentration_json', JSON.stringify((sectorConcentration || {}).by_sector || [])],
-
- // ── N1: POSITION_SIZE_REGIME_SCALE_V1 ───────────────────────
- ['regime_size_scale', (regimeSizeScale || {}).scale !== undefined ? (regimeSizeScale || {}).scale : 1.0],
-
- // ── N3: STOP_PRICE_ADEQUACY_V1 ──────────────────────────────
- ['stop_adequacy_json', JSON.stringify(stopAdequacyRows || [])],
- ['stop_adequacy_lock', 'true'],
-
- // ── N4: HOLDING_STALE_REVIEW_V1 ─────────────────────────────
- ['holding_stale_json', JSON.stringify(staleRows || [])],
- ['holding_stale_lock', 'true'],
-
- // ── N5: REGIME_CASH_UPLIFT_V1 ───────────────────────────────
- ['regime_cash_uplift_min_pct', typeof regimeCashMinPct === 'number' ? regimeCashMinPct : cashFloorInfo.minPct],
-
- // ── O1: SINGLE_POSITION_WEIGHT_CAP_V1 ───────────────────────
- ['single_position_weight_gate', (singlePositionWeightCap || {}).gate_status || 'PASS'],
- ['single_position_weight_json', JSON.stringify((singlePositionWeightCap || {}).by_position || [])],
-
- // ── O2: SEMICONDUCTOR_CLUSTER_GATE_V1 ───────────────────────
- ['semiconductor_cluster_gate', (semiconductorClusterGate || {}).gate_status || 'PASS'],
- ['semiconductor_cluster_json', JSON.stringify(semiconductorClusterGate || {})],
-
- // ── O3: PORTFOLIO_DRAWDOWN_GATE_V1 ──────────────────────────
- ['portfolio_drawdown_gate', (portfolioDrawdownGate || {}).gate || 'INSUFFICIENT_DATA'],
- ['portfolio_drawdown_pct', (portfolioDrawdownGate || {}).drawdown_pct !== null ? (portfolioDrawdownGate || {}).drawdown_pct : null],
- ['portfolio_peak_krw', (portfolioDrawdownGate || {}).peak_krw || null],
-
- // ── O4: WIN_LOSS_STREAK_GUARD_V1 ────────────────────────────
- ['win_loss_streak_state', (winLossStreakGuard || {}).state || 'INSUFFICIENT_HISTORY'],
- ['win_loss_streak_buy_scale', (winLossStreakGuard || {}).buy_scale !== undefined ? (winLossStreakGuard || {}).buy_scale : 1.0],
- ['win_loss_streak_win_rate_pct', (winLossStreakGuard || {}).win_rate_pct !== null ? (winLossStreakGuard || {}).win_rate_pct : null],
-
- // ── O5: POSITION_COUNT_LIMIT_V1 ─────────────────────────────
- ['position_count_gate', (positionCountLimit || {}).gate_status || 'PASS'],
- ['position_count', (positionCountLimit || {}).position_count !== undefined ? (positionCountLimit || {}).position_count : 0],
- ['position_count_max', (positionCountLimit || {}).max_count !== undefined ? (positionCountLimit || {}).max_count : 8],
-
- // ── P1: STOP_BREACH_ALERT_V1 ─────────────────────────────────
- ['stop_breach_gate', (stopBreachAlert || {}).gate || 'PASS'],
- ['stop_breach_alert_json', JSON.stringify((stopBreachAlert || {}).alerts || [])],
-
- // ── P1-BIS: RELATIVE_STOP_SIGNAL_V1 ─────────────────────────
- ['relative_stop_gate', ((hApex || {}).relative_stop_signal || {}).gate || 'PASS'],
- ['relative_stop_signal_json', JSON.stringify(((hApex || {}).relative_stop_signal || {}).signals || [])],
-
- // ── P2: TP_TRIGGER_ALERT_V1 ──────────────────────────────────
- ['tp_trigger_gate', (tpTriggerAlert || {}).gate || 'PASS'],
- ['tp_trigger_alert_json', JSON.stringify((tpTriggerAlert || {}).triggered || [])],
-
- // ── P3: HEAT_CONCENTRATION_ALERT_V1 ─────────────────────────
- ['heat_concentration_gate', (heatConcentrationAlert || {}).gate || 'PASS'],
- ['heat_concentration_json', JSON.stringify((heatConcentrationAlert || {}).by_holding || [])],
-
- // ── P4: REGIME_TRANSITION_ALERT_V1 ──────────────────────────
- ['regime_transition_type', (regimeTransitionAlert || {}).transition_type || 'NO_CHANGE'],
- ['regime_transition_json', JSON.stringify(regimeTransitionAlert || {})],
-
- // ── P5: PORTFOLIO_HEALTH_SCORE_V1 ────────────────────────────
- ['portfolio_health_label', (portfolioHealthScore || {}).label || 'CAUTION'],
- ['portfolio_health_score', (portfolioHealthScore || {}).score !== undefined ? (portfolioHealthScore || {}).score : 50],
- ['portfolio_health_critical_count', (portfolioHealthScore || {}).critical_count || 0],
- ['portfolio_health_caution_count', (portfolioHealthScore || {}).caution_count || 0],
- ['portfolio_health_blocked_json', JSON.stringify((portfolioHealthScore || {}).blocked_gates || [])],
-
- // ── [2026-05-20_HARNESS_V5] H6/H7/H8 신규 게이트 ────────────────────
- ['breakout_quality_gate_json', JSON.stringify((hApex || {}).breakout_quality_gate_json || [])],
- ['breakout_quality_gate_lock', 'true'],
- ['anti_whipsaw_gate_json', JSON.stringify((hApex || {}).anti_whipsaw_gate_json || [])],
- ['anti_whipsaw_gate_lock', 'true'],
- ['smart_cash_raise_json', JSON.stringify((hApex || {}).smart_cash_raise_json || [])],
- ['smart_cash_raise_route', (hApex || {}).smart_cash_raise_route || 'NO_ACTION'],
-
- // ── [2026-05-21_CLA_HARNESS_V1] SFG 하네스 출력 ──────────────────────────
- ['satellite_failure_gate_json', JSON.stringify((hApex || {}).satellite_failure_gate_json || {})],
- ['sapg_json', JSON.stringify((hApex || {}).sapg_json || {})],
- ['sfg_v1_lock', 'true'],
-
- // ── [SPRINT2_REGIME_CLA_V1] CONCENTRATED_LEADER_ADVANCE 게이트 ──────────
- ['regime_cla_json', (function() {
- var phase = (regimeTrimGuidance || {}).phase || 'UNKNOWN';
- var cla_active = phase === 'CONCENTRATED_LEADER_ADVANCE';
- var sc = semiconductorClusterGate || {};
- var combined_pct = sc.combined_pct || 0;
- var cluster_state = cla_active ? 'CLUSTER_HOLD_ONLY'
- : (sc.cluster_state || 'CLUSTER_OPEN');
- var cla_exit = cla_active ? 'CLA_ACTIVE' : 'CLA_EXIT_CONFIRMED';
- var rag_pass = !cla_active || combined_pct < 60.0;
- return JSON.stringify({
- cla_active: cla_active,
- market_regime: phase,
- cluster_state: cluster_state,
- cluster_combined_pct: combined_pct,
- cla_exit_status: cla_exit,
- core_sell_blocked: cla_active,
- satellite_buy_gate: (cla_active && combined_pct >= 60.0)
- ? 'CLUSTER_HOLD_ONLY' : 'CLUSTER_OPEN',
- cash_raise_priority: cla_active ? 'LAGGARD_BROKEN_FIRST' : 'H2_RANK',
- rag_v1: rag_pass ? 'PASS' : 'FAIL',
- rag_reason: rag_pass
- ? 'CLA 비활성 또는 반도체 합산 비중 60% 미만 — 위성 BUY 허용'
- : 'CLA 활성 + 반도체 합산 비중 ≥60% — 위성 신규 BUY 차단',
- formula_id: 'CONCENTRATED_LEADER_ADVANCE_V1',
- });
- })()],
- ['cla_exit_status', (function() {
- var phase = (regimeTrimGuidance || {}).phase || 'UNKNOWN';
- return phase === 'CONCENTRATED_LEADER_ADVANCE' ? 'CLA_ACTIVE' : 'CLA_EXIT_CONFIRMED';
- })()],
- ['rag_v1', (function() {
- var phase = (regimeTrimGuidance || {}).phase || 'UNKNOWN';
- var cla_active = phase === 'CONCENTRATED_LEADER_ADVANCE';
- var combined_pct = (semiconductorClusterGate || {}).combined_pct || 0;
- return (!cla_active || combined_pct < 60.0) ? 'PASS' : 'FAIL';
- })()],
- ['rag_reason', (function() {
- var phase = (regimeTrimGuidance || {}).phase || 'UNKNOWN';
- var cla_active = phase === 'CONCENTRATED_LEADER_ADVANCE';
- var combined_pct = (semiconductorClusterGate || {}).combined_pct || 0;
- if (!cla_active) return 'CLA 비활성 — RAG 조건 불필요';
- return combined_pct < 60.0
- ? 'CLA 활성이나 반도체 합산 60% 미만 — 위성 BUY 허용'
- : 'CLA 활성 + 반도체 합산 ≥60% — 위성 신규 BUY 차단';
- })()],
-
- ['apex_formula_ids',
- 'ALPHA_LEAD_SCORE_V1,FOLLOW_THROUGH_CONFIRM_V1,DISTRIBUTION_RISK_SCORE_V1,'
- + 'PROFIT_PRESERVATION_STATE_V1,SMART_CASH_RAISE_PLAN_V1,REBOUND_SELL_TRIGGER_V1,'
- + 'EXECUTION_QUALITY_GUARD_V1,BUY_PERMISSION_MATRIX_V1,SELL_QUANTITY_ALLOCATOR_V1,'
- + 'LIMIT_PRICE_POLICY_V1,STAGED_ENTRY_TRANCHE_V1,K2_STAGED_REBOUND_SELL,K3_REGIME_SELL_PRIORITY_V1,'
- + 'SECTOR_ROTATION_MOMENTUM_V1,RATCHET_TRAILING_AUTO_V1,PRE_DISTRIBUTION_EARLY_WARNING_V1,'
- + 'DYNAMIC_HEAT_GATE_V1,DRAWDOWN_GUARD_V1,PORTFOLIO_BETA_GATE_V1,TP_QUANTITY_LADDER_V1,'
- + 'EVENT_RISK_HOLD_GATE_V1,SECTOR_CONCENTRATION_LIMIT_V1,'
- + 'POSITION_SIZE_REGIME_SCALE_V1,VOLUME_BREAKOUT_CONFIRM_V1,STOP_PRICE_ADEQUACY_V1,'
- + 'HOLDING_STALE_REVIEW_V1,REGIME_CASH_UPLIFT_V1,'
- + 'SINGLE_POSITION_WEIGHT_CAP_V1,SEMICONDUCTOR_CLUSTER_GATE_V1,'
- + 'PORTFOLIO_DRAWDOWN_GATE_V1,WIN_LOSS_STREAK_GUARD_V1,POSITION_COUNT_LIMIT_V1,'
- + 'STOP_BREACH_ALERT_V1,TP_TRIGGER_ALERT_V1,HEAT_CONCENTRATION_ALERT_V1,'
- + 'REGIME_TRANSITION_ALERT_V1,PORTFOLIO_HEALTH_SCORE_V1,'
- + 'BREAKOUT_QUALITY_GATE_V2,ANTI_WHIPSAW_HOLD_GATE_V1,SMART_CASH_RAISE_V2,'
- + 'BENCHMARK_RELATIVE_TIMESERIES_V1,RS_VERDICT_V2,SATELLITE_ALPHA_QUALITY_GATE_V1,'
- + 'CASH_CREATION_PURPOSE_LOCK_V1,SATELLITE_AGGREGATE_PNL_GATE_V1,ALPHA_EVALUATION_WINDOW_V1,'
- + 'ALPHA_FEEDBACK_LOOP_V1,ENTRY_FRESHNESS_GATE_V1,SELL_VALUE_PRESERVATION_GATE_V1,'
- + 'INDEX_RELATIVE_HEALTH_GATE_V1,'
- + 'RS_VERDICT_V1,COMPOSITE_VERDICT_V1,REPLACEMENT_ALPHA_GATE_V1,SATELLITE_FAILURE_GATE_V1,'
- + 'CONCENTRATED_LEADER_ADVANCE_V1,'
- // ── [2026-05-23_PROPOSAL46] PA1~PA5
- + 'PREDICTIVE_ALPHA_ENGINE_V1,ANTI_LATE_ENTRY_GATE_V2,CASH_PRESERVATION_SELL_ENGINE_V2,'
- + 'MACRO_EVENT_SYNCHRONIZER_V1,CONSISTENCY_VALIDATOR_V2'],
-
- // ── [2026-05-23_PROPOSAL46] PA1~PA5 신규 하네스 출력 ─────────────────────────
- ['predictive_alpha_json', JSON.stringify((hApex || {}).predictive_alpha_json || [])],
- ['anti_late_entry_json', JSON.stringify((hApex || {}).anti_late_entry_json || [])],
- ['cash_preservation_sell_json', JSON.stringify((hApex || {}).cash_preservation_sell_json || [])],
- ['macro_event_json', JSON.stringify((hApex || {}).macro_event_json || {})],
- ['macro_risk_score', (hApex || {}).macro_risk_score !== undefined ? String((hApex || {}).macro_risk_score) : ''],
- ['macro_risk_regime', (hApex || {}).macro_risk_regime || ''],
- ['mega_sell_alert', (hApex || {}).mega_sell_alert === true ? 'true' : 'false'],
- ['consistency_report_json', JSON.stringify((hApex || {}).consistency_report_json || {})],
- ['consistency_score', (hApex || {}).consistency_score !== undefined ? String((hApex || {}).consistency_score) : ''],
- ['cv_verdict', (hApex || {}).cv_verdict || ''],
- ['portfolio_alpha_confidence', (hApex || {}).portfolio_alpha_confidence !== null && (hApex || {}).portfolio_alpha_confidence !== undefined ? String((hApex || {}).portfolio_alpha_confidence) : ''],
- ['fomc_position_size_gate', (hApex || {}).fomc_position_size_gate || 'INACTIVE'],
- ['prediction_accuracy_rate', (hApex || {}).prediction_accuracy_rate !== null && (hApex || {}).prediction_accuracy_rate !== undefined ? String((hApex || {}).prediction_accuracy_rate) : ''],
- ['watch_breakout_candidates_json', JSON.stringify((hApex || {}).watch_breakout_candidates_json || [])],
- ['anti_whipsaw_reentry_json', JSON.stringify((hApex || {}).anti_whipsaw_reentry_json || [])],
- ['alpha_history_summary_json', JSON.stringify((hApex || {}).alpha_history_summary_json || {})],
-
- // ── P4 허용 목록 (LLM 참조용) ────────────────────────────────
- ['p4_intraday_allowed_actions', JSON.stringify(INTRADAY_ALLOWED_ACTIONS)],
-
- // ── M1: 국면별 감축 가이던스 (REGIME_TRIM_WEIGHT_V1) ──────────
- // LLM의 주관적 국면 판단 및 임의 감축비율 산출을 차단
- ['market_regime_state', (regimeTrimGuidance || {}).phase || 'UNKNOWN'],
- ['regime_trim_guidance_json', JSON.stringify(regimeTrimGuidance || {})],
- ['regime_trim_lock', 'true'],
-
- // ── H3: 주도주 승자 포지션 보호 게이트 (SECULAR_LEADER_REGIME_GATE_V1) ─
- // 삼성전자·SK하이닉스 secular_leader_profit_lock 발동 여부 결정론적 확정
- ['secular_leader_gate_json', JSON.stringify(
- (h4.prices || []).reduce(function(acc, p) {
- if (p.secular_leader_gate_status && p.secular_leader_gate_status !== 'NOT_APPLICABLE') {
- acc[p.ticker] = {
- active: p.secular_leader_gate_active,
- status: p.secular_leader_gate_status,
- reasons: p.secular_leader_gate_reasons
- };
- }
- return acc;
- }, {})
- )],
-
- // ── M4: 5억원 목표 자산 추적 대시보드 ──────────────────────────────────────
- // GOAL_RETIREMENT_V1: 은퇴자산 5억원 목표 — 하네스 결정론적 산출 (LLM 재판단 금지)
- ['goal_asset_krw', M4_GOAL_KRW],
- ['goal_current_asset_krw', Math.round(m4Asset)],
- ['goal_achievement_pct', m4Achieve],
- ['goal_remaining_krw', Math.round(m4Remain)],
- ['goal_eta_months', m4EtaMonths],
- ['goal_eta_label', m4EtaLabel],
- ['goal_monthly_growth_pct', m4NetExp30],
- ['goal_status', m4Asset >= M4_GOAL_KRW ? 'ACHIEVED' : 'IN_PROGRESS'],
-
- // ── [3RD_HARNESS_V1] 커버리지 완성 — GAS 30.2% → 100% ───────────────────────────
- // 목표: LLM 자유도 69.8% → 0% (완전 결정론적)
- // 43/43 필수 필드를 GAS가 직접 산출 — LLM 추정 불필요
-
- // HARNESS_DATA_FRESHNESS_GATE_V1
- ['data_freshness_status',
- (((hApex || {}).data_freshness_json) || {}).data_freshness_status
- || (snapshotGate.status === 'PASS' ? 'FRESH' : 'STALE')],
-
- // INTRADAY_ACTION_MATRIX_V1
- ['intraday_scope', intradayLock ? 'INTRADAY_RESTRICTED' : 'FULL_EOD'],
-
- // PROFIT_LOCK_RATCHET_V1 — profit_preservation_json 최고 단계
- ['profit_lock_stage', (function() {
- var pp = (hApex || {}).profit_preservation_json || [];
- var order = { APEX_SUPER: 7, APEX_TRAILING: 6, PROFIT_LOCK_30: 5, PROFIT_LOCK_20: 4,
- PROFIT_LOCK_10: 3, BREAKEVEN_RATCHET: 2, NORMAL: 1 };
- var best = 'NORMAL';
- pp.forEach(function(r) {
- var st = String(r.profit_preservation_state || 'NORMAL');
- if ((order[st] || 1) > (order[best] || 1)) best = st;
- });
- return best;
- })()],
- ['auto_trailing_stop', (function() {
- var pp = (hApex || {}).profit_preservation_json || [];
- var maxStop = null;
- pp.forEach(function(r) {
- if (typeof r.auto_trailing_stop === 'number'
- && (maxStop === null || r.auto_trailing_stop > maxStop)) {
- maxStop = r.auto_trailing_stop;
- }
- });
- return maxStop !== null ? maxStop : 0;
- })()],
-
- // PROFIT_RATCHET_TIERED_V2 — APEX_SUPER(+60%) ATR×1.2 trailing
- // profit_pct >= 60 → APEX_SUPER; inject_computed_harness.py 가 정밀값 교체
- ['ratchet_stage_v2', (function() {
- var pp = (hApex || {}).profit_preservation_json || [];
- var order = { APEX_SUPER: 7, APEX_TRAILING: 6, PROFIT_LOCK_30: 5, PROFIT_LOCK_20: 4,
- PROFIT_LOCK_10: 3, BREAKEVEN_RATCHET: 2, NORMAL: 1 };
- var best = 'NORMAL';
- pp.forEach(function(r) {
- var pct = typeof r.profit_pct === 'number' ? r.profit_pct : 0;
- var st = pct >= 60 ? 'APEX_SUPER'
- : String(r.profit_preservation_state || 'NORMAL');
- if ((order[st] || 1) > (order[best] || 1)) best = st;
- });
- return best;
- })()],
- ['auto_trailing_stop_v2', (function() {
- var pp = (hApex || {}).profit_preservation_json || [];
- var maxStop = null;
- pp.forEach(function(r) {
- // APEX_SUPER 종목: 기존 auto_trailing_stop 그대로 사용 (Python inject로 ATR×1.2 보정)
- if (typeof r.auto_trailing_stop === 'number'
- && (maxStop === null || r.auto_trailing_stop > maxStop)) {
- maxStop = r.auto_trailing_stop;
- }
- });
- return maxStop !== null ? maxStop : 0;
- })()],
-
- // FLOW_ACCELERATION_V1 — W4 신호 집계
- ['flow_acceleration_status', (function() {
- var ph = (hAlpha || {}).per_holding || [];
- return ph.some(function(h) { return h.w4_status === 'FLOW_DECEL_WARNING'; })
- ? 'FLOW_DECEL_DETECTED' : 'NORMAL';
- })()],
-
- // DISTRIBUTION_SELL_DETECTOR_V1 — distribution_risk_json 집계
- ['distribution_sell_detector_status', (function() {
- var dist = (hApex || {}).distribution_risk_json || [];
- if (dist.some(function(d) { return d.anti_distribution_state === 'BLOCK_BUY'; }))
- return 'DISTRIBUTION_DETECTED';
- if (dist.some(function(d) { return d.anti_distribution_state === 'TRIM_REVIEW'; }))
- return 'TRIM_REVIEW_ALERT';
- return 'NORMAL';
- })()],
- ['signals_count', (function() {
- var dist = (hApex || {}).distribution_risk_json || [];
- return dist.filter(function(d) { return d.anti_distribution_state !== 'PASS'; }).length;
- })()],
-
- // BREAKOUT_QUALITY_GATE_V2 — breakout_quality_gate_json 최소 점수
- ['breakout_quality_score', (function() {
- var bqg = (hApex || {}).breakout_quality_gate_json || [];
- if (!bqg.length) return 0;
- var min = null;
- bqg.forEach(function(b) {
- if (typeof b.breakout_quality_score === 'number'
- && (min === null || b.breakout_quality_score < min)) min = b.breakout_quality_score;
- });
- return min !== null ? min : 0;
- })()],
-
- // ANTI_CHASING_VELOCITY_V1 — entry_freshness_json 집계 (worst-case)
- ['anti_chasing_verdict', (function() {
- var ef = (hApex || {}).entry_freshness_json || [];
- var worst = 'CLEAR';
- ef.forEach(function(r) {
- var fs = String(r.freshness_state || '').toUpperCase();
- if (fs === 'BLOCK_LATE_CHASE') { worst = 'BLOCK_CHASE'; }
- else if (fs === 'PULLBACK_WAIT' && worst !== 'BLOCK_CHASE') { worst = 'PULLBACK_WAIT'; }
- });
- return worst;
- })()],
- ['anti_chasing_velocity_status', (function() {
- var ef = (hApex || {}).entry_freshness_json || [];
- var worst = 'PASS';
- ef.forEach(function(r) {
- var fs = String(r.freshness_state || '').toUpperCase();
- if (fs === 'BLOCK_LATE_CHASE') { worst = 'BLOCKED'; }
- else if (fs === 'PULLBACK_WAIT' && worst === 'PASS') { worst = 'WAIT'; }
- });
- return worst;
- })()],
-
- // PULLBACK_ENTRY_TRIGGER_V1
- ['pullback_entry_verdict', (function() {
- var ef = (hApex || {}).entry_freshness_json || [];
- return ef.some(function(r) {
- return String(r.freshness_state || '').toUpperCase() === 'PULLBACK_WAIT';
- }) ? 'PULLBACK_ZONE' : 'ABOVE_PULLBACK_ZONE';
- })()],
- // per-ticker only; Python inject가 종목별 기준가 제공. 0 = 활성 눌림목 없음.
- ['pullback_entry_trigger_price', 0],
-
- // CASH_RECOVERY_OPTIMIZER_V1 — cash_raise_plan_json이 GAS 확정 현금회복 계획
- ['cash_recovery_plan_json', JSON.stringify((hApex || {}).cash_raise_plan_json || [])],
-
- // SELL_WATERFALL_ENGINE_V1 — 동일 계획(폭포수 매도 순서)
- ['waterfall_plan_json', JSON.stringify((hApex || {}).cash_raise_plan_json || [])],
-
- // ── SPRINT 1-3 Python-computed fields: GAS placeholder (inject.py가 덮어씀) ──
- // ANTI_CHASING_VELOCITY_V1 — per-ticker 속도 게이트 (inject.py 교체)
- ['anti_chasing_velocity_json', '[]'],
- // DISTRIBUTION_SELL_DETECTOR_V1 — per-ticker 6신호 배급형 탐지 (inject.py 교체)
- ['distribution_sell_detector_json', '[]'],
- // K2_STAGED_REBOUND_SELL_V1 — 현금확보 K2 분할 계획 (inject.py 교체)
- ['k2_staged_rebound_sell_json', '[]'],
- // PRE_DISTRIBUTION_EARLY_WARNING_V1 — distribution_risk_json 선행경보 집계 (inject.py 교체)
- ['pre_distribution_warning', JSON.stringify({ status: 'NONE', affected_count: 0, affected_tickers: [],
- buy_gate: 'PASS', formula_id: 'PRE_DISTRIBUTION_EARLY_WARNING_V1' })],
-
- // SELL_EXECUTION_TIMING_V1
- ['sell_timing_verdict',
- intradayLock ? 'TIMING_BLOCKED_INTRADAY'
- : (snapshotGate.status === 'PASS' ? 'SELL_READY' : 'SELL_BLOCKED_DATA')],
- ['sell_execution_window', intradayLock ? 'NEXT_DAY_OPEN' : 'EOD_30MIN'],
-
- // SELL_VALUE_PRESERVATION_TIERED_V2 — sell_value_preservation_json 집계
- ['preservation_verdict', (function() {
- var svp = (hApex || {}).sell_value_preservation_json || [];
- if (!svp.length) return 'NO_DATA';
- if (svp.some(function(r) { return r.sell_value_preservation_state === 'EMERGENCY_EXIT'; }))
- return 'EMERGENCY_EXIT';
- if (svp.some(function(r) { return r.sell_value_preservation_state === 'TRIM_ONLY'; }))
- return 'TRIM_ONLY';
- if (svp.some(function(r) {
- return r.sell_value_preservation_state === 'REBOUND_CONFIRM_HOLD';
- })) return 'REBOUND_CONFIRM_HOLD';
- return 'HOLD';
- })()],
-
- // TICK_NORMALIZER_V1 — GAS는 모든 가격에 tickNormalize_() 적용
- ['tick_normalized_price', true],
-
- // SELL_PRICE_SANITY_V1 — H4 prices 호가단위 검증 (GAS 생성 가격은 항상 PASS)
- // inject_computed_harness.py 가 스프레드시트 원본 입력값 검증 후 교체
- ['sell_price_sanity_status', (function() {
- var prices = (h4 || {}).prices || [];
- var worst = 'PASS';
- prices.forEach(function(p) {
- var candidates = [p.stop_price, p.tp1_price, p.tp2_price];
- candidates.forEach(function(price) {
- if (price == null || price <= 0) return;
- var tick = getTickSize_(price);
- if (price % tick !== 0) { worst = 'INVALID_TICK'; }
- });
- });
- return worst;
- })()],
-
- // BENCHMARK_RELATIVE_TIMESERIES_V1 — BRT 집계
- ['brt_verdict', (function() {
- var brt = (hApex || {}).benchmark_relative_timeseries_json || [];
- if (!brt.length) return 'NO_DATA';
- if (brt.some(function(r) { return r.brt_verdict === 'BROKEN'; })) return 'BROKEN';
- if (brt.some(function(r) { return r.brt_verdict === 'LEADER'; })) return 'LEADER';
- return 'MARKET';
- })()],
- ['brt_rs_slope', (function() {
- var brt = (hApex || {}).benchmark_relative_timeseries_json || [];
- var slopes = brt.map(function(r) { return r.rs_line_20d_slope; })
- .filter(function(v) { return v != null && isFinite(v); });
- if (!slopes.length) return 0;
- return parseFloat((slopes.reduce(function(a, b) { return a + b; }, 0)
- / slopes.length).toFixed(4));
- })()],
-
- // RS_VERDICT_V2 FUSION — buy_permission_json + BRT 융합 집계
- ['rs_verdict', (function() {
- var bp = (hApex || {}).buy_permission_json || [];
- var brt = (hApex || {}).benchmark_relative_timeseries_json || [];
- if (!bp.length) return 'NO_DATA';
- // V1 raw
- var v1_broken = bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'BROKEN'; });
- var v1_laggard = bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'LAGGARD'; });
- var v1_leader = bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'LEADER'; });
- // RS_VERDICT-5: brt_verdict=BROKEN AND v1=LEADER → V2 결과는 LAGGARD
- if (brt.some(function(r) { return r.brt_verdict === 'BROKEN'; }) && v1_leader && !v1_broken) {
- return 'LAGGARD';
- }
- if (v1_broken) return 'BROKEN';
- if (v1_laggard) return 'LAGGARD';
- if (v1_leader) return 'LEADER';
- return 'MARKET';
- })()],
- ['rs_verdict_source', (function() {
- var brt = (hApex || {}).benchmark_relative_timeseries_json || [];
- return brt.length ? 'V2_FUSION' : 'V1_ONLY';
- })()],
- ['rs_verdict_v1_raw', (function() {
- var bp = (hApex || {}).buy_permission_json || [];
- if (!bp.length) return 'NO_DATA';
- if (bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'BROKEN'; })) return 'BROKEN';
- if (bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'LAGGARD'; })) return 'LAGGARD';
- if (bp.some(function(r) { return (r.rs_verdict_v1 || r.rs_verdict) === 'LEADER'; })) return 'LEADER';
- return 'MARKET';
- })()],
-
- // SATELLITE_ALPHA_QUALITY_GATE_V1 — saqg_json 집계
- ['saqg_verdict', (function() {
- var saqg = (hApex || {}).saqg_json || [];
- if (!saqg.length) return 'NO_DATA';
- if (saqg.some(function(r) { return r.saqg_v1 === 'ELIGIBLE'; })) return 'ELIGIBLE';
- if (saqg.every(function(r) { return r.saqg_v1 === 'EXCLUDED'; })) return 'ALL_EXCLUDED';
- return 'WATCHLIST_ONLY';
- })()],
-
- // SATELLITE_AGGREGATE_PNL_GATE_V1
- ['sapg_verdict', ((hApex || {}).sapg_json || {}).sapg_status || 'INSUFFICIENT_DATA'],
-
- // LLM_SERVING_CONSTRAINT_V1
- ['serving_constraint_check', 'PASS'],
-
- // DETERMINISTIC_ROUTING_ENGINE_V1 — 9단계 라우팅 완료 로그
- ['routing_execution_log', JSON.stringify({
- stages_completed: [
- 'STAGE_0_FRESHNESS', 'STAGE_1_CASH_RATIOS', 'STAGE_2_RATCHET',
- 'STAGE_3_DISTRIBUTION', 'STAGE_4_BUY_TIMING', 'STAGE_5_SELL_WATERFALL',
- 'STAGE_6_PRICE_VALIDATION', 'STAGE_7_RS_BRT', 'STAGE_8_SATELLITE',
- 'STAGE_9_LLM_SERVING'
- ],
- routing_completed: true,
- formula_id: 'DETERMINISTIC_ROUTING_ENGINE_V1'
- })],
-
- // TRADE_QUALITY_SCORER_V1 — 월간 배치 결과 캐시 읽기 (없으면 MONTHLY_BATCH_PENDING)
- ['trade_quality_json', (function() {
- try {
- var ss2 = getSpreadsheet_();
- var setSh = ss2.getSheetByName('settings');
- if (!setSh) return JSON.stringify({ status: 'MONTHLY_BATCH_PENDING', last_computed: null, formula_id: 'TRADE_QUALITY_SCORER_V1' });
- var sData = setSh.getDataRange().getValues();
- for (var si = 0; si < sData.length; si++) {
- if (String(sData[si][0] || '').trim() === 'trade_quality_json') {
- var raw = sData[si][1];
- if (raw) {
- var s = String(raw);
- return s.length <= 45000 ? s : JSON.stringify({ status: 'OVERSIZED_TRIMMED', formula_id: 'TRADE_QUALITY_SCORER_V1' });
- }
- break;
- }
- }
- } catch(e) { Logger.log('[HARNESS_ROWS] trade_quality_json 읽기 오류: ' + e.message); }
- return JSON.stringify({ status: 'MONTHLY_BATCH_PENDING', last_computed: null, formula_id: 'TRADE_QUALITY_SCORER_V1' });
- })()],
-
- // PATTERN_BLACKLIST_AUTO_V1 — 월간 배치 결과 캐시 읽기
- ['pattern_blacklist_status', (function() {
- try {
- var ss3 = getSpreadsheet_();
- var setSh3 = ss3.getSheetByName('settings');
- if (!setSh3) return 'INACTIVE';
- var sData3 = setSh3.getDataRange().getValues();
- for (var si3 = 0; si3 < sData3.length; si3++) {
- if (String(sData3[si3][0] || '').trim() === 'pattern_blacklist_json') {
- try {
- var parsed = JSON.parse(String(sData3[si3][1] || '{}'));
- var hasTriggered = Array.isArray(parsed.patterns) &&
- parsed.patterns.some(function(p) { return p.pattern_blacklist_status === 'TRIGGERED'; });
- return hasTriggered ? 'TRIGGERED' : 'INACTIVE';
- } catch(pe) { break; }
- }
- }
- } catch(e) { Logger.log('[HARNESS_ROWS] pattern_blacklist_status 읽기 오류: ' + e.message); }
- return 'INACTIVE';
- })()],
- ['pattern_blacklist_json', (function() {
- try {
- var ss4 = getSpreadsheet_();
- var setSh4 = ss4.getSheetByName('settings');
- if (!setSh4) return JSON.stringify({ status: 'INACTIVE', patterns: [], pattern_count: 0, formula_id: 'PATTERN_BLACKLIST_AUTO_V1' });
- var sData4 = setSh4.getDataRange().getValues();
- for (var si4 = 0; si4 < sData4.length; si4++) {
- if (String(sData4[si4][0] || '').trim() === 'pattern_blacklist_json') {
- var raw4 = sData4[si4][1];
- if (raw4) return String(raw4);
- break;
- }
- }
- } catch(e) { Logger.log('[HARNESS_ROWS] pattern_blacklist_json 읽기 오류: ' + e.message); }
- return JSON.stringify({ status: 'INACTIVE', patterns: [], pattern_count: 0, formula_id: 'PATTERN_BLACKLIST_AUTO_V1' });
- })()],
-
- // ── [SPRINT4_SFG_SCALARS] SFG 스칼라 (inject.py 교체) ────────────────────
- ['sfg_v1', 'CLEAR'],
- ['sfg_broken_count', 0],
- ['sfg_failure_rate', 0],
-
- // ── [SPRINT4_PCG] PORTFOLIO_CORRELATION_GATE_V1 (inject.py 교체) ─────────
- ['portfolio_correlation_gate_json',
- JSON.stringify({ correlation_gate_status: 'INSUFFICIENT_DATA', satellite_cluster_beta: null,
- effective_portfolio_beta: null, regime_beta_limit: 1.0,
- reason: 'GAS 초기값 — inject.py 교체 대상',
- formula_id: 'PORTFOLIO_CORRELATION_GATE_V1' })],
- ['correlation_gate_status', 'INSUFFICIENT_DATA'],
-
- // TICK_NORMALIZER_V1 — 종목별 호가 정규화 가격 맵 (Python inject 보완)
- ['tick_normalized_prices_json', (function() {
- var prices4 = (h4 || {}).prices || [];
- var map = {};
- prices4.forEach(function(p) {
- if (!p.ticker) return;
- var sp = p.stop_price ? tickNormalize_(p.stop_price) : null;
- var tp = p.tp1_price ? tickNormalize_(p.tp1_price) : null;
- map[p.ticker] = { stop: sp, tp1: tp };
- });
- return JSON.stringify(map);
- })()],
-
- // SELL_PRICE_SANITY — 종목별 상세 (ratchet_v2 per-ticker)
- ['ratchet_v2_per_ticker_json', (function() {
- var pp = (hApex || {}).profit_preservation_json || [];
- return JSON.stringify(pp.map(function(r) {
- return { ticker: r.ticker || '', profit_pct: r.profit_pct || 0,
- ratchet_stage_v2: r.profit_preservation_state || 'NORMAL',
- auto_trailing_stop_v2: r.auto_trailing_stop || null };
- }));
- })()],
-
- // ── LLM 종합 지침 V6 (SPRINT 1: D1-ROUTING·D2-LLM·A2-ANTI_CHASE·K2-REBOUND 추가) ────
- ['llm_instruction',
- 'HARNESS_AUTHORITATIVE_V4(H4): '
- + '▶ 재계산 금지: sell_priority_lock·quantities_lock·prices_lock·decision_lock·alpha_shield_lock·regime_trim_lock=true — '
- + 'GAS 확정값을 LLM이 재계산·수정·추가·삭제하는 행위는 HARNESS_VIOLATION으로 보고서 전체 무효. '
- + '▶ [HS009] TP 유효성 잠금: prices_json의 tp1_price/tp2_price가 null이면 INVALID_TP_STALE — '
- + 'LLM이 대체 TP 가격을 임의 산출하는 것 절대 금지. tp1_state/tp2_state 그대로 보고. '
- + '▶ [HS010] WATCH/BLOCKED 출력 잠금: order_blueprint_json의 validation_status!=PASS인 행은 '
- + '지정가·손절가·익절가·수량 전부 null. LLM이 참고값이라도 HTS 주문 표에 숫자 기재 금지. '
- + '감시값은 별도 "WATCH 감시 원장(주문 아님)" 섹션으로만 표시. '
- + '▶ [HS011] LLM 즉석 공식 정의 금지: spec/13_formula_registry.yaml에 등록되지 않은 공식명·알고리즘명을 '
- + '즉석 정의하고 이에 기반한 원화 가격·정수 수량을 산출하는 것 절대 금지. '
- + '하네스 미구현 영역은 "DATA_MISSING — 하네스 업데이트 필요"로만 표시. '
- + '▶ [M1] 국면별 감축: regime_trim_guidance_json의 satellite_trim_pct/leader_trim_pct 범위를 그대로 인용. '
- + 'LLM이 임의 감축비율을 제시하는 것 금지. '
- + '▶ [H3] 주도주 게이트: secular_leader_gate_json의 active/status를 그대로 보고. '
- + '005930·000660 종목에서 secular_leader_gate_active=true이면 '
- + 'tp1_state=DEFERRED_SECULAR_LEADER 구간에서 TP 매도 신호 생성 금지. '
- + '하네스가 null로 전달한 tp1_price를 LLM이 임의 복원하는 것 절대 금지. '
- + '▶ Blueprint 무결성: order_blueprint_json 수정 절대 금지. blueprint_checksum(CRC32_V1) Python 검증. '
- + '▶ 구조화 출력 강제: [Rule_ID:X, Value:Y, Threshold:Z, Result:R] 포맷만 허용. '
- + '▶ Zero-Adjective: 감성 형용사·부사 금지. 수치와 Rule_ID만 허용. '
- + '▶ P4 장중 모드(intraday_lock=true): p4_intraday_allowed_actions 외 액션 출력 금지. '
- + '▶ CLAMP 발동 종목은 clamp_label 표기 필수. '
- + '▶ [M4] 목표 자산 추적: goal_achievement_pct·goal_remaining_krw·goal_eta_label은 하네스 산출값 그대로 보고. '
- + 'LLM이 5억원 달성 여부·ETA를 독자적으로 재계산하는 것 절대 금지. '
- + '▶ [G1] 현금 부족액 잠금(CASH_SHORTFALL_V1): cash_shortfall_min_krw·cash_shortfall_target_krw는 하네스 확정값. '
- + '"약 N원 필요" 형태의 LLM 즉석 계산 절대 금지. cash_current_pct_d2·cash_target_pct도 하네스 복사 전용. '
- + '▶ [G2] TRIM 계획 잠금(TRIM_PLAN_MIN_CASH_V1): trim_plan_to_min_cash_json은 H2 매도우선순위 기반 GAS 확정. '
- + 'LLM이 현금 회복을 위해 임의로 종목·수량·순서를 선택하는 것 절대 금지. 하네스 plan 복사만 허용. '
- + '▶ [APEX_V1] 판단 자료 생성시점 로직: alpha_lead_json·distribution_risk_json·buy_permission_json·'
- + 'cash_raise_plan_json·smart_sell_quantities_json·execution_quality_json은 GAS 확정값. '
- + '뒷북매수/설거지/현금확보 매도 방식은 LLM 해석 금지, *_lock=true 값 그대로 복사. '
- + 'buy_permission_state가 ALLOW_*가 아니면 BUY 수량 출력 금지. '
- + 'execution_style=OVERSOLD_REBOUND_SELL이면 rebound_wait_qty를 immediate_qty로 이동 금지. '
- + '▶ [ENTRY_FRESHNESS_GATE_V1] entry_freshness_json 없이 뒷북/추격 BUY 승인 금지. '
- + 'BLOCK_LATE_CHASE/PULLBACK_WAIT는 BUY/STAGED_BUY/ADD_ON 차단. '
- + '▶ [SELL_VALUE_PRESERVATION_GATE_V1] sell_value_preservation_json 없이 현금확보 매도와 수익보호 매도 혼용 금지. '
- + 'EMERGENCY_EXIT 외에는 반등대기 수량을 즉시매도로 승격 금지. '
- + '▶ [INDEX_RELATIVE_HEALTH_GATE_V1] index_relative_health_json 없이 지수 대비 괴리 종목을 BUY 승인 금지. '
- + 'DECOUPLED/OVER_EXTENDED는 신규 BUY 차단, UNDERPERFORMING은 WATCH 우선. '
- + '▶ [HS010-B] 종합 판단 제안표 필수 출력: comprehensive_proposal_json을 "종합 판단 제안표(PROPOSAL)" 표로 '
- + '항상 출력. PENDING_EXPORT·BLOCKED·DATA_MISSING 상태와 무관하게 생략 금지. '
- + '판단은 사용자 몫이므로 reference_stop_price·reference_tp1_price·tp1_state·reference_tp2_price·tp2_state·'
- + 'proposed_immediate_qty·proposed_staged_qty·expected_cash_krw를 그대로 표시. '
- + '이 표에서 LLM이 가격·수량을 임의로 변경하거나 새 수치를 추가하는 것 절대 금지. '
- + '▶ [HS010-C] 위성 후보 스크리닝 표 필수 출력: satellite_candidate_json을 "위성 후보 스크리닝(SATELLITE_CANDIDATE_SCREEN_V1)" 표로 '
- + '항상 출력. 후보가 0개여도 표를 출력하고 "현재 추가 적합 후보 없음"을 명시. '
- + 'satellite_candidate_summary.watch_candidates를 항상 표 제목에 병기. '
- + 'LLM이 universe 외 종목을 임의 추가하거나 grade를 변경하는 것 금지. '
- + '▶ [D1-ROUTING] 9단계 결정론적 라우팅 의무: 보고서는 routing_execution_log의 '
- + '9단계 순서(①신선도→②장중판별→③포트폴리오상태→④매도레이더→⑤매수타이밍→'
- + '⑥현금확보→⑦가격정규화→⑧RS/위성→⑨LLM서빙) 결과를 먼저 표 형태로 출력하고 '
- + '이후 분석을 진행한다. routing_execution_log 생략 시 INCOMPLETE_ROUTING_LOG 처리. '
- + '▶ [D2-LLM] LLM 8금지(위반 시 INVALID_LLM_OVERRIDE): '
- + '①미등록공식 지정가/수량 산출 금지 '
- + '②하네스BLOCK 판정 우회("그래도매수") 금지 '
- + '③SELL_PRICE_SANITY INVALID 가격 복원 금지 '
- + '④cash_shortfall LLM 즉석계산 금지 '
- + '⑤K2 반등대기 수량을 "현금급함"으로 즉시전환 금지 '
- + '⑥APEX_SUPER 구간 trailing_stop 미병기 금지 '
- + '⑦DISTRIBUTION_CONFIRMED 매수 우회 금지 '
- + '⑧routing_execution_log 생략 금지. '
- + '▶ [A2-ANTI_CHASE] anti_chasing_velocity_json의 anti_chase_verdict=BLOCK_CHASE인 '
- + '종목은 당일 신규 BUY 절대 금지. PULLBACK_WAIT는 pullback_entry_trigger_price 도달 전 매수 금지. '
- + 'distribution_sell_detector_json의 distribution_verdict=DISTRIBUTION_CONFIRMED인 종목 BUY 절대 금지. '
- + '▶ [K2-REBOUND] cash_recovery_plan_json의 rebound_wait_qty는 '
- + 'rebound_trigger_price 도달 전 즉시매도 전환 금지. "현금이 급하니까" 이유로 '
- + 'Stage 2 즉시전환 금지. emergency_full_sell=true일 때만 전량 즉시 허용. '
- + '▶ [PA47-A1] watch_breakout_candidates_json 필수 출력: promotion_eligible=true 항목을 '
- + '"급등 탐지 — 라이프사이클 재검토 권고" 표로 출력. '
- + 'lifecycle_stage=EXIT이어도 breakout_signal=WATCH_BREAKOUT_DETECTED면 즉시 매도 금지; '
- + 'satellite_lifecycle_gate_json의 breakout_promotion_recommendation=PROMOTE_TO_WATCH 참조. '
- + '후보가 0건이면 표 생략 가능. '
- + '▶ [PA47-PA1] buy_permission_json의 pa1_synthesis_verdict·pa1_direction_confidence 반드시 인용: '
- + 'EXIT_SIGNAL(dc<-30) 종목은 "방향성 부적합—보유 재검토", TRIM_SIGNAL(dc<-10) 종목은 '
- + '"비중 축소 검토"로 표시. STRONG_BUY/MODERATE_BUY 종목은 신규 진입 우선순위 상향. '
- + 'pa1_synthesis_verdict가 없는 종목은 PA1 미적용으로 명시. '
- + '▶ [PA47-A3] anti_whipsaw_reentry_json의 reentry_signal=REENTRY_CANDIDATE 종목은 '
- + '"매도 재검토 — 반등 감지" 경고로 표시. 매도 실행 전 재확인 의무. '
- + 'reentry_grade=A/B이면 매도 보류 후 다음날 재평가 권고. '
- + '▶ [PA47-B4] harness_generation_status=BLOCKED_STALE_DATA 또는 BLOCKED_CV_FAIL이면 '
- + '보고서 생성을 거부하고 "하네스 BLOCK — 데이터 갱신 후 재실행 요망"만 출력. '
- + '▶ [PROPOSAL50-EG] export_gate_json의 json_validation_status=PENDING_EXPORT이면 '
- + 'hts_entry_allowed=false — HTS 주문 입력 절대 금지. failed_checks와 resolution_guide를 출력. '
- + '▶ [PROPOSAL50-EJCE] ejce_json의 consensus_result=NO_BUY 종목은 3개 관점 중 2개 이상 BLOCK — '
- + 'buy_permission이 ALLOW여도 EJCE NO_BUY 종목 BUY 실행 금지. block_reasons 인용 필수. '
- + '▶ [PROPOSAL50-SCRS] scrs_v2_json의 selected_combo만 현금확보 매도 기재 허용. '
- + 'immediate_sell_qty와 rebound_wait_qty 구분 표시 의무. '
- + 'emergency_level=TRIM_ONLY이면 추가 매도 금지. '
- + '▶ [PROPOSAL50-DSLE] serving_lock_json의 llm_serving_budget.numeric_generation_allowed=0 — '
- + 'LLM이 가격·수량·수익률 등 숫자를 자체 생성하는 것 절대 금지. '
- + '▶ [PROPOSAL50-H10] shadow_ledger_json은 BLOCKED/INVALID 블루프린트를 투명하게 보존. '
- + '산출 지정가·손절가·익절가·이론수량을 null 처리하거나 은폐 금지(HS010). '
- + '사용자의 사후 평가·오버라이드를 위해 "투명한 감시 원장" 표로 출력. '
- + '▶ [PROPOSAL50-D2] llm_serving_constraint_json의 constraint_status=INVALID_LLM_OVERRIDE이면 '
- + '보고서 조립 중단 — violations 목록 전체를 "[INVALID_LLM_OVERRIDE: 사유]"로 표시 후 재실행 요망.'],
-
- // ── [PROPOSAL50] MRAG-V2 + M5 V1.1 의무감축계획 ─────────────────────────────────────────
- ['mrag_v2_json', JSON.stringify((hApex || {}).mrag_v2_json || {})],
- ['effective_heat_gate_threshold', (hApex || {}).effective_heat_gate_threshold || null],
- ['effective_position_size_scale', (hApex || {}).effective_position_size_scale || null],
- ['mandatory_reduction_json', JSON.stringify((hApex || {}).mandatory_reduction_json || {})],
-
- // ── [PROPOSAL50] Export Gate / Routing Trace / Watch Ledger / EJCE / SCRS-V2 / DSLE ──────
- ['export_gate_json', JSON.stringify((hApex || {}).export_gate_json || {})],
- ['hts_entry_allowed', String((hApex || {}).hts_entry_allowed || false)],
- ['routing_trace_json', JSON.stringify((hApex || {}).routing_trace_json || {})],
- ['watch_ledger_json', JSON.stringify((hApex || {}).watch_ledger_json || [])],
- ['ejce_json', JSON.stringify((hApex || {}).ejce_json || [])],
- ['scrs_v2_json', JSON.stringify((hApex || {}).scrs_v2_json || {})],
- ['serving_lock_json', JSON.stringify((hApex || {}).serving_lock_json || {})],
-
- // ── [PROPOSAL50-P0-GAP] H10/D2 신규 필드 ───────────────────────────────────────────────────
- ['shadow_ledger_json', JSON.stringify((hApex || {}).shadow_ledger_json || { shadow_ledger: [], blocked_count: 0 })],
- ['llm_serving_constraint_json', JSON.stringify((hApex || {}).llm_serving_constraint_json || { constraint_status: 'DATA_MISSING' })],
-
- // ── [PROPOSAL51] SU_51_K 신규 필드 ────────────────────────────────────────────────────────
- ['cluster_sync_result_json', JSON.stringify((hApex || {}).cluster_sync_result_json || {})],
- ['proactive_sell_radar_json', JSON.stringify((hApex || {}).proactive_sell_radar_json || [])],
- ['sell_pass_accuracy_rate', (hApex || {}).sell_pass_accuracy_rate !== undefined
- ? (hApex || {}).sell_pass_accuracy_rate : null],
- ['sell_execution_quality_json', JSON.stringify((hApex || {}).sell_execution_quality_json || [])],
- // ── [PROPOSAL51] P0-D / P1-B / P1-C 신규 필드 ──────────────────────────────────────────
- ['price_hierarchy_json', JSON.stringify((hApex || {}).price_hierarchy_json || [])],
- ['data_quality_gate_v2_json', JSON.stringify((hApex || {}).data_quality_gate_v2_json || {})],
- ['cash_recovery_display_json', JSON.stringify((hApex || {}).cash_recovery_display_json || {})],
- ['portfolio_health_json', JSON.stringify((hApex || {}).portfolio_health_json || {})],
- // [PROPOSAL53] 신규 P0 하네스
- ['fundamental_quality_json', JSON.stringify((hApex || {}).fundamental_quality_json || {})],
- ['horizon_allocation_json', JSON.stringify((hApex || {}).horizon_allocation_json || {})],
- ['smart_money_liquidity_json', JSON.stringify((hApex || {}).smart_money_liquidity_json || {})],
- ['routing_serving_trace_v2_json',JSON.stringify((hApex || {}).routing_serving_trace_v2_json|| {})],
- ['fundamental_multifactor_json', JSON.stringify((hApex || {}).fundamental_multifactor_json || {})],
- ['earnings_growth_quality_json', JSON.stringify((hApex || {}).earnings_growth_quality_json || {})],
- ['market_share_proxy_json', JSON.stringify((hApex || {}).market_share_proxy_json || {})],
- ['cashflow_stability_json', JSON.stringify((hApex || {}).cashflow_stability_json || {})],
- ['routing_decision_explain_json', JSON.stringify((hApex || {}).routing_decision_explain_json || {})],
-
- // [PROPOSAL47_B4] STALE_BLOCK enforcement: cv_verdict=BLOCK 시 생성 차단 마커
- ['harness_generation_status', (function() {
- var verdict = (hApex || {}).cv_verdict || '';
- var cvReport = (hApex || {}).consistency_report_json || {};
- var failedList = cvReport.failed || [];
- var staleBlock = failedList.some(function(f) {
- return f && typeof f.reason === 'string' && f.reason.indexOf('STALE_BLOCK') >= 0;
- });
- if (verdict === 'BLOCK' && staleBlock) return 'BLOCKED_STALE_DATA';
- if (verdict === 'BLOCK') return 'BLOCKED_CV_FAIL';
- return 'OK';
- })()]
- ];
-}
-
-
-/**
- * F3: buildHarnessRows_ 출력 완전성 자체검증
- * 19_harness_contract.yaml required_harness_context_keys 기준 필수 키 누락 체크.
- * 누락 키가 있으면 Logger.log 경고 — 운영 배포 전 조기 감지.
- */
-function assertHarnessRowsComplete_(rows) {
- var REQUIRED_KEYS = [
- // H1 포트폴리오 가드
- 'harness_version', 'captured_at', 'request_route', 'route_reason_code',
- 'bundle_selected', 'prompt_entrypoint', 'json_validation_status', 'capture_required',
- 'cash_ledger_basis', 'intraday_lock', 'snapshot_execution_gate', 'snapshot_execution_reason',
- 'immediate_cash_krw', 'settlement_cash_d2_krw',
- 'open_order_amount_krw', 'buy_power_krw', 'cash_floor_status', 'total_heat_pct',
- 'heat_gate_status', 'heat_gate_threshold_pct', 'sell_priority_lock', 'quantities_lock', 'prices_lock',
- 'decision_lock', 'blueprint_row_count', 'blueprint_checksum', 'blueprint_hash_algo',
- 'source_manifest_checksum', 'decision_trace_checksum', 'checksum_hash_algo',
- // Collections
- 'source_manifest_json', 'allowed_actions', 'blocked_actions',
- 'account_snapshot_freshness_json',
- 'sell_candidates_json', 'sell_quantities_json', 'buy_qty_inputs_json',
- 'prices_json', 'decisions_json', 'decision_trace_json',
- 'order_blueprint_json', 'p4_intraday_allowed_actions',
- 'proposal_reference_json', 'proposal_reference_lock',
- 'regime_trim_guidance_json', 'secular_leader_gate_json',
- 'backdata_feature_bank_json',
- // G1 현금 부족액 잠금 (CASH_SHORTFALL_V1)
- 'cash_current_pct_d2', 'cash_target_pct', 'cash_shortfall_min_krw', 'cash_shortfall_target_krw',
- // G2 현금 회복 TRIM 계획 (TRIM_PLAN_MIN_CASH_V1)
- 'trim_plan_to_min_cash_json',
- // APEX V1 판단자료 생성 시점 로직 하네스
- 'alpha_lead_json', 'alpha_lead_lock', 'backdata_feature_bank_json', 'backdata_learning_lock',
- 'follow_through_json', 'follow_through_lock',
- 'distribution_risk_json', 'distribution_lock', 'profit_preservation_json', 'profit_preservation_lock',
- 'cash_raise_plan_json', 'rebound_sell_trigger_json', 'smart_sell_quantities_json', 'smart_cash_raise_lock',
- 'execution_quality_json', 'execution_quality_lock', 'buy_permission_json', 'limit_price_policy_json',
- 'regime_adjusted_sell_priority_json', // K3: 국면·섹터 연계 H2 동적 우선순위
- 'benchmark_relative_timeseries_json',
- 'index_relative_health_json',
- 'saqg_json',
- 'cash_creation_purpose_lock_json',
- 'alpha_feedback_json',
- 'alpha_evaluation_window_json',
- 'entry_freshness_json',
- 'sell_value_preservation_json',
- 'sector_rotation_momentum_json', // L1: 섹터 로테이션 모멘텀 추적
- // M1-M5 신규
- 'drawdown_guard_state', 'drawdown_buy_scale',
- 'portfolio_beta_gate', 'portfolio_beta_gate_json',
- 'tp_quantity_ladder_json', 'event_risk_json',
- 'sector_concentration_gate', 'sector_concentration_json',
- // N1-N5 신규
- 'regime_size_scale',
- 'stop_adequacy_json',
- 'holding_stale_json',
- 'regime_cash_uplift_min_pct',
- // O1-O5 신규
- 'single_position_weight_gate',
- 'semiconductor_cluster_gate',
- 'portfolio_drawdown_gate',
- 'win_loss_streak_state', 'win_loss_streak_buy_scale',
- 'position_count_gate', 'position_count',
- // O-group collections
- 'single_position_weight_json',
- 'semiconductor_cluster_json',
- // P1-P5 실시간 경보 & 건전성
- 'stop_breach_gate', 'stop_breach_alert_json',
- 'tp_trigger_gate',
- 'heat_concentration_gate',
- 'regime_transition_type',
- 'portfolio_health_label', 'portfolio_health_score',
- 'portfolio_health_blocked_json',
- // M4 목표 자산 추적
- 'goal_asset_krw', 'goal_current_asset_krw', 'goal_achievement_pct',
- 'goal_remaining_krw', 'goal_eta_label', 'goal_status',
- // ── [2026-05-20_HARNESS_V5] H6/H7/H8 신규 게이트
- 'breakout_quality_gate_json', 'breakout_quality_gate_lock',
- 'anti_whipsaw_gate_json', 'anti_whipsaw_gate_lock',
- 'smart_cash_raise_json', 'smart_cash_raise_route',
- 'follow_through_confirm_json', 'follow_through_confirm_lock',
- // ── [2026-05-20_HARNESS_V5] 4종 결정론적 체크섬
- 'input_snapshot_checksum', 'rendered_output_checksum', 'rendered_report_checksum', 'non_deterministic_flag',
- // ── [2026-05-21_CLA_HARNESS_V1] SFG
- 'satellite_failure_gate_json', 'sapg_json', 'sfg_v1_lock',
- // ── [SPRINT2_REGIME_CLA_V1] CLA 게이트 + RAG + RS_VERDICT V2 FUSION
- 'regime_cla_json', 'cla_exit_status', 'rag_v1', 'rag_reason',
- 'rs_verdict_source', 'rs_verdict_v1_raw',
- // ── [SPRINT3_L4] PRE_DISTRIBUTION_EARLY_WARNING_V1
- 'pre_distribution_warning',
- // ── [SPRINT4] SFG 스칼라 / F2 / PCG
- 'sfg_v1', 'sfg_broken_count', 'sfg_failure_rate',
- 'pattern_blacklist_json',
- 'portfolio_correlation_gate_json', 'correlation_gate_status',
- // ── [3RD_HARNESS_V1] 커버리지 완성 30개 필드
- 'data_freshness_status', 'intraday_scope',
- 'profit_lock_stage', 'auto_trailing_stop',
- 'auto_trailing_stop_v2', 'ratchet_stage_v2',
- 'flow_acceleration_status',
- 'distribution_sell_detector_status', 'signals_count',
- 'breakout_quality_score',
- 'anti_chasing_verdict', 'anti_chasing_velocity_status',
- 'pullback_entry_verdict', 'pullback_entry_trigger_price',
- 'cash_recovery_plan_json', 'waterfall_plan_json',
- 'sell_timing_verdict', 'sell_execution_window',
- 'preservation_verdict', 'tick_normalized_price',
- 'sell_price_sanity_status',
- 'brt_verdict', 'brt_rs_slope', 'rs_verdict',
- 'saqg_verdict', 'sapg_verdict',
- 'serving_constraint_check', 'routing_execution_log',
- 'trade_quality_json', 'pattern_blacklist_status',
- 'tick_normalized_prices_json', 'ratchet_v2_per_ticker_json',
- // SPRINT 1 신규 필드 (Direction O1/O2/O5/P1/P3/P5/A2/B1/B3/K2/C1/D1)
- 'semiconductor_cluster_json',
- 'single_position_weight_json',
- 'position_count', 'position_count_max', 'position_count_gate',
- 'stop_breach_alert_json',
- 'relative_stop_gate', 'relative_stop_signal_json',
- 'heat_concentration_json',
- 'portfolio_health_blocked_json',
- 'anti_chasing_velocity_json',
- 'distribution_sell_detector_json',
- 'k2_staged_rebound_sell_json',
- 'cash_recovery_plan_json',
- // [PROPOSAL50] 신규 필수 필드 (P0-P2)
- 'export_gate_json', 'json_validation_status', 'hts_entry_allowed',
- 'routing_trace_json', 'watch_ledger_json', 'ejce_json', 'scrs_v2_json', 'serving_lock_json',
- 'mrag_v2_json', 'mandatory_reduction_json',
- // [PROPOSAL50-P0-GAP] H10/D2 신규 필드
- 'shadow_ledger_json', 'llm_serving_constraint_json',
- // [PROPOSAL51] P0-D / P1-B / P1-C 신규 필드
- 'price_hierarchy_json', 'data_quality_gate_v2_json', 'cash_recovery_display_json',
- // [PROPOSAL53]
- 'fundamental_quality_json', 'horizon_allocation_json',
- 'smart_money_liquidity_json', 'routing_serving_trace_v2_json'
- ,'fundamental_multifactor_json','earnings_growth_quality_json','market_share_proxy_json',
- 'cashflow_stability_json','routing_decision_explain_json'
- ];
- var keySet = {};
- for (var i = 0; i < rows.length; i++) {
- if (Array.isArray(rows[i]) && rows[i].length >= 1) {
- keySet[rows[i][0]] = true;
- }
- }
- var missing = REQUIRED_KEYS.filter(function(k) { return !keySet[k]; });
- if (missing.length > 0) {
- Logger.log('[HARNESS_CONTRACT_FAIL] buildHarnessRows_ missing required keys: ' + missing.join(', '));
- } else {
- Logger.log('[HARNESS_CONTRACT_OK] All ' + REQUIRED_KEYS.length + ' required keys present.');
- }
- return missing;
-}
-
-/**
- * YAML_FORMULA_BINDING_REGISTRY_V1
- * spec 공식 ID와 GS 구현/연계 지점 연결 레지스트리 (커버리지 계량용)
- */
-var YAML_FORMULA_BINDING_REGISTRY_V1 = {
- BUY_TIMING_SUITABILITY_V1: "core_satellite timing gate binding",
- CASH_RATIOS_V1: "cash ledger binding",
- ECP_RISK_SCALE_V1: "risk scale binding",
- EXECUTION_QUALITY_SCORE_V1: "execution quality binding",
- EXPECTED_EDGE_V1: "expected edge binding",
- FINANCIAL_HEALTH_SCORE_V1: "financial health binding",
- OVERSOLD_DELAY_V1: "oversold delay binding",
- PEG_SCORE_V1: "valuation peg binding",
- PORTFOLIO_BAND_STATUS_V1: "portfolio band binding",
- PORTFOLIO_BETA_V1: "factor beta binding",
- RS_MOMENTUM_V1: "rs momentum binding",
- SEA_TIMING_V1: "sell timing binding",
- SELL_CONFLICT_AWARE_RECOMMENDATION_V1: "sell conflict binding",
- STOP_PROPOSAL_LADDER_V1: "proposal stop ladder binding",
- T1_FORCED_SELL_RISK_V1: "t+1 forced sell risk binding"
-};
diff --git a/gas_lib.gs b/gas_lib.gs
deleted file mode 100644
index 56898e3..0000000
--- a/gas_lib.gs
+++ /dev/null
@@ -1,2965 +0,0 @@
-// gas_lib.gs - Common utilities & static features
-// Last Updated: 2026-06-13 18:48:40 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);
- }
- });
-}
-
-function getAlphaFeedbackJson_() {
- var defaultPayload = {
- formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
- as_of: '',
- analysis_period: '',
- status: 'DATA_MISSING',
- cases_analyzed: 0,
- grade_count: 0,
- eligible_t20_fail_rate: null,
- eligible_t60_fail_rate: null,
- recommended_filter_adjustments: [],
- grade_summary: []
- };
- try {
- var settings = readSettingsTab_();
- var raw = settings['afl_v1_last_result'];
- if (!raw) return defaultPayload;
- var payload = typeof raw === 'string' ? JSON.parse(raw) : raw;
- return payload && typeof payload === 'object' ? payload : defaultPayload;
- } catch (e) {
- Logger.log('[AFL] getAlphaFeedbackJson_ error: ' + e.message);
- return defaultPayload;
- }
-}
-
-// ── settings 탭 읽기 → 사용자 입력 파라미터 (total_asset 등) ────────────────
-// settings 탭: row2=헤더(key|value|note), row3+=데이터
-// 없으면 빈 객체 반환 (각 호출처에서 null 처리)
-function readSettingsTab_() {
- const result = {};
- try {
- const ss = getSpreadsheet_();
- const sheet = ss.getSheetByName("settings");
- if (!sheet) { Logger.log("readSettingsTab_: settings 탭 없음"); return result; }
- const data = sheet.getDataRange().getValues();
- // 헤더·메타 행 자동 스킵 — "key", "updated", "date" 등 예약어 및 빈 셀 무시
- const SKIP_KEYS = new Set(["key", "updated", "date", "항목", "파라미터"]);
- for (let i = 0; i < data.length; i++) {
- const rawKey = String(data[i][0] ?? "").trim();
- if (!rawKey || SKIP_KEYS.has(rawKey.toLowerCase())) continue;
- const val = data[i][1];
- if (val !== "" && val != null) result[rawKey] = val;
- }
- try {
- var verbose = String(PropertiesService.getScriptProperties().getProperty('HARNESS_VERBOSE_LOG') || '').toLowerCase() === 'true';
- if (verbose) Logger.log("readSettingsTab_ 로드됨: " + Object.keys(result).join(", "));
- } catch (e) {}
- } catch(e) { handleFetchError_("readSettingsTab_", e, "CRITICAL"); }
- return result;
-}
-
-// ── performance 탭 읽기 → Bayesian multiplier 계산 ──────────────────────────
-// spec/17_performance_contract.yaml 구현.
-// performance 탭이 없거나 청산 완료 거래 5건 미만이면 medium_confidence(0.5×) 반환.
-function readPerformanceSheet_() {
- const DEFAULT = { bayesian_multiplier: 0.5, bayesian_label: "medium_confidence", trades_used: 0,
- win_rate_30: null, net_expectancy_30: null, consecutive_losses: 0,
- bayesian_data_source: "default" };
- try {
- const ss = getSpreadsheet_();
- const sheet = ss.getSheetByName("performance");
- if (!sheet) return DEFAULT;
- const data = sheet.getDataRange().getValues();
- if (data.length < 3) return DEFAULT;
- const hdr = data[1].map(h => String(h).trim());
- const pnlIdx = hdr.indexOf("pnl_pct");
- const exitIdx = hdr.indexOf("exit_date");
- const exitDateIdx = hdr.indexOf("exit_date");
- if (pnlIdx < 0 || exitIdx < 0) return DEFAULT;
-
- // 청산 완료 거래만 (exit_date 있음) — 최신 30건
- const closed = [];
- for (let i = 2; i < data.length; i++) {
- const exitVal = data[i][exitIdx];
- if (!exitVal || String(exitVal).trim() === "") continue;
- const pnl = parseFloat(data[i][pnlIdx]);
- if (!Number.isFinite(pnl)) continue;
- const exitRaw = exitDateIdx >= 0 ? data[i][exitDateIdx] : exitVal;
- const exitMs = exitRaw instanceof Date && !isNaN(exitRaw.getTime())
- ? exitRaw.getTime()
- : new Date(exitRaw).getTime();
- closed.push({ pnl, exitMs: Number.isFinite(exitMs) ? exitMs : 0 });
- }
- if (closed.length === 0) return DEFAULT;
- closed.sort((a, b) => b.exitMs - a.exitMs);
- const recent = closed.slice(0, 30).map(r => r.pnl);
- const n = recent.length;
- if (n < 5) return DEFAULT;
-
- const wins = recent.filter(p => p > 0);
- const losses = recent.filter(p => p <= 0);
- const winRate = wins.length / n;
- const avgWin = wins.length > 0 ? wins.reduce((a,b)=>a+b,0)/wins.length : 0;
- const avgLoss = losses.length > 0 ? losses.reduce((a,b)=>a+Math.abs(b),0)/losses.length : 0;
- const netExp = winRate * avgWin - (1 - winRate) * avgLoss;
-
- // 연속 손절 체크
- let consLoss = 0;
- for (const p of recent) {
- if (p <= 0) consLoss++;
- else break;
- }
-
- let multiplier, label;
- if (consLoss >= 5) {
- multiplier = 0.0; label = "no_bet";
- } else if (winRate >= 0.60 && netExp >= 3.0) {
- multiplier = 1.0; label = "high_bet";
- } else if (winRate >= 0.45 && netExp >= 0) {
- multiplier = 0.5; label = "medium_bet";
- } else {
- multiplier = 0.25; label = "low_bet";
- }
-
- return {
- bayesian_multiplier: multiplier,
- bayesian_label: label,
- trades_used: n,
- win_rate_30: parseFloat(winRate.toFixed(3)),
- net_expectancy_30: parseFloat(netExp.toFixed(2)),
- consecutive_losses: consLoss,
- bayesian_data_source: "actual",
- };
- } catch(e) {
- handleFetchError_("readPerformanceSheet_", e, "WARN");
- return DEFAULT;
- }
-}
-
-// ── 섹터 자금 흐름 ────────────────────────────────────────────────────────
-const DEFAULT_SECTOR_UNIVERSE_V2 = [
- { sector: "반도체", proxyTicker: "091160", proxyName: "KODEX 반도체", proxyType: "ETF", baseTicker: "069500", constituents: [
- { code: "005930", name: "삼성전자", weight: 0.50 },
- { code: "000660", name: "SK하이닉스", weight: 0.35 },
- { code: "042700", name: "한미반도체", weight: 0.10 },
- { code: "091160", name: "KODEX 반도체", weight: 0.05, isEtf: true },
- ]},
- { sector: "AI전력", proxyTicker: "0117V0", proxyName: "TIGER 코리아AI전력기기TOP3플러스", proxyType: "ETF", baseTicker: "069500", constituents: [
- { code: "010120", name: "LS ELECTRIC", weight: 0.30 },
- { code: "267260", name: "HD현대일렉트릭", weight: 0.30 },
- { code: "006260", name: "LS", weight: 0.20 },
- { code: "062040", name: "산일전기", weight: 0.10 },
- { code: "298040", name: "효성중공업", weight: 0.10 },
- ]},
- { sector: "방산", proxyTicker: "012450", proxyName: "한화에어로스페이스", proxyType: "대표주", baseTicker: "069500", constituents: [
- { code: "012450", name: "한화에어로스페이스", weight: 0.45 },
- { code: "079550", name: "LIG넥스원", weight: 0.25 },
- { code: "047810", name: "한국항공우주", weight: 0.15 },
- { code: "064350", name: "현대로템", weight: 0.15 },
- ]},
- { sector: "조선", proxyTicker: "494670", proxyName: "TIGER 조선TOP10", proxyType: "ETF", baseTicker: "069500", constituents: [
- { code: "329180", name: "HD현대중공업", weight: 0.35 },
- { code: "042660", name: "한화오션", weight: 0.30 },
- { code: "009540", name: "HD한국조선해양", weight: 0.20 },
- { code: "494670", name: "TIGER 조선TOP10", weight: 0.15, isEtf: true },
- ]},
- { sector: "건설/EPC", proxyTicker: "028050", proxyName: "삼성E&A", proxyType: "대표주", baseTicker: "069500", constituents: [
- { code: "028050", name: "삼성E&A", weight: 0.40 },
- { code: "000720", name: "현대건설", weight: 0.30 },
- { code: "006360", name: "GS건설", weight: 0.20 },
- { code: "047040", name: "대우건설", weight: 0.10 },
- ]},
- { sector: "자동차", proxyTicker: "091180", proxyName: "TIGER 자동차", proxyType: "ETF", baseTicker: "069500", constituents: [
- { code: "005380", name: "현대차", weight: 0.45 },
- { code: "000270", name: "기아", weight: 0.40 },
- { code: "012330", name: "현대모비스", weight: 0.15 },
- ]},
- { sector: "금융/은행", proxyTicker: "091170", proxyName: "KODEX 은행", proxyType: "ETF", baseTicker: "069500", constituents: [
- { code: "105560", name: "KB금융", weight: 0.30 },
- { code: "055550", name: "신한지주", weight: 0.30 },
- { code: "086790", name: "하나금융지주", weight: 0.20 },
- { code: "316140", name: "우리금융지주", weight: 0.10 },
- { code: "003540", name: "대신증권", weight: 0.10 },
- ]},
- { sector: "2차전지", proxyTicker: "305720", proxyName: "KODEX 2차전지산업", proxyType: "ETF", baseTicker: "069500", constituents: [
- { code: "373220", name: "LG에너지솔루션", weight: 0.40 },
- { code: "006400", name: "삼성SDI", weight: 0.30 },
- { code: "051910", name: "LG화학", weight: 0.20 },
- { code: "096770", name: "SK이노베이션", weight: 0.10 },
- ]},
- { sector: "바이오", proxyTicker: "266410", proxyName: "KODEX 헬스케어", proxyType: "ETF", baseTicker: "069500", constituents: [
- { code: "207940", name: "삼성바이오로직스", weight: 0.45 },
- { code: "068270", name: "셀트리온", weight: 0.30 },
- { code: "128940", name: "한미약품", weight: 0.15 },
- { code: "000100", name: "유한양행", weight: 0.10 },
- ]},
- { sector: "원전", proxyTicker: "099440", proxyName: "두산에너빌리티", proxyType: "대표주", baseTicker: "069500", constituents: [
- { code: "099440", name: "두산에너빌리티", weight: 0.45 },
- { code: "023450", name: "한전기술", weight: 0.25 },
- { code: "015760", name: "한국전력", weight: 0.20 },
- { code: "071320", name: "지역난방공사", weight: 0.10 },
- ]},
- { sector: "소비재", proxyTicker: "139220", proxyName: "TIGER 생활소비재", proxyType: "ETF", baseTicker: "069500", constituents: [
- { code: "028260", name: "삼성물산", weight: 0.35 },
- { code: "097950", name: "CJ제일제당", weight: 0.25 },
- { code: "004370", name: "농심", weight: 0.20 },
- { code: "051900", name: "LG생활건강", weight: 0.20 },
- ]},
-];
-
-function runSectorFlow() {
- const rows = runSectorFlowV3();
- writeLegacySectorFlowFromStage2_(rows);
-
- // 연쇄 실행: 매크로 지표
- runMacro();
-}
-
-function normalizeSectorName_(sector) {
- const s = String(sector ?? "").trim();
- if (s === "AI전력/전력기기") return "AI전력";
- if (s === "바이오/헬스케어") return "바이오";
- if (s === "원전/에너지") return "원전";
- if (s === "소비재/유통") return "소비재";
- return s;
-}
-
-function boolFromSheet_(value, defaultValue) {
- if (value === true || value === false) return value;
- const s = String(value ?? "").trim().toUpperCase();
- if (["TRUE","Y","YES","1","사용","사용함"].includes(s)) return true;
- if (["FALSE","N","NO","0","미사용","제외"].includes(s)) return false;
- return defaultValue;
-}
-
-function readSectorUniverse_() {
- const ss = getSpreadsheet_();
- const sheet = ss.getSheetByName("sector_universe");
- if (!sheet) {
- writeDefaultSectorUniverseSheet_();
- return DEFAULT_SECTOR_UNIVERSE_V2;
- }
- const data = sheet.getDataRange().getValues();
- if (data.length < 3) {
- writeDefaultSectorUniverseSheet_();
- return DEFAULT_SECTOR_UNIVERSE_V2;
- }
- const hdr = data[1].map(h => String(h).trim());
- const idx = name => hdr.indexOf(name);
- const required = ["Sector","Proxy_Ticker","Constituent_Code","Weight"];
- if (required.some(h => idx(h) < 0)) return DEFAULT_SECTOR_UNIVERSE_V2;
-
- const map = {};
- for (let i = 2; i < data.length; i++) {
- const enabled = idx("Enabled") >= 0 ? boolFromSheet_(data[i][idx("Enabled")], true) : true;
- if (!enabled) continue;
- const sector = normalizeSectorName_(data[i][idx("Sector")]);
- const code = normalizeTickerCode(data[i][idx("Constituent_Code")]);
- const weight = parseFloat(data[i][idx("Weight")]);
- if (!sector || !code || !Number.isFinite(weight) || weight <= 0) continue;
- if (!map[sector]) {
- map[sector] = {
- sector,
- proxyTicker: normalizeTickerCode(data[i][idx("Proxy_Ticker")]),
- proxyName: idx("Proxy_Name") >= 0 ? String(data[i][idx("Proxy_Name")] ?? "").trim() : "",
- proxyType: idx("Proxy_Type") >= 0 ? String(data[i][idx("Proxy_Type")] ?? "").trim() : "",
- baseTicker: idx("Base_Ticker") >= 0 ? normalizeTickerCode(data[i][idx("Base_Ticker")]) : "069500",
- constituents: [],
- };
- }
- map[sector].constituents.push({
- code,
- name: idx("Constituent_Name") >= 0 ? String(data[i][idx("Constituent_Name")] ?? "").trim() : "",
- weight,
- isEtf: idx("Is_ETF") >= 0 ? boolFromSheet_(data[i][idx("Is_ETF")], false) : false,
- });
- }
- const sectors = Object.values(map).filter(s => s.proxyTicker && s.constituents.length > 0);
- return sectors.length ? sectors : DEFAULT_SECTOR_UNIVERSE_V2;
-}
-
-function writeDefaultSectorUniverseSheet_() {
- const headers = [
- "Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Base_Ticker",
- "Constituent_Code","Constituent_Name","Weight","Is_ETF","Enabled","Effective_Date","Source"
- ];
- const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
- const rows = [];
- for (const sector of DEFAULT_SECTOR_UNIVERSE_V2) {
- for (const c of sector.constituents) {
- rows.push([
- sector.sector,
- sector.proxyTicker,
- sector.proxyName,
- sector.proxyType || "대표주",
- sector.baseTicker || "069500",
- c.code,
- c.name || "",
- c.weight,
- c.isEtf ? "Y" : "N",
- "Y",
- today,
- "sector_universe(DEFAULT_SECTOR_UNIVERSE_V2)",
- ]);
- }
- }
- writeToSheet("sector_universe", headers, rows);
- Logger.log(`sector_universe 기본 템플릿 생성: ${rows.length}행`);
-}
-
-function sectorDataQuality_(coverage, flowRowsMin, staleCount, proxyOk, hasNorm, weightSum) {
- if (!proxyOk || coverage <= 0 || !hasNorm) return "D";
- if (coverage >= 0.80 && flowRowsMin >= 20 && staleCount === 0 && weightSum >= 0.70) return "A";
- if (coverage >= 0.60 && flowRowsMin >= 5 && weightSum >= 0.60) return "B";
- return "C";
-}
-
-function sectorUseMode_(quality) {
- if (quality === "A" || quality === "B") return "TRADE_OK";
- if (quality === "C") return "WATCH_ONLY";
- return "INVALID";
-}
-
-function scoreSmartMoneyNorm_(v) {
- if (!Number.isFinite(v)) return 0;
- if (v >= 0.15) return 25;
- if (v >= 0.05) return 18;
- if (v > 0) return 10;
- if (v > -0.05) return 4;
- return 0;
-}
-
-function scoreBreadth_(v) {
- if (!Number.isFinite(v)) return 0;
- if (v >= 0.70) return 15;
- if (v >= 0.50) return 10;
- if (v >= 0.30) return 5;
- return 0;
-}
-
-function calcEtfLiquidityScore_(etf) {
- if (!etf || etf.proxyType !== "ETF") return 5;
- let score = 0;
- if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw >= 1000000000) score += 4;
- else if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw >= 300000000) score += 2;
- if (Number.isFinite(etf.spreadPct) && etf.spreadPct <= 0.25) score += 3;
- else if (Number.isFinite(etf.spreadPct) && etf.spreadPct <= 0.50) score += 1;
- if (etf.priceOk && !etf.isPriceStale) score += 2;
- if (etf.navRisk === "NAV_DATA_MISSING") score += 0;
- else if (etf.navRisk === "OK") score += 1;
- return Math.max(0, Math.min(10, score));
-}
-
-function calcEtfLiquidityStatus_(etf) {
- if (!etf || etf.proxyType !== "ETF") return "NOT_ETF";
- if (!etf.priceOk) return "BLOCK";
- if (etf.isPriceStale) return "WARN";
- if (Number.isFinite(etf.spreadPct) && etf.spreadPct > 0.80) return "BLOCK";
- if (Number.isFinite(etf.avgTradeValue5DKrw) && etf.avgTradeValue5DKrw < 300000000) return "WARN";
- if (etf.navRisk === "NAV_DATA_MISSING") return "WARN";
- return "OK";
-}
-
-function calcEtfExecutionUse_(etf) {
- if (!etf || etf.proxyType !== "ETF") return "NOT_ETF";
- if (etf.liquidityStatus === "BLOCK" || !etf.priceOk) return "BLOCK";
- if (etf.navRisk !== "OK") return "WATCH_ONLY";
- if (etf.liquidityStatus === "OK") return "TRADE_OK";
- return "WATCH_ONLY";
-}
-
-function readEtfNavManualMap_() {
- const result = {};
- try {
- const sheet = getSpreadsheet_().getSheetByName("etf_nav_manual");
- if (!sheet) return result;
- const data = sheet.getDataRange().getValues();
- if (data.length < 3) return result;
- const hdr = data[1].map(h => String(h).trim());
- const idx = name => hdr.indexOf(name);
- const tickerIdx = idx("ETF_Ticker");
- if (tickerIdx < 0) return result;
- for (let i = 2; i < data.length; i++) {
- const ticker = normalizeTickerCode(data[i][tickerIdx]);
- if (!ticker) continue;
- const enabled = idx("Enabled") >= 0 ? boolFromSheet_(data[i][idx("Enabled")], true) : true;
- if (!enabled) continue;
- const close = idx("Close") >= 0 ? parseFloat(data[i][idx("Close")]) : null;
- const nav = idx("NAV") >= 0 ? parseFloat(data[i][idx("NAV")]) : null;
- const inav = idx("iNAV") >= 0 ? parseFloat(data[i][idx("iNAV")]) : null;
- let premiumDiscountPct = idx("Premium_Discount_Pct") >= 0 ? parseFloat(data[i][idx("Premium_Discount_Pct")]) : null;
- const basisPrice = Number.isFinite(close) ? close : null;
- const basisNav = Number.isFinite(nav) ? nav : Number.isFinite(inav) ? inav : null;
- if (!Number.isFinite(premiumDiscountPct) && Number.isFinite(basisPrice) && Number.isFinite(basisNav) && basisNav > 0) {
- premiumDiscountPct = ((basisPrice / basisNav) - 1) * 100;
- }
- const sourceDate = idx("Source_Date") >= 0 ? normalizeSheetDateString_(data[i][idx("Source_Date")]) : "";
- const trackingError = idx("Tracking_Error") >= 0 ? parseFloat(data[i][idx("Tracking_Error")]) : null;
- const aum = idx("AUM") >= 0 ? parseFloat(data[i][idx("AUM")]) : null;
- result[ticker] = {
- close: Number.isFinite(close) ? close : null,
- nav: Number.isFinite(nav) ? nav : null,
- inav: Number.isFinite(inav) ? inav : null,
- premiumDiscountPct: Number.isFinite(premiumDiscountPct) ? premiumDiscountPct : null,
- trackingError: Number.isFinite(trackingError) ? trackingError : null,
- aum: Number.isFinite(aum) ? aum : null,
- sourceDate,
- source: idx("Source") >= 0 ? String(data[i][idx("Source")] ?? "").trim() : "etf_nav_manual",
- };
- }
- } catch(e) { handleFetchError_("readEtfNavManualMap_", e, "WARN"); }
- return result;
-}
-
-function calcEtfNavRisk_(manual) {
- if (!manual) return "NAV_DATA_MISSING";
- if (!Number.isFinite(manual.nav) && !Number.isFinite(manual.inav)) return "NAV_DATA_MISSING";
- if (manual.sourceDate && isStalePriceDate_(manual.sourceDate, 2)) return "NAV_STALE";
- if (Number.isFinite(manual.premiumDiscountPct) && Math.abs(manual.premiumDiscountPct) > 1.0) return "NAV_BLOCK";
- if (Number.isFinite(manual.premiumDiscountPct) && Math.abs(manual.premiumDiscountPct) > 0.5) return "NAV_WARN";
- return "OK";
-}
-
-function buildEtfRawRows_(universe) {
- const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
- const navManual = readEtfNavManualMap_();
- const etfMap = {};
- for (const sector of universe) {
- if (sector.proxyType === "ETF") {
- etfMap[sector.proxyTicker] = {
- sector: sector.sector,
- ticker: sector.proxyTicker,
- name: sector.proxyName,
- proxyType: sector.proxyType,
- };
- }
- for (const c of sector.constituents) {
- if (c.isEtf) {
- etfMap[c.code] = {
- sector: sector.sector,
- ticker: c.code,
- name: c.name || sector.proxyName,
- proxyType: "ETF",
- };
- }
- }
- }
-
- const rows = [];
- for (const etf of Object.values(etfMap)) {
- const price = fetchYahooOhlcMetrics(etf.ticker);
- const flow = fetchNaverFlow(etf.ticker);
- const close = Number.isFinite(price.close) ? price.close : null;
- const frg5Sh = flow.ok ? flow.rows.slice(0, 5).reduce((a, r) => a + r.frgn, 0) : null;
- const inst5Sh = flow.ok ? flow.rows.slice(0, 5).reduce((a, r) => a + r.inst, 0) : null;
- const frg5Krw = Number.isFinite(frg5Sh) && Number.isFinite(close) ? frg5Sh * close : null;
- const inst5Krw = Number.isFinite(inst5Sh) && Number.isFinite(close) ? inst5Sh * close : null;
- const avgTradeValue5DKrw = Number.isFinite(price.avgTradingValue5D) ? price.avgTradingValue5D * 1000000 : null;
- const avgTradeValue20DKrw = Number.isFinite(price.avgTradingValue20D) ? price.avgTradingValue20D * 1000000 : null;
- const manual = navManual[etf.ticker] ?? null;
- const raw = {
- ...etf,
- close: Number.isFinite(manual?.close) ? manual.close : close,
- nav: manual?.nav ?? null,
- inav: manual?.inav ?? null,
- premiumDiscountPct: manual?.premiumDiscountPct ?? null,
- trackingError: manual?.trackingError ?? null,
- aum: manual?.aum ?? null,
- bid: Number.isFinite(price.bid) ? price.bid : null,
- ask: Number.isFinite(price.ask) ? price.ask : null,
- spreadPct: Number.isFinite(price.spreadPct) ? price.spreadPct : null,
- avgTradeValue5DKrw,
- avgTradeValue20DKrw,
- etfFrg5Krw: frg5Krw,
- etfInst5Krw: inst5Krw,
- priceOk: Boolean(price.ok),
- isPriceStale: Boolean(price.isPriceStale),
- flowOk: Boolean(flow.ok),
- flowRows: Array.isArray(flow.rows) ? flow.rows.length : 0,
- navRisk: calcEtfNavRisk_(manual),
- navSource: manual?.source ?? "",
- navSourceDate: manual?.sourceDate ?? "",
- asOfDate: today,
- };
- raw.liquidityScore = calcEtfLiquidityScore_(raw);
- raw.liquidityStatus = calcEtfLiquidityStatus_(raw);
- raw.executionUse = calcEtfExecutionUse_(raw);
- raw.lpQualityFlag = raw.liquidityStatus === "OK" ? "OK" : raw.liquidityStatus;
- raw.dataStatus = raw.priceOk ? (raw.flowOk ? "PARTIAL_NAV_MISSING" : "PARTIAL_FLOW_NAV_MISSING") : "FAIL";
- rows.push(raw);
- Utilities.sleep(100);
- }
- return rows;
-}
-
-function buildEtfRawMap_(etfRows) {
- return Object.fromEntries(etfRows.map(r => [r.ticker, r]));
-}
-
-function calcSectorScoreV2_(sectorRet20D, sectorRs20D, smart5Norm, smart20Norm, breadth5, tradeValueRatio, proxyType, etfLiquidityScore) {
- let score = 0;
- const rs = Number.isFinite(sectorRs20D) ? sectorRs20D : sectorRet20D;
- score += rs >= 8 ? 25 : rs >= 3 ? 18 : rs >= 0 ? 10 : rs >= -3 ? 5 : 0;
- score += Math.min(25, Math.round(scoreSmartMoneyNorm_(smart5Norm) * 0.7 + scoreSmartMoneyNorm_(smart20Norm) * 0.3));
- score += scoreBreadth_(breadth5);
- score += tradeValueRatio >= 1.2 ? 15 : tradeValueRatio >= 0.8 ? 8 : 0;
- score += 5; // EPS revision/PER/PBR 정밀 축은 Phase 2에서 보수적 중립값만 부여.
- score += proxyType === "ETF" ? (Number.isFinite(etfLiquidityScore) ? etfLiquidityScore : 0) : 5;
- return Math.max(0, Math.min(100, score));
-}
-
-function runSectorFlowV3() {
- const universe = readSectorUniverse_();
- const etfRawMap = buildEtfRawMap_(buildEtfRawRows_(universe));
- const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
- const headers = [
- "Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Coverage_Weight",
- "Sector_Ret5D","Sector_Ret20D","Sector_RS_20D",
- "SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW","SmartMoney_5D_Norm",
- "Flow_Breadth_5D","Flow_Rows_Min","Stale_Count",
- "ETF_Liquidity_Score","ETF_NAV_Risk","ETF_Liquidity_Status","ETF_Execution_Use",
- "Sector_Median_PE","Sector_Median_PBR",
- "Sector_Score","Sector_Rank","Alert_Level","Data_Quality","Decision_Use","Reason","AsOfDate"
- ];
- const rows = [];
-
- for (const sector of universe) {
- const proxy = fetchYahooOhlcMetrics(sector.proxyTicker);
- const base = sector.baseTicker ? fetchYahooOhlcMetrics(sector.baseTicker) : { ok: false };
- const perVals = [], pbrVals = [];
- const eligibleConstituents = sector.constituents.filter(c => !c.isEtf);
- const weightSum = eligibleConstituents.reduce((a, c) => a + (Number(c.weight) || 0), 0);
- let coverage = 0, frg5Krw = 0, inst5Krw = 0, frg20Krw = 0, inst20Krw = 0;
- let avgTv20Krw = 0, avgTv5Krw = 0, ret5Weighted = 0, ret20Weighted = 0, breadth5 = 0;
- let flowRowsMin = 999, staleCount = 0;
- const reasons = [];
-
- for (const c of eligibleConstituents) {
- const w = Number(c.weight) || 0;
- const flow = fetchNaverFlow(c.code);
- const price = fetchYahooOhlcMetrics(c.code);
- const flowRows = Array.isArray(flow.rows) ? flow.rows.length : 0;
- if (!flow.ok || !price.ok || flowRows < 5 || !Number.isFinite(price.close)) {
- reasons.push(`${c.code}:DATA_PARTIAL`);
- Utilities.sleep(150);
- continue;
- }
-
- const frg5Sh = flow.rows.slice(0, 5).reduce((a, r) => a + r.frgn, 0);
- const inst5Sh = flow.rows.slice(0, 5).reduce((a, r) => a + r.inst, 0);
- const frg20Sh = flow.rows.slice(0, 20).reduce((a, r) => a + r.frgn, 0);
- const inst20Sh = flow.rows.slice(0, 20).reduce((a, r) => a + r.inst, 0);
- const cFrg5Krw = frg5Sh * price.close;
- const cInst5Krw = inst5Sh * price.close;
- const cFrg20Krw = frg20Sh * price.close;
- const cInst20Krw = inst20Sh * price.close;
-
- coverage += w;
- frg5Krw += cFrg5Krw * w;
- inst5Krw += cInst5Krw * w;
- frg20Krw += cFrg20Krw * w;
- inst20Krw += cInst20Krw * w;
- if (Number.isFinite(price.avgTradingValue20D)) avgTv20Krw += price.avgTradingValue20D * 1000000 * w;
- if (Number.isFinite(price.avgTradingValue5D)) avgTv5Krw += price.avgTradingValue5D * 1000000 * w;
- if (Number.isFinite(price.ret5D)) ret5Weighted += price.ret5D * w;
- if (Number.isFinite(price.ret20D)) ret20Weighted += price.ret20D * w;
- if (cFrg5Krw + cInst5Krw > 0) breadth5 += w;
- flowRowsMin = Math.min(flowRowsMin, flowRows);
- if (flow.isFlowStale || price.isPriceStale) staleCount++;
-
- const qm = fetchNaverMarketMetrics(c.code);
- if (Number.isFinite(qm.per) && qm.per > 0) perVals.push(qm.per);
- if (Number.isFinite(qm.pbr) && qm.pbr > 0) pbrVals.push(qm.pbr);
- Utilities.sleep(150);
- }
-
- if (flowRowsMin === 999) flowRowsMin = 0;
- const smart5 = frg5Krw + inst5Krw;
- const smart20 = frg20Krw + inst20Krw;
- const smart5Norm = avgTv20Krw > 0 ? smart5 / avgTv20Krw : null;
- const smart20Norm = avgTv20Krw > 0 ? smart20 / avgTv20Krw : null;
- const sectorRet5D = coverage > 0 ? ret5Weighted / coverage : null;
- const sectorRet20D = coverage > 0 ? ret20Weighted / coverage : null;
- const sectorRs20D = Number.isFinite(sectorRet20D) && base.ok && Number.isFinite(base.ret20D) ? sectorRet20D - base.ret20D : null;
- const tradeValueRatio = avgTv20Krw > 0 && avgTv5Krw > 0 ? avgTv5Krw / avgTv20Krw : null;
- const medianPE = calcMedian_(perVals);
- const medianPBR = calcMedian_(pbrVals);
- const etfRaw = etfRawMap[sector.proxyTicker] ?? null;
- const etfLiquidityScore = sector.proxyType === "ETF" ? (etfRaw?.liquidityScore ?? 0) : 5;
- const etfNavRisk = sector.proxyType === "ETF" ? (etfRaw?.navRisk ?? "NAV_DATA_MISSING") : "NOT_ETF";
- const etfLiquidityStatus = sector.proxyType === "ETF" ? (etfRaw?.liquidityStatus ?? "WARN") : "NOT_ETF";
- const etfExecutionUse = sector.proxyType === "ETF" ? (etfRaw?.executionUse ?? "WATCH_ONLY") : "NOT_ETF";
- const quality = sectorDataQuality_(coverage, flowRowsMin, staleCount, proxy.ok, Number.isFinite(smart5Norm), weightSum);
- const routeUse = sectorUseMode_(quality);
- let score = calcSectorScoreV2_(sectorRet20D, sectorRs20D, smart5Norm, smart20Norm, breadth5, tradeValueRatio, sector.proxyType, etfLiquidityScore);
- if (quality === "C") score = Math.min(score, 49);
- if (quality === "D") score = Math.min(score, 20);
- const alert = score >= 70 && smart5 > 0 && breadth5 >= 0.50 ? "INFLOW_STRONG" :
- score >= 50 && smart5 > 0 ? "INFLOW_MODERATE" :
- score >= 30 ? "NEUTRAL" :
- smart5 < 0 && breadth5 < 0.40 ? "OUTFLOW_ALERT" : "OUTFLOW_CAUTION";
- if (quality === "C") reasons.push("Data_Quality=C:WATCH_ONLY");
- if (quality === "D") reasons.push("Data_Quality=D:INVALID");
- if (coverage < 0.60) reasons.push("Coverage<0.60");
- if (sector.constituents.length !== eligibleConstituents.length) reasons.push("ETF_Constituent_Excluded_From_Sector_Flow");
- if (staleCount > 0) reasons.push(`Stale_Count=${staleCount}`);
- if (!proxy.ok) reasons.push("Proxy_Price_FAIL");
- if (!Number.isFinite(smart5Norm)) reasons.push("SmartMoney_Norm_MISSING");
- if (sector.proxyType === "ETF" && etfNavRisk === "NAV_DATA_MISSING") reasons.push("ETF_NAV_DATA_MISSING");
- if (sector.proxyType === "ETF" && etfLiquidityStatus !== "OK") reasons.push(`ETF_Liquidity=${etfLiquidityStatus}`);
- if (sector.proxyType === "ETF" && etfExecutionUse !== "TRADE_OK") reasons.push(`ETF_Execution=${etfExecutionUse}`);
-
- rows.push({
- sector: sector.sector,
- proxyTicker: sector.proxyTicker,
- proxyName: sector.proxyName,
- proxyType: sector.proxyType || "대표주",
- coverage,
- sectorRet5D,
- sectorRet20D,
- sectorRs20D,
- frg5Krw,
- inst5Krw,
- frg20Krw,
- inst20Krw,
- smart5,
- smart20,
- avgTv20Krw,
- smart5Norm,
- breadth5,
- flowRowsMin,
- staleCount,
- etfLiquidityScore,
- etfNavRisk,
- etfLiquidityStatus,
- etfExecutionUse,
- medianPE,
- medianPBR,
- score,
- rank: 0,
- alert,
- quality,
- routeUse,
- reason: reasons.length ? reasons.join(" | ") : "OK",
- asOfDate: today,
- proxyRet5D: proxy.ok ? proxy.ret5D : null,
- proxyRet10D: proxy.ok ? proxy.ret10D : null,
- proxyRet20D: proxy.ok ? proxy.ret20D : null,
- });
- }
-
- rows.sort((a, b) => Number(b.score) - Number(a.score));
- rows.forEach((r, i) => { r.rank = i + 1; });
- appendSectorFlowHistoryV2_(rows);
- return rows;
-}
-
-function appendSectorFlowHistoryV2_(rows) {
- // 주말(토·일)은 KRX 휴장 — 새 시장 데이터 없으므로 이력 저장 불필요
- const dow = new Date().getDay(); // 0=일, 6=토
- if (dow === 0 || dow === 6) {
- Logger.log("appendSectorFlowHistoryV2_: 주말 스킵 (dow=" + dow + ")");
- return;
- }
-
- const headers = [
- "Snapshot_Date","Sector","Sector_Score","Sector_Rank","SmartMoney_5D_KRW","SmartMoney_20D_KRW",
- "Flow_Breadth_5D","Alert_Level","Data_Quality","Decision_Use","ETF_Liquidity_Status","ETF_Execution_Use","Reason","Saved_At"
- ];
- const ss = getSpreadsheet_();
- let sheet = ss.getSheetByName("sector_flow_history");
- if (!sheet) {
- sheet = ss.insertSheet("sector_flow_history");
- sheet.getRange(1, 1).setValue("updated: sector_flow_history cumulative snapshots");
- sheet.getRange(2, 1, 1, headers.length).setValues([headers]);
- }
- const data = sheet.getDataRange().getValues();
- const hdr = data[1] ?? headers;
- const dateIdx = hdr.indexOf("Snapshot_Date");
- const sectorIdx = hdr.indexOf("Sector");
- const existing = [];
- const byKey = {};
- for (let i = 2; i < data.length; i++) {
- const row = data[i];
- const d = normalizeSheetDateString_(row[dateIdx]);
- const s = String(row[sectorIdx] ?? "").trim();
- if (!d || !s) continue;
- byKey[`${d}|${s}`] = row;
- existing.push(row);
- }
- const savedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
- for (const r of rows) {
- byKey[`${r.asOfDate}|${r.sector}`] = [
- r.asOfDate, r.sector, r.score, r.rank, Math.round(r.smart5), Math.round(r.smart20),
- roundNum(r.breadth5, 4), r.alert, r.quality, r.routeUse, r.etfLiquidityStatus, r.etfExecutionUse, r.reason, savedAt
- ];
- }
- const out = Object.values(byKey).sort((a, b) => {
- const da = String(a[0]), db = String(b[0]);
- if (da !== db) return da.localeCompare(db);
- return String(a[1]).localeCompare(String(b[1]));
- });
- sheet.clearContents();
- sheet.getRange(1, 1).setValue(`updated: ${savedAt} KST`);
- sheet.getRange(2, 1, 1, headers.length).setValues([headers]);
- if (out.length) sheet.getRange(3, 1, out.length, headers.length).setValues(out);
-}
-
-function normalizeSheetDateString_(value) {
- if (value instanceof Date && !isNaN(value.getTime())) {
- return Utilities.formatDate(value, "Asia/Seoul", "yyyy-MM-dd");
- }
- const raw = String(value ?? "").trim();
- if (!raw) return "";
- const normalized = raw.replace(/\./g, "-").replace(/\//g, "-");
- const m = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
- if (m) return `${m[1]}-${String(m[2]).padStart(2, "0")}-${String(m[3]).padStart(2, "0")}`;
- const d = new Date(raw);
- return isNaN(d.getTime()) ? "" : Utilities.formatDate(d, "Asia/Seoul", "yyyy-MM-dd");
-}
-
-function readSectorFlowHistoryPrev_(currentDate) {
- const result = {};
- try {
- const sheet = getSpreadsheet_().getSheetByName("sector_flow_history");
- if (!sheet) return result;
- const data = sheet.getDataRange().getValues();
- const hdr = data[1] ?? [];
- const dIdx = hdr.indexOf("Snapshot_Date");
- const sIdx = hdr.indexOf("Sector");
- const rankIdx = hdr.indexOf("Sector_Rank");
- const sm5Idx = hdr.indexOf("SmartMoney_5D_KRW");
- const breadthIdx = hdr.indexOf("Flow_Breadth_5D");
- if (dIdx < 0 || sIdx < 0) return result;
- const grouped = {};
- for (let i = 2; i < data.length; i++) {
- const d = normalizeSheetDateString_(data[i][dIdx]);
- const s = String(data[i][sIdx] ?? "").trim();
- if (!d || !s || d === currentDate) continue;
- if (!grouped[s]) grouped[s] = [];
- grouped[s].push({
- date: d,
- rank: rankIdx >= 0 ? parseInt(data[i][rankIdx]) : null,
- smart5: sm5Idx >= 0 ? parseFloat(data[i][sm5Idx]) : null,
- breadth5: breadthIdx >= 0 ? parseFloat(data[i][breadthIdx]) : null,
- });
- }
- for (const [sector, items] of Object.entries(grouped)) {
- items.sort((a, b) => b.date.localeCompare(a.date));
- result[sector] = { w1: items[0] ?? null, w2: items[1] ?? null };
- }
- } catch(e) { handleFetchError_("readSectorFlowHistoryPrev_", e, "WARN"); }
- return result;
-}
-
-function readPrevLegacySectorFlow_() {
- const result = {};
- try {
- const sfSheet = getSpreadsheet_().getSheetByName("sector_flow");
- if (!sfSheet) return result;
- const data = sfSheet.getDataRange().getValues();
- const hdr = data[1] ?? [];
- const sIdx = hdr.indexOf("Sector");
- const rIdx = hdr.indexOf("Sector_Rank") >= 0 ? hdr.indexOf("Sector_Rank") : hdr.indexOf("Rotation_Rank");
- const s5Idx = hdr.indexOf("SmartMoney_5D_KRW") >= 0 ? hdr.indexOf("SmartMoney_5D_KRW") : hdr.indexOf("Frg_5D_SUM");
- const s20Idx = hdr.indexOf("SmartMoney_20D_KRW") >= 0 ? hdr.indexOf("SmartMoney_20D_KRW") : hdr.indexOf("Frg_20D_SUM");
- if (sIdx < 0) return result;
- for (let i = 2; i < data.length; i++) {
- const s = String(data[i][sIdx]).trim();
- if (!s || s === "Sector") continue;
- const smart5 = s5Idx >= 0 ? parseFloat(data[i][s5Idx]) : null;
- const smart20 = s20Idx >= 0 ? parseFloat(data[i][s20Idx]) : null;
- result[s] = {
- rank: rIdx >= 0 ? parseInt(data[i][rIdx]) : null,
- smart5: Number.isFinite(smart5) ? smart5 : null,
- smart20: Number.isFinite(smart20) ? smart20 : null,
- frg5: Number.isFinite(smart5) ? smart5 : null,
- inst5: Number.isFinite(smart5) ? smart5 : null,
- };
- }
- } catch(e) { handleFetchError_("readPrevLegacySectorFlow_", e, "WARN"); }
- return result;
-}
-
-function readW2LegacySectorFlow_() {
- const result = {};
- try {
- const props = PropertiesService.getScriptProperties();
- const w2Json = props.getProperty("sf_w2_ranks_json");
- if (w2Json) Object.assign(result, JSON.parse(w2Json).data ?? {});
- } catch(e) { handleFetchError_("readW2LegacySectorFlow_", e, "INFO"); }
- return result;
-}
-
-function writeLegacySectorFlowFromStage2_(stage2Rows) {
- const headers = [
- "Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Coverage_Weight",
- "Sector_Ret5D","Sector_Ret10D","Sector_Ret20D","Sector_RS_20D",
- "SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW",
- "SmartMoney_5D_Norm","SmartMoney_20D_Norm","Flow_Breadth_5D","Flow_Rows_Min","Stale_Count",
- "ETF_Liquidity_Score","ETF_NAV_Risk","ETF_Liquidity_Status","ETF_Execution_Use",
- "Sector_Median_PE","Sector_Median_PBR","Sector_Score","Sector_Rank",
- "Alert_Level","Data_Quality","Decision_Use","Reason","RW1","RW3","AsOfDate",
- "ETF_Code","Frg_5D_SUM","Inst_5D_SUM","Indiv_5D_SUM","Frg_20D_SUM","Inst_20D_SUM",
- "ETF_Ret5D","ETF_Ret10D","ETF_Ret20D",
- "Rotation_Score","Rotation_Rank","Prev_Rotation_Rank","Prev_Frg_5D_SUM","Prev_Inst_5D_SUM",
- "Prev_Rotation_Rank_W2","Prev_Frg_5D_SUM_W2","Prev_Inst_5D_SUM_W2","Smart_Money"
- ];
- const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
- const prev = readPrevLegacySectorFlow_();
- const w2 = readW2LegacySectorFlow_();
- const historyPrev = readSectorFlowHistoryPrev_(today);
- try {
- const props = PropertiesService.getScriptProperties();
- if (Object.keys(prev).length > 0) props.setProperty("sf_w2_ranks_json", JSON.stringify({ saved_at: today, data: prev }));
- } catch(e) { handleFetchError_("writeLegacySectorFlowFromStage2_:W2 save", e, "INFO"); }
-
- const rows = stage2Rows.map(r => {
- const p = prev[r.sector] ?? {};
- const w = w2[r.sector] ?? {};
- const hp = historyPrev[r.sector]?.w1 ?? null;
- const hw = historyPrev[r.sector]?.w2 ?? null;
- const w1Rank = Number.isFinite(hp?.rank) ? hp.rank : p.rank;
- const w2Rank = Number.isFinite(hw?.rank) ? hw.rank : w.rank;
- const rw1 = Number.isFinite(w1Rank) && Number.isFinite(w2Rank) && (r.rank - w1Rank >= 3) && (w1Rank - w2Rank >= 3) ? 1 : 0;
- const curOutflow = r.smart5 < 0 && r.breadth5 < 0.40;
- const prevOutflow = Number.isFinite(p.frg5) && p.frg5 < 0 && Number.isFinite(p.inst5) && p.inst5 < 0;
- const histOutflow = Number.isFinite(hp?.smart5) && hp.smart5 < 0 && Number.isFinite(hp?.breadth5) && hp.breadth5 < 0.40;
- const rw3 = curOutflow && (histOutflow || prevOutflow) ? 1 : 0;
- const smart = r.smart5 > 0 && r.breadth5 >= 0.70 ? "STRONG" :
- r.smart5 > 0 && r.breadth5 >= 0.40 ? "MODERATE" :
- r.smart5 > 0 ? "WEAK" : "ABSENT";
- const smartMoneyHalf = Number.isFinite(r.smart5) ? r.smart5 / 2 : "";
- const frg5Alias = Number.isFinite(smartMoneyHalf) ? smartMoneyHalf : "";
- const inst5Alias = Number.isFinite(smartMoneyHalf) ? smartMoneyHalf : "";
- const frg20Alias = Number.isFinite(r.smart20) ? r.smart20 / 2 : "";
- const inst20Alias = Number.isFinite(r.smart20) ? r.smart20 / 2 : "";
- return [
- r.sector, r.proxyTicker, r.proxyName, r.proxyType, r.coverage,
- r.sectorRet5D, r.proxyRet10D, r.sectorRet20D, r.sectorRs20D,
- r.smart5, r.smart20, r.avgTv20Krw,
- r.smart5Norm, r.smart20Norm, r.breadth5, r.flowRowsMin, r.staleCount,
- r.etfLiquidityScore, r.etfNavRisk, r.etfLiquidityStatus, r.etfExecutionUse,
- r.medianPE != null ? r.medianPE.toFixed(1) : "",
- r.medianPBR != null ? r.medianPBR.toFixed(2) : "",
- r.score, r.rank,
- r.alert, r.quality, r.routeUse, r.reason, rw1, rw3, r.asOfDate,
- r.proxyTicker, frg5Alias, inst5Alias, 0, frg20Alias, inst20Alias,
- Number.isFinite(r.proxyRet5D) ? r.proxyRet5D : "N/A",
- Number.isFinite(r.proxyRet10D) ? r.proxyRet10D : "N/A",
- Number.isFinite(r.proxyRet20D) ? r.proxyRet20D : "N/A",
- r.score, r.rank, Number.isFinite(w1Rank) ? w1Rank : "",
- Number.isFinite(p.frg5) ? p.frg5 : "", Number.isFinite(p.inst5) ? p.inst5 : "",
- Number.isFinite(w2Rank) ? w2Rank : "", Number.isFinite(w.frg5) ? w.frg5 : "",
- Number.isFinite(w.inst5) ? w.inst5 : "", smart
- ];
- });
- writeToSheet("sector_flow", headers, rows);
- Logger.log(`sector_flow 완료: ${rows.length}섹터`);
-}
-
-// ── F4: Trailing Stop account_snapshot 일괄 갱신 ────────────────────────────
-// _trailingStopUpdates_ 배열을 소비해 account_snapshot의 highest_price/stop_price/last_updated 갱신.
-// 신규 최고가 경신 종목만 업데이트 — entry 없는 종목은 건드리지 않음.
-function applyTrailingStopUpdates_() {
- if (!_trailingStopUpdates_.length) return;
- try {
- const ss = getSpreadsheet_();
- const sheet = ss.getSheetByName("account_snapshot");
- if (!sheet) { Logger.log("applyTrailingStopUpdates_: account_snapshot 탭 없음"); return; }
- const data = sheet.getDataRange().getValues();
- const hdr = data[1] ?? []; // row2 = 헤더
- const tkIdx = hdr.indexOf("ticker");
- const highIdx= hdr.indexOf("highest_price_since_entry");
- const stopIdx= hdr.indexOf("stop_price");
- const updIdx = hdr.indexOf("last_updated");
- if (tkIdx < 0 || highIdx < 0 || stopIdx < 0) {
- Logger.log("applyTrailingStopUpdates_: account_snapshot 컬럼 미발견");
- return;
- }
- const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
- const updateMap = {};
- _trailingStopUpdates_.forEach(u => { updateMap[u.ticker] = u; });
-
- for (let i = 2; i < data.length; i++) {
- const tk = String(data[i][tkIdx] ?? "").trim();
- if (!tk || !updateMap[tk]) continue;
- const upd = updateMap[tk];
- sheet.getRange(i + 1, highIdx + 1).setValue(upd.new_highest);
- sheet.getRange(i + 1, stopIdx + 1).setValue(upd.new_stop);
- if (updIdx >= 0) sheet.getRange(i + 1, updIdx + 1).setValue(today);
- Logger.log(`TrailingStop 갱신: ${tk} highest=${upd.new_highest} stop=${upd.new_stop}`);
- }
- } catch(e) {
- handleFetchError_("applyTrailingStopUpdates_", e, "WARN");
- }
-}
-
-// ── 버킷 할당 상태 계산 ─────────────────────────────────────────────────────
-// _bucketSnapshot_이 있어야 동작. runDataFeed() 실행 후 runMacro()에서 호출.
-// 목표 범위: core 60-72%, satellite 10-25%, cash 10-22% (spec/risk)
-function calcBucketStatus_() {
- if (!_bucketSnapshot_) return null;
- const { core_pct, satellite_pct } = _bucketSnapshot_;
- const cash_pct = parseFloat(Math.max(0, 100 - core_pct - satellite_pct).toFixed(2));
- const coreStatus = core_pct < THRESHOLDS.BUCKET_CORE_MIN ? "UNDERWEIGHT" : core_pct > THRESHOLDS.BUCKET_CORE_MAX ? "OVERWEIGHT" : "OK";
- const satStatus = satellite_pct < THRESHOLDS.BUCKET_SAT_MIN ? "UNDERWEIGHT" : satellite_pct > THRESHOLDS.BUCKET_SAT_MAX ? "OVERWEIGHT" : "OK";
- const cashStatus = cash_pct < THRESHOLDS.BUCKET_CASH_MIN ? "LOW" : cash_pct > THRESHOLDS.BUCKET_CASH_MAX ? "HIGH" : "OK";
- const issues = [
- coreStatus !== "OK" ? `core_${coreStatus}` : null,
- satStatus !== "OK" ? `sat_${satStatus}` : null,
- cashStatus !== "OK" ? `cash_${cashStatus}` : null,
- ].filter(Boolean);
- return {
- core_pct, satellite_pct, cash_pct,
- core_status: coreStatus, satellite_status: satStatus, cash_status: cashStatus,
- overall: issues.length === 0 ? "BALANCED" : issues.join("|"),
- detail: `core=${core_pct}%(${coreStatus}) sat=${satellite_pct}%(${satStatus}) cash=${cash_pct}%(${cashStatus})`,
- };
-}
-
-// ── 매크로 지표 수집 ─────────────────────────────────────────────────────────
-function runMacro() {
- const MACRO_TICKERS = [
- { sym: "^KS11", name: "KOSPI", category: "Index" },
- { sym: "^KQ11", name: "KOSDAQ", category: "Index" },
- { sym: "^VIX", name: "VIX", category: "Risk" },
- { sym: "KRW=X", name: "USD_KRW", category: "FX" },
- { sym: "JPY=X", name: "USD_JPY", category: "FX" },
- { sym: "DX-Y.NYB",name: "DXY", category: "FX" },
- { sym: "GC=F", name: "Gold", category: "Commodity" },
- { sym: "CL=F", name: "WTI_Oil", category: "Commodity" },
- { sym: "^TNX", name: "US10Y_Yield",category: "Bond" },
- { sym: "^TYX", name: "US30Y_Yield",category: "Bond" },
- { sym: "^GSPC", name: "SP500", category: "Index" },
- { sym: "^NDX", name: "NASDAQ100", category: "Index" },
- // HYG: HY 회사채 ETF → Ret5D로 credit_stress_status 산출 (MRS 신용위험 입력값)
- { sym: "HYG", name: "HYG_HY_Bond",category: "CreditProxy" },
- ];
-
- const headers = ["Symbol","Name","Category","Close","Ret1D","Ret2D","Ret5D","Ret10D","Ret20D","MA20","MA60","AsOfDate","Status"];
- const rows = [];
- const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
-
- for (const m of MACRO_TICKERS) {
- const p = fetchYahooPrice(m.sym);
- let ma20 = "", ma60 = "", ret10D = "", ret2D = "";
- if (m.category === "Index") {
- const ohlc = fetchYahooOhlcMetrics(m.sym);
- if (ohlc?.ok) {
- if (Number.isFinite(ohlc.ma20)) ma20 = ohlc.ma20.toFixed(2);
- if (Number.isFinite(ohlc.ma60)) ma60 = ohlc.ma60.toFixed(2);
- if (Number.isFinite(ohlc.ret10D)) ret10D = ohlc.ret10D.toFixed(2);
- if (Number.isFinite(ohlc.ret2D)) ret2D = ohlc.ret2D.toFixed(2);
- }
- } else if (m.category === "FX" && m.name === "USD_JPY") {
- // USD/JPY Ret2D: MRS usd_jpy_score 전용
- if (p.ok && Number.isFinite(parseFloat(p.ret5D))) {
- // 2일 변화율은 fetchYahooOhlcMetrics가 필요 — FX는 budget 여유 있으면 시도
- const ohlc = fetchYahooOhlcMetrics(m.sym);
- if (ohlc?.ok && Number.isFinite(ohlc.ret2D)) ret2D = ohlc.ret2D.toFixed(2);
- }
- }
- if (p.ok) {
- const p1d = fetchYahooPrice1D(m.sym);
- rows.push([m.sym, m.name, m.category, p.close, p1d, ret2D, p.ret5D, ret10D !== "" ? ret10D : (p.ok ? p.ret10D ?? "" : ""), p.ret20D, ma20, ma60, today, "OK"]);
- } else {
- rows.push([m.sym, m.name, m.category, "N/A", "N/A", "", "N/A", "", "N/A", ma20, ma60, today, "FAIL"]);
- }
- Utilities.sleep(300);
- }
-
- // ── MRS(시장위험점수) 자동 계산 후 summary 행 추가 ────────────────────────
- const byName = {};
- rows.forEach(r => { byName[r[1]] = r; }); // Name 기준 인덱싱
- const vixClose = parseFloat(byName["VIX"]?.[3]);
- const kospiClose= parseFloat(byName["KOSPI"]?.[3]);
- const kospiMA20 = parseFloat(byName["KOSPI"]?.[9]);
- const usdKrw = parseFloat(byName["USD_KRW"]?.[3]);
- const usdJpyR2D = parseFloat(byName["USD_JPY"]?.[5]); // Ret2D
- const hygRet5D = parseFloat(byName["HYG_HY_Bond"]?.[6]); // Ret5D
-
- // credit_stress_status 산출 (HYG Ret5D 기반 proxy)
- const creditStress = Number.isFinite(hygRet5D)
- ? (hygRet5D < -2 ? "stress" : hygRet5D < -1 ? "caution" : "none")
- : "DATA_MISSING";
-
- // MARKET_RISK_SCORE_V1
- let mrs = 0;
- mrs += Number.isFinite(vixClose) ? (vixClose < 18 ? 0 : vixClose <= 25 ? 2 : vixClose <= 35 ? 3 : 4) : 4;
- mrs += Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) ? (kospiClose >= kospiMA20 ? 0 : 2) : 2;
- mrs += Number.isFinite(usdKrw) ? (usdKrw < 1400 ? 0 : usdKrw <= 1450 ? 1 : 2) : 2;
- mrs += Number.isFinite(usdJpyR2D) ? (usdJpyR2D > -1 ? 0 : 1) : 1;
- mrs += creditStress === "none" ? 0 : 1;
-
- // kosdaq_regime_supplement: KOSDAQ < MA20 이고 KOSPI >= MA20이면 MRS +1
- const kosdaqClose = parseFloat(byName["KOSDAQ"]?.[3]);
- const kosdaqMA20 = parseFloat(byName["KOSDAQ"]?.[9]);
- const kosdaqSupp = Number.isFinite(kosdaqClose) && Number.isFinite(kosdaqMA20)
- && kosdaqClose < kosdaqMA20
- && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose >= kospiMA20
- ? 1 : 0;
- mrs = Math.min(10, mrs + kosdaqSupp);
-
- // TARGET_CASH_PCT_V1
- const targetCashPct = (5 + (mrs / 10) * 15).toFixed(1);
-
- // ── sector_flow 읽기 → 완전 국면 판정용 데이터 수집 ─────────────────────
- // runSectorFlow()가 sector_flow 기록 완료 후 runMacro()가 실행되므로 최신값 읽기 가능
- let sfTop1Score = 0, sfTop2Sum = 0, sfTop1AlertScore = 0, sfTop1Sector = "";
- let sfSmart20Sum = 0;
- try {
- const sfSheet = getSpreadsheet_().getSheetByName("sector_flow");
- if (sfSheet) {
- const sfData = sfSheet.getDataRange().getValues();
- const sfHdr = sfData[1] ?? [];
- const sfRankIdx = sfHdr.indexOf("Sector_Rank") >= 0 ? sfHdr.indexOf("Sector_Rank") : sfHdr.indexOf("Rotation_Rank");
- const sfScoreIdx = sfHdr.indexOf("Sector_Score") >= 0 ? sfHdr.indexOf("Sector_Score") : sfHdr.indexOf("Rotation_Score");
- const sfAlertIdx = sfHdr.indexOf("Alert_Level");
- const sfSmart20Idx= sfHdr.indexOf("SmartMoney_20D_KRW") >= 0 ? sfHdr.indexOf("SmartMoney_20D_KRW") : sfHdr.indexOf("Frg_20D_SUM");
- const sfSectorIdx = sfHdr.indexOf("Sector");
- const sfEntries = [];
- for (let i = 2; i < sfData.length; i++) {
- const row = sfData[i];
- const sec = String(row[sfSectorIdx] ?? "").trim();
- if (!sec || sec === "Sector") continue;
- const score = parseFloat(row[sfScoreIdx]);
- const rank = parseInt(row[sfRankIdx]);
- const als = String(row[sfAlertIdx] ?? "");
- const aScore = als === "INFLOW_STRONG" ? 3 : als === "INFLOW_MODERATE" ? 2 : als === "NEUTRAL" ? 1 : 0;
- const smart20 = parseFloat(row[sfSmart20Idx]);
- sfEntries.push({ rank, score, alertScore: aScore, sec, smart20 });
- if (Number.isFinite(smart20)) sfSmart20Sum += smart20;
- }
- sfEntries.sort((a, b) => a.rank - b.rank);
- if (sfEntries.length >= 1) {
- sfTop1Score = sfEntries[0].score ?? 0;
- sfTop1AlertScore = sfEntries[0].alertScore ?? 0;
- sfTop1Sector = sfEntries[0].sec;
- }
- if (sfEntries.length >= 2) {
- sfTop2Sum = (sfEntries[0].score ?? 0) + (sfEntries[1].score ?? 0);
- }
- }
- } catch(e) { handleFetchError_("runMacro:sector_flow regime read", e, "WARN"); }
-
- // KOSPI MA60·Ret20D — byName column index (행 구조: [sym,name,cat,close,ret1d,ret2d,ret5d,ret10d,ret20d,ma20,ma60,...])
- const kospiMA60 = parseFloat(byName["KOSPI"]?.[10]);
- const kospiRet20D = parseFloat(byName["KOSPI"]?.[8]);
-
- // ── MARKET_REGIME_V1 완전 판정 (spec/11_market_regime.yaml) ─────────────
- const leaderSectorFlag_ = SECTOR_TIER_MAP[sfTop1Sector] === "Tier_1" ? 1 : 0;
-
- const isRiskOff_ = mrs >= 7
- || (Number.isFinite(vixClose) && vixClose >= 25
- && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose < kospiMA20);
-
- const riskOnBase_ = !isRiskOff_
- && Number.isFinite(vixClose) && vixClose < 18
- && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose > kospiMA20
- && ((Number.isFinite(kospiMA60) && kospiMA20 >= kospiMA60)
- || (Number.isFinite(kospiRet20D) && kospiRet20D > 0));
- const riskOnFlow_ = sfSmart20Sum > 0 || sfTop2Sum >= 100;
-
- const isLeader_ = !isRiskOff_
- && sfTop2Sum >= 100 && sfTop1Score >= 55 && sfTop1AlertScore >= 2 && leaderSectorFlag_ === 1
- && Number.isFinite(kospiRet20D) && kospiRet20D > 0
- && Number.isFinite(vixClose) && vixClose < 25;
-
- const isSecularLeader_ = isLeader_
- && sfTop1Sector === "반도체"
- && Number.isFinite(vixClose) && vixClose < 22
- && Number.isFinite(kospiClose) && Number.isFinite(kospiMA20) && kospiClose > kospiMA20;
-
- let marketRegime;
- if (isRiskOff_) marketRegime = "RISK_OFF";
- else if (isSecularLeader_) marketRegime = "SECULAR_LEADER_RISK_ON";
- else if (isLeader_) marketRegime = "LEADER_CONCENTRATION";
- else if (riskOnBase_ && riskOnFlow_) marketRegime = "RISK_ON";
- else if (mrs <= 5) marketRegime = "NEUTRAL";
- else marketRegime = "RISK_OFF_CANDIDATE";
-
- const mrsDetail = `score=${mrs}/10 cash=${targetCashPct}% regime=${marketRegime}` +
- `${kosdaqSupp ? " [KOSDAQ+1]" : ""} top1=${sfTop1Sector}(${sfTop1Score.toFixed(0)}) top2sum=${sfTop2Sum.toFixed(0)}`;
-
- // ── Bayesian multiplier ────────────────────────────────────────────────────
- const bayesianInfo = readPerformanceSheet_();
- const bayesianDetail = `${bayesianInfo.bayesian_label} (${bayesianInfo.bayesian_multiplier}×)` +
- (bayesianInfo.win_rate_30 != null ? ` wr=${(bayesianInfo.win_rate_30*100).toFixed(0)}%` : "") +
- (bayesianInfo.net_expectancy_30 != null ? ` ne=${bayesianInfo.net_expectancy_30.toFixed(1)}%` : "") +
- ` trades=${bayesianInfo.trades_used}`;
-
- // ── net_return_feedback 상태 (RISK_BUDGET_CASCADE_V1 입력) ────────────────
- // spec/05_position_sizing.yaml:net_return_feedback
- const neTrades_ = bayesianInfo.trades_used;
- const ne30_ = bayesianInfo.net_expectancy_30; // %, e.g. 3.2 = 3.2% avg expectancy
- const consLoss_ = bayesianInfo.consecutive_losses;
- let netRF = "NORMAL", netRFDetail = "";
- if (neTrades_ < 20) {
- netRFDetail = `trades<20(${neTrades_}건) — 규칙 미적용`;
- } else if (Number.isFinite(ne30_) && ne30_ <= -2) {
- netRF = "REDUCED";
- netRFDetail = `ne=${ne30_.toFixed(1)}% — base_risk 0.007→0.003 삭감 권고`;
- } else if (Number.isFinite(ne30_) && ne30_ <= 0) {
- netRF = "CAUTION";
- netRFDetail = `ne=${ne30_.toFixed(1)}% — high_confidence 금지, multiplier 0.5× 강제`;
- } else {
- netRFDetail = `ne=${Number.isFinite(ne30_) ? ne30_.toFixed(1) : "N/A"}% — 정상`;
- }
- if (consLoss_ >= 5 && netRF === "NORMAL") {
- netRF = "CAUTION";
- netRFDetail = `연속손실 ${consLoss_}건 — high_confidence 금지`;
- }
-
- // ── TOTAL_HEAT_V1 계산 — account_snapshot 기반 ──────────────────────────
- const macroSettings = readSettingsTab_();
- const totalAssetKrw = Number.isFinite(parseFloat(macroSettings["total_asset_krw"]))
- ? parseFloat(macroSettings["total_asset_krw"]) : null;
- const heatInfo = readAccountSnapshotHeat_(totalAssetKrw);
-
- // ── FC(탐색) 손실 예산 월별 집계 ────────────────────────────────────────
- const fcBudgetPct = Number.isFinite(parseFloat(macroSettings["fc_budget_pct_override"]))
- ? parseFloat(macroSettings["fc_budget_pct_override"]) : null;
- const fcInfo = calcFcBudget_(totalAssetKrw, fcBudgetPct);
-
- // ── orbit_gap 계산 (spec/01_objective_profile.yaml:orbit_monthly_tracker) ──
- const orbitInfo = calcOrbitGap_(macroSettings);
-
- // summary 행 8개 (MRS / REGIME / BAYESIAN / TOTAL_HEAT / FC_BUDGET / NET_RETURN_FEEDBACK / ORBIT_GAP / ORBIT_STATE)
- rows.push(["MRS_COMPUTED", "Market_Risk_Score", "Computed", mrs, "", "", "", "", "", "", "", today, mrsDetail]);
- rows.push(["REGIME_PRELIM", "Market_Regime_Prelim", "Computed", marketRegime, "", "", "", "", "", "", "", today, `credit_stress=${creditStress} smart20=${sfSmart20Sum.toFixed(0)}`]);
- rows.push(["BAYESIAN_COMPUTED", "Bayesian_Multiplier", "Computed", bayesianInfo.bayesian_multiplier, "", "", "", "", "", "", "", today, bayesianDetail]);
- rows.push(["TOTAL_HEAT", "Total_Heat_Pct", "Computed", heatInfo.total_heat_pct ?? "N/A", "", "", "", "", "", "", "", today,
- `${heatInfo.hf005_status} account_snapshot=${heatInfo.positions_count}` +
- (heatInfo.total_heat_krw != null ? ` heat_krw=${Math.round(heatInfo.total_heat_krw).toLocaleString()}` : "")]);
- rows.push(["FC_BUDGET", "FC_Loss_Budget_Monthly", "Computed", fcInfo.fc_used_pct ?? "N/A", "", "", "", "", "", "", "", today, `${fcInfo.fc_status} trades=${fcInfo.trades}`]);
- rows.push(["NET_RETURN_FEEDBACK", "Net_Return_Feedback", "Computed", netRF, "", "", "", "", "", "", "", today, netRFDetail]);
- rows.push(["ORBIT_GAP", "Orbit_Gap_Pct", "Computed", orbitInfo.ok ? orbitInfo.orbit_gap_pct : "N/A", "", "", "", "", "", "", "", today, orbitInfo.detail]);
- rows.push(["ORBIT_STATE", "Orbit_State", "Computed", orbitInfo.ok ? orbitInfo.orbit_state : "N/A", "", "", "", "", "", "", "", today,
- orbitInfo.ok ? `slot_adj=${orbitInfo.offensive_slot_adj} cash_adj=${orbitInfo.cash_floor_adj} (${orbitInfo.elapsed_months}/${orbitInfo.total_months}개월)` : orbitInfo.detail]);
- const bucketInfo = calcBucketStatus_();
- rows.push(["BUCKET_STATUS", "Bucket_Allocation_Status","Computed",
- bucketInfo ? bucketInfo.overall : "N/A", "", "", "", "", "", "", "", today,
- bucketInfo ? bucketInfo.detail : "data_feed 미실행 OR account_snapshot 없음"]);
-
- writeToSheet("macro", headers, rows);
- Logger.log(`macro 완료: ${rows.length - 9}종목 + MRS/REGIME/BAYESIAN/TOTAL_HEAT/FC_BUDGET/NET_RETURN_FEEDBACK/ORBIT_GAP/ORBIT_STATE/BUCKET_STATUS`);
-
- // orbit_gap 월별 이력 탭 갱신 (이미 계산된 macroSettings/orbitInfo 재사용)
- runOrbitGap(macroSettings, orbitInfo);
-
- // 개별 실행에서는 기존 연쇄를 유지하고, run_all() 모드에서는 상위 오케스트레이터가 다음 단계를 수행한다.
- if (!isRunAllOrchestrated_()) {
- runEventRisk();
- }
-}
-
-// ── 이벤트 리스크 ─────────────────────────────────────────────────────────────
-// event_calendar 탭을 source of truth로 읽어 event_risk 탭을 생성한다.
-// 날짜는 GAS 코드에 hardcode하지 않는다 — 운영자가 event_calendar 탭을 직접 관리.
-// 최초 실행 또는 탭이 비어 있으면 seedEventCalendar_()가 초기값을 채운다.
-// 탭 업데이트: GAS 편집기 → seedEventCalendar_ 또는 직접 시트 편집.
-
-// seed: FOMC / US_CPI / EARNINGS / EXPIRY / IPO 기준값 (빈 탭에만 기록)
-function seedEventCalendar_() {
- const ss = getSpreadsheet_();
- let sheet = ss.getSheetByName("event_calendar");
- if (!sheet) sheet = ss.insertSheet("event_calendar");
-
- const SEED_HEADERS = ["Date", "Event", "Type", "Impact", "Alert"];
- const SEED_ROWS = [
- // FOMC — Federal Reserve 공식 일정 (연 8회). 업데이트: https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm
- ["2026-06-11", "FOMC 금리결정", "FOMC", "HIGH", "금리동결 시 KOSPI +1~2% 기대, 인상 시 원화 약세 압력"],
- ["2026-07-28", "FOMC 금리결정", "FOMC", "HIGH", ""],
- ["2026-09-16", "FOMC 금리결정", "FOMC", "HIGH", ""],
- // US CPI — BLS 발표일 (매월 1회). 업데이트: https://www.bls.gov/schedule/news_release/cpi.htm
- ["2026-06-11", "미국 CPI 발표 (5월)", "US_CPI", "HIGH", "예상치 상회 시 금리인상 우려 → 원화 약세·KOSPI 하방 압력. 당일 신규매수 자제"],
- ["2026-07-15", "미국 CPI 발표 (6월)", "US_CPI", "HIGH", "FOMC 전 마지막 CPI — 금리 경로 재평가 촉매"],
- ["2026-08-12", "미국 CPI 발표 (7월)", "US_CPI", "HIGH", ""],
- // EARNINGS
- ["2026-06-20", "삼성전자 1Q 잠정실적", "EARNINGS", "HIGH", "반도체 섹터 선행 지표"],
- // EXPIRY
- ["2026-06-15", "옵션만기일", "EXPIRY", "MEDIUM", "변동성 확대 구간 주의"],
- ["2026-07-15", "선물·옵션 동시만기", "EXPIRY", "HIGH", "트리플위칭 — 포지션 줄이기"],
- // IPO — 대형 IPO 확정 시 직접 추가. Type=IPO, Impact=HIGH
- // 예: ["2026-MM-DD", "XXX 상장", "IPO", "HIGH", "공모자금 수급 쏠림 → 보유 소형주 매도 압력"]
- ];
-
- const existingData = sheet.getDataRange().getValues();
- // 헤더만 있거나 완전히 비어 있으면 seed 기록
- const dataRowCount = existingData.filter((r, i) => i > 0 && r[0] && String(r[0]).trim()).length;
- if (dataRowCount === 0) {
- sheet.clearContents();
- sheet.appendRow(SEED_HEADERS);
- SEED_ROWS.forEach(r => sheet.appendRow(r));
- Logger.log(`event_calendar seed 완료: ${SEED_ROWS.length}건`);
- } else {
- Logger.log(`event_calendar seed skip: 기존 데이터 ${dataRowCount}건 보존`);
- }
-}
-
-// event_calendar 탭을 읽어 DaysLeft 계산 후 event_risk 탭에 기록
-function runEventRisk() {
- const ss = getSpreadsheet_();
- let calSheet = ss.getSheetByName("event_calendar");
-
- // 탭이 없거나 비어 있으면 seed 실행
- if (!calSheet || calSheet.getLastRow() < 2) {
- seedEventCalendar_();
- calSheet = ss.getSheetByName("event_calendar");
- }
-
- const calData = calSheet.getDataRange().getValues();
- if (!calData || calData.length < 2) {
- Logger.log("event_calendar 데이터 없음 — event_risk 업데이트 skip");
- return;
- }
-
- // 헤더 인덱스 매핑 (대소문자 무관)
- const calHeaders = calData[0].map(h => String(h).trim().toLowerCase());
- const idxDate = calHeaders.indexOf("date");
- const idxEvent = calHeaders.indexOf("event");
- const idxType = calHeaders.indexOf("type");
- const idxImpact = calHeaders.indexOf("impact");
- const idxAlert = calHeaders.indexOf("alert");
- if (idxDate < 0 || idxEvent < 0) {
- Logger.log("event_calendar 헤더 누락 (Date/Event 필수) — seed 재실행 필요");
- return;
- }
-
- const todayStr = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
- const todayParts = todayStr.split("-").map(Number);
- const todayMs = Date.UTC(todayParts[0], todayParts[1]-1, todayParts[2]);
-
- const outHeaders = ["Date","DaysLeft","Event","Type","Impact","Alert","AsOfDate"];
- const rows = [];
- for (let i = 1; i < calData.length; i++) {
- const row = calData[i];
- const rawDate = row[idxDate];
- if (!rawDate || String(rawDate).trim() === "") continue;
- // Date 셀이 Date 객체이거나 "YYYY-MM-DD" 문자열 모두 지원
- let dateStr;
- if (rawDate instanceof Date) {
- dateStr = Utilities.formatDate(rawDate, "Asia/Seoul", "yyyy-MM-dd");
- } else {
- dateStr = String(rawDate).trim();
- }
- if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue;
- const ep = dateStr.split("-").map(Number);
- const eventMs = Date.UTC(ep[0], ep[1]-1, ep[2]);
- const daysLeft = Math.round((eventMs - todayMs) / (1000*60*60*24));
- if (daysLeft < -3) continue; // 3일 이전 경과 이벤트 제외
- rows.push([
- dateStr,
- daysLeft,
- idxEvent >= 0 ? row[idxEvent] : "",
- idxType >= 0 ? row[idxType] : "",
- idxImpact >= 0 ? row[idxImpact] : "",
- idxAlert >= 0 ? row[idxAlert] : "",
- todayStr
- ]);
- }
- rows.sort((a, b) => a[1] - b[1]);
-
- writeToSheet("event_risk", outHeaders, rows);
- Logger.log(`event_risk 완료: ${rows.length}건 (event_calendar 탭에서 읽음)`);
-
- // 매달 1일 실행 시 월별 자산 스냅샷 기록 (asset_history 탭)
- const dayOfMonth = parseInt(Utilities.formatDate(new Date(), "Asia/Seoul", "d"), 10);
- if (dayOfMonth === 1) runMonthlySnapshot();
-
- // 하위 단계 연쇄는 개별 실행에서만 수행한다. run_all()에서는 최종 오케스트레이터가 한 번만 처리한다.
- if (!isRunAllOrchestrated_()) {
- runHarnessRefresh_();
- cacheAllViews();
- }
-}
-
-function runHarnessRefresh_() {
- if (typeof buildHarnessContext_ !== "function") {
- Logger.log("[HARNESS] buildHarnessContext_ missing - integrated code 손상 여부 확인 필요");
- return;
- }
- try {
- buildHarnessContext_();
- Logger.log("[HARNESS] buildHarnessContext_ completed");
- } catch (e) {
- var msg = (e && e.message) ? e.message : String(e);
- var stack = (e && e.stack) ? String(e.stack) : 'NO_STACK';
- Logger.log("[HARNESS][ERROR] runHarnessRefresh_ message=" + msg);
- Logger.log("[HARNESS][ERROR] runHarnessRefresh_ stack=" + stack);
- handleFetchError_("runHarnessRefresh_", e, "CRITICAL");
- }
-}
-
-// ── All-in-one orchestration ────────────────────────────────────────────────
-// 원하는 최종 결과를 한 번에 갱신하는 진입점.
-// 순서:
-// 1) data_feed
-// 2) sector_flow -> macro
-// 3) core_satellite
-// 4) event_risk
-// 5) harness 재생성
-// 6) cache 재생성
-var __RUN_ALL_ORCHESTRATED__ = false;
-
-function isRunAllOrchestrated_() {
- return __RUN_ALL_ORCHESTRATED__ === true;
-}
-
-function setRunAllOrchestrated_(value) {
- __RUN_ALL_ORCHESTRATED__ = value === true;
-}
-
-function clearRunAllState_() {
- const props = PropertiesService.getScriptProperties();
- props.deleteProperty("run_all_step");
- props.deleteProperty("run_all_start_time");
- if (typeof clearFetchCache === "function") {
- try {
- clearFetchCache();
- } catch (e) {
- Logger.log("[RUN_ALL] clearFetchCache failed: " + e.message);
- }
- }
-}
-
-function run_all() {
- const props = PropertiesService.getScriptProperties();
- const runAllInvocationMode = String(props.getProperty("run_all_invocation_mode") || "external_scheduler");
- const invocationStartTime = new Date().getTime();
-
- clearRunAllState_();
- if (typeof beginFetchSession_ === "function") {
- try {
- beginFetchSession_("run_all");
- } catch (e) {
- Logger.log("[RUN_ALL] Failed to auto begin fetch session: " + e.message);
- }
- }
-
- Logger.log("[RUN_ALL] invocation_mode=" + runAllInvocationMode);
-
- const steps = [
- {
- name: "runDaily (Calendar Scraping)",
- fn: function() {
- if (typeof runDaily === "function") {
- try {
- runDaily();
- } catch(e) {
- Logger.log("[WARN] runDaily 실행 중 일부 단계 실패 (단, 스크래핑 및 정렬은 시도됨): " + e.message);
- }
- } else {
- Logger.log("[WARN] runDaily 함수가 정의되어 있지 않아 캘린더 스크래핑을 건너뜁니다.");
- }
- }
- },
- { name: "runSectorFlow", fn: runSectorFlow },
- { name: "runDataFeed", fn: runDataFeed },
- { name: "runCoreSatelliteFlow_", fn: runCoreSatelliteFlow_ },
- { name: "runEventRisk", fn: runEventRisk },
- { name: "runHarnessRefresh_", fn: runHarnessRefresh_ },
- {
- name: "runRebalanceSheet_",
- fn: function() {
- if (typeof runRebalanceSheet_ === "function") {
- runRebalanceSheet_();
- } else {
- Logger.log("[WARN] runRebalanceSheet_ 함수가 정의되어 있지 않아 건너뜁니다. gdf_06_rebalance.gs 배포 여부 확인.");
- }
- }
- },
- ];
-
- Logger.log("[RUN_ALL] start");
- setRunAllOrchestrated_(true);
- try {
- for (let i = 0; i < steps.length; i++) {
- const step = steps[i];
-
- const elapsedBefore = (new Date().getTime() - invocationStartTime) / 1000;
- if (elapsedBefore > 240) {
- Logger.log("[RUN_ALL] 단계 [" + step.name + "] 시작 전 실행 한도 도달 직전 종료 (경과: " + elapsedBefore.toFixed(1) + "초).");
- return;
- }
-
- try {
- Logger.log("[RUN_ALL] step=" + step.name + " start");
- step.fn();
- Logger.log("[RUN_ALL] step=" + step.name + " done");
- } catch (e) {
- if (e.message === "PARTIAL_SAVE_REQUESTED") {
- Logger.log("[RUN_ALL] step=" + step.name + " partial save 요청 수신.");
- return;
- }
- Logger.log("[RUN_ALL][ERROR] step=" + step.name + " message=" + ((e && e.message) ? e.message : String(e)));
- handleFetchError_("run_all:" + step.name, e, "CRITICAL");
- throw e;
- }
- }
-
- scheduleCacheAllViews_();
-
- // 완료 시 Properties 정리 및 예약 트리거 청소
- props.deleteProperty("run_all_invocation_mode");
-
- ScriptApp.getProjectTriggers()
- .filter(t => t.getHandlerFunction() === "run_all")
- .forEach(t => ScriptApp.deleteTrigger(t));
-
- } finally {
- setRunAllOrchestrated_(false);
- }
- Logger.log("[RUN_ALL] done");
-}
-
-function scheduleCacheAllViews_() {
- ScriptApp.getProjectTriggers()
- .filter(t => t.getHandlerFunction() === "cacheAllViews")
- .forEach(t => ScriptApp.deleteTrigger(t));
- ScriptApp.newTrigger("cacheAllViews").timeBased().after(60 * 1000).create();
- Logger.log("[RUN_ALL] step=cacheAllViews scheduled (1min trigger)");
-}
-
-function runCoreSatelliteFlow_() {
- const props = PropertiesService.getScriptProperties();
- const universe = getCoreSatelliteUniverse();
- const totalChunks = Math.max(1, Math.ceil(universe.length / CHUNK_SIZE));
- const startTime = new Date().getTime();
-
- for (let i = 0; i < totalChunks; i++) {
- let chunkIdx = parseInt(props.getProperty("cs_chunk_idx") ?? "0", 10);
- if (chunkIdx >= totalChunks) {
- break;
- }
-
- const elapsed = (new Date().getTime() - startTime) / 1000;
- if (elapsed > 120) {
- Logger.log("[RUN_ALL] core_satellite 청크 " + chunkIdx + " 실행 전 한도 도달 직전 종료 (경과: " + elapsed.toFixed(1) + "초).");
- throw new Error("PARTIAL_SAVE_REQUESTED");
- }
-
- runCoreSatelliteBatch();
- const statusRaw = props.getProperty("cs_status") || "{}";
- let status = {};
- try {
- status = JSON.parse(statusRaw);
- } catch (e) {
- status = {};
- }
- const state = String(status.status || "").toUpperCase();
- if (state === "COMPLETE" || state === "FINALIZED") {
- break;
- }
- }
-}
-
-// ── JSON 캐시 업데이트 ────────────────────────────────────────────────────────
-// 매일 runEventRisk() 완료 후 호출. doGet()이 Sheets를 다시 읽지 않고
-// CacheService 캐시만 반환하므로 응답 시간이 2~8s → <300ms로 단축됨.
-function cacheAllViews() {
- // one-shot 트리거로 실행된 경우 자신을 삭제 (누적 방지)
- ScriptApp.getProjectTriggers()
- .filter(t => t.getHandlerFunction() === "cacheAllViews")
- .forEach(t => ScriptApp.deleteTrigger(t));
-
- const cache = CacheService.getScriptCache();
- const generatedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST";
- const TTL = 3600; // 1시간
- const MAX_CACHE_BYTES = 95 * 1024; // CacheService 실효 한계(100KB) 대비 여유
-
- const sellPriorityView = runSellPriority();
- const views = {
- health: getHealthJson_(),
- meta: getWorkbookMetaJson_(),
- data_feed: getDataFeedJson(),
- // backdata_feature_bank는 누적 운영으로 대용량이므로 캐시 제외 (요청 시 doGet에서 실시간 조회)
- backdata_feature_bank_compact: getBackdataFeatureBankJsonCompact(),
- portfolio: getPortfolioJson(),
- sectors: getSectorFlowJson(),
- macro: getMacroJson(),
- events: getEventRiskJson(),
- orbit_gap: getOrbitGapJson(),
- asset_history: getAssetHistoryJson(),
- brief: getDailyBrief(sellPriorityView),
- sell_priority: sellPriorityView,
- };
-
- // summary는 위 뷰들을 조합 — 개별 결과 재활용
- const port = views.portfolio;
- const sectors = views.sectors;
- const macro = views.macro;
- const events = views.events;
- const orbit = views.orbit_gap;
- const holdings = port.holdings;
- const totalFrg5 = holdings.reduce((s,h) => s + (parseFloat(h.Frg_5D) || 0), 0);
- const totalInst5 = holdings.reduce((s,h) => s + (parseFloat(h.Inst_5D) || 0), 0);
- const flowOkCount = holdings.filter(h => h.Flow_OK === "Y").length;
- const ss001Dist = { A: 0, B: 0, C: 0, D: 0 };
- const actionDist = {};
- holdings.forEach(h => {
- const g = h["SS001_Grade"]; if (g in ss001Dist) ss001Dist[g]++;
- const a = h["Allowed_Action"] || "UNKNOWN"; actionDist[a] = (actionDist[a] ?? 0) + 1;
- });
- views.summary = {
- portfolio_flow_summary: {
- total_holdings: holdings.length,
- data_ok_count: flowOkCount,
- portfolio_frg_5d_total: roundNum(totalFrg5, 0),
- portfolio_inst_5d_total: roundNum(totalInst5, 0),
- portfolio_indiv_5d_total: roundNum(-(totalFrg5 + totalInst5), 0),
- },
- ss001_grade_distribution: ss001Dist,
- action_distribution: actionDist,
- sector_summary: {
- total_sectors: sectors.count,
- top_inflow_sectors: sectors.top_inflow,
- outflow_warning_sectors: sectors.outflow_warning,
- strong_smart_money_sectors:sectors.strong_smart_money,
- },
- macro_snapshot: {
- vix: macro.vix,
- usd_krw: macro.usd_krw,
- kospi: macro.kospi,
- sp500_5d_ret: macro.sp500_ret5d,
- market_regime: macro.market_regime,
- mrs_score: macro.mrs_score,
- bayesian_multiplier:macro.bayesian_multiplier,
- total_heat_pct: macro.total_heat_pct,
- fc_budget_pct: macro.fc_budget_pct,
- net_return_feedback:macro.net_return_feedback,
- orbit_gap_pct: macro.orbit_gap_pct,
- orbit_state: macro.orbit_state,
- orbit_slot_adj: macro.orbit_slot_adj,
- },
- event_alerts: events.upcoming_7d,
- holdings_detail: holdings,
- sector_detail: sectors.sectors,
- macro_computed: macro.computed_summary,
- orbit_current: orbit.current,
- };
-
- // 각 뷰를 CacheService에 저장 (최대 100KB/키)
- for (const [view, payload] of Object.entries(views)) {
- payload.view = view;
- payload.generated_at = generatedAt;
- try {
- const serialized = JSON.stringify(payload, null, 2);
- if (serialized.length > MAX_CACHE_BYTES) {
- Logger.log(`캐시 스킵 (${view}): payload too large ${serialized.length} bytes`);
- continue;
- }
- cache.put(`view_${view}`, serialized, TTL);
- } catch(e) {
- Logger.log(`캐시 저장 실패 (${view}): ${e.message}`);
- }
- }
- Logger.log(`cacheAllViews 완료 (TTL: ${TTL}s)`);
-}
-
-// ────────────────────────────────────────────────────────────────────────────
-// Phase 3: Web App API (doGet) — Custom GPT Action 엔드포인트
-//
-// 배포: script.google.com → 배포 → 웹 앱 → 실행 권한: "모든 사용자"
-// URL: https://script.google.com/macros/s/{DEPLOYMENT_ID}/exec
-//
-// Custom GPT에서 ?view=summary 로 호출 → 포트폴리오 분석 JSON 반환
-// ────────────────────────────────────────────────────────────────────────────
-const VIEW_GID_MAP = {
- "1835496032": "macro",
- "361215520": "events",
- "857909836": "sectors",
- "1266919040": "data_feed",
- "1490216937": "core_satellite",
-};
-
-function doGet(e) {
- const rawView = String(e?.parameter?.view ?? "").trim().toLowerCase();
- const rawGid = String(e?.parameter?.gid ?? "").trim();
- const compactFlag_ = parseCompactFlag_(e?.parameter?.compact);
- const view = rawView || VIEW_GID_MAP[rawGid] || "summary";
-
- // ① 캐시 우선 반환 — 매일 runEventRisk() 완료 시 cacheAllViews()가 채워 둠
- // 캐시 HIT: <300ms, 캐시 MISS(만료·첫 호출): Sheets 직접 읽기(2~5s)
- const cache = CacheService.getScriptCache();
- const cached = cache.get(`view_${view}`);
- if (cached) {
- return ContentService
- .createTextOutput(cached)
- .setMimeType(ContentService.MimeType.JSON);
- }
-
- // ② 캐시 MISS → Sheets에서 직접 읽어 반환 (기존 동작 유지)
- let payload;
- try {
- switch(view) {
- case "health": payload = getHealthJson_(); break;
- case "meta": payload = getWorkbookMetaJson_(); break;
- case "all": payload = getAllJson_(compactFlag_); break;
- case "raw_all": payload = getRawAllJson_(compactFlag_); break;
- case "data_feed": payload = getDataFeedJson(); break;
- case "backdata_feature_bank": payload = compactFlag_ ? getBackdataFeatureBankJsonCompact() : getBackdataFeatureBankJson(); break;
- case "backdata_feature_bank_compact": payload = getBackdataFeatureBankJsonCompact(); break;
- case "sectors": payload = getSectorFlowJson(); break;
- case "portfolio": payload = getPortfolioJson(); break;
- case "core_satellite": payload = getCoreSatelliteJson(compactFlag_); break;
- case "macro": payload = getMacroJson(); break;
- case "events": payload = getEventRiskJson(); break;
- case "orbit_gap": payload = getOrbitGapJson(); break;
- case "brief": payload = getDailyBrief(null); break;
- case "sell_priority": payload = runSellPriority(); break;
- case "asset_history": payload = getAssetHistoryJson(); break;
- case "source_health": payload = checkDataSourceHealth(); break;
- case "trade_template":
- payload = getTradeTemplate(String(e?.parameter?.ticker ?? "").trim()); break;
- case "init_account_snapshot":
- payload = initAccountSnapshotTemplate_(); break;
- case "summary":
- default: payload = getSummaryJson(); break;
- }
- payload.view = view;
- payload.generated_at = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss") + " KST";
- } catch(err) {
- payload = { error: err.message, view };
- }
-
- return ContentService
- .createTextOutput(JSON.stringify(payload, null, 2))
- .setMimeType(ContentService.MimeType.JSON);
-}
-
-// ── Sheets → JSON 변환 헬퍼 ───────────────────────────────────────────────
-function parseCompactFlag_(value) {
- const raw = String(value ?? "").trim().toLowerCase();
- return raw === "1" || raw === "true" || raw === "yes" || raw === "y";
-}
-
-function getHealthJson_() {
- return {
- status: "OK",
- mode: "health",
- app: "gas_data_feed",
- schema_version: SCHEMA_VERSION,
- spreadsheet_id: SPREADSHEET_ID,
- timezone: "Asia/Seoul",
- available_views: ["health","summary","brief","data_feed","backdata_feature_bank","backdata_feature_bank_compact","core_satellite","sell_priority","macro","events","sectors","portfolio","orbit_gap","asset_history","trade_template","all","raw_all"],
- transport_policy: {
- canonical_transport: "HTTP GET",
- canonical_client: "Invoke-WebRequest / curl / script fetch",
- direct_open: "may be blocked by session policy",
- },
- };
-}
-
-function getWorkbookMetaJson_() {
- const ss = getSpreadsheet_();
- const sheets = ss.getSheets().map(sheet => {
- const data = sheet.getDataRange().getValues();
- const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim();
- const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null;
- const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : [];
- const rowCount = data.length >= 3 ? data.slice(2).filter(r => r.some(c => c !== "")).length : 0;
- return {
- sheet: sheet.getName(),
- gid: sheet.getSheetId(),
- hidden: sheet.isSheetHidden(),
- updated_at: updatedAt,
- count: rowCount,
- header_count: headers.length,
- };
- });
- return {
- mode: "meta",
- schema_version: SCHEMA_VERSION,
- sheet_count: sheets.length,
- sheets,
- };
-}
-
-function getSheetEnvelopeJson_(sheetName, gid, options) {
- const compact = Boolean(options?.compact);
- const maxRows = Number.isFinite(Number(options?.maxRows)) ? Math.max(0, Number(options.maxRows)) : null;
- const ss = getSpreadsheet_();
- const sheet = ss.getSheetByName(sheetName);
- if (!sheet) {
- return {
- sheet: sheetName,
- gid: gid ?? null,
- schema_version: SCHEMA_VERSION,
- updated_at: null,
- count: 0,
- headers: [],
- rows: [],
- compact: false,
- truncated: false,
- };
- }
-
- const data = sheet.getDataRange().getValues();
- const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim();
- const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null;
- const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : [];
- const rowsFull = sheetToJson(sheetName);
- const rows = compact && Number.isFinite(maxRows) ? rowsFull.slice(0, maxRows) : rowsFull;
-
- return {
- sheet: sheetName,
- gid: gid ?? null,
- schema_version: SCHEMA_VERSION,
- updated_at: updatedAt,
- count: rowsFull.length,
- headers,
- rows,
- compact,
- truncated: rows.length < rowsFull.length,
- };
-}
-
-function sheetToJson(sheetName) {
- const ss = getSpreadsheet_();
- const sheet = ss.getSheetByName(sheetName);
- if (!sheet) return [];
- const data = sheet.getDataRange().getValues();
- // row[0] = updated 메타, row[1] = 헤더, row[2..] = 데이터
- if (data.length < 3) return [];
- const headers = data[1].map(h => String(h).trim());
- // 날짜 컬럼 식별 (AsOfDate, Updated_At, Date, Price_Date)
- const dateCols = new Set(["AsOfDate","Updated_At","Date","Price_Date"]);
- return data.slice(2).filter(r => r.some(c => c !== "")).map(r => {
- const obj = {};
- headers.forEach((h, i) => {
- const v = r[i];
- // Date 객체 → "yyyy-MM-dd" 문자열로 직렬화
- if (v instanceof Date && !isNaN(v)) {
- obj[h] = Utilities.formatDate(v, "Asia/Seoul", "yyyy-MM-dd");
- } else {
- obj[h] = v;
- }
- });
- return obj;
- });
-}
-
-function getSectorFlowJson() {
- const sectors = sheetToJson("sector_flow");
- return {
- sectors,
- top_inflow: sectors.filter(s => s.Alert_Level === "INFLOW_STRONG").map(s => s.Sector),
- outflow_warning: sectors.filter(s => ["OUTFLOW_ALERT","OUTFLOW_CAUTION"].includes(s.Alert_Level)).map(s => s.Sector),
- strong_smart_money: sectors.filter(s => s.Smart_Money === "STRONG").map(s => s.Sector),
- count: sectors.length
- };
-}
-
-function getPortfolioJson() {
- const holdings = sheetToJson("data_feed");
- return { holdings, count: holdings.length };
-}
-
-function getDataFeedJson() {
- return getSheetEnvelopeJson_("data_feed", 1266919040, { compact: false });
-}
-
-function getBackdataFeatureBankJson() {
- return getSheetEnvelopeJson_("backdata_feature_bank", null, { compact: false });
-}
-
-function getBackdataFeatureBankJsonCompact() {
- return getSheetEnvelopeJson_("backdata_feature_bank", null, { compact: true, maxRows: 50 });
-}
-
-function getCoreSatelliteJson(compact) {
- return getSheetEnvelopeJson_("core_satellite", 1490216937, {
- compact: Boolean(compact),
- maxRows: compact ? 20 : null,
- });
-}
-
-function getAllJson_(compact) {
- return {
- data_feed: getDataFeedJson(),
- backdata_feature_bank: getBackdataFeatureBankJson(),
- core_satellite: getCoreSatelliteJson(compact),
- sector_flow: getSectorFlowJson(),
- macro: getMacroJson(),
- event_risk: getEventRiskJson(),
- summary: getSummaryJson(),
- };
-}
-
-function getRawAllJson_(compact) {
- const ss = getSpreadsheet_();
- const sheets = ss.getSheets();
- const maxRows = compact ? 20 : null;
- const payloadSheets = sheets.map(sheet => {
- const name = sheet.getName();
- const gid = sheet.getSheetId();
- const data = sheet.getDataRange().getValues();
- const rawMeta = String(sheet.getRange(1, 1).getDisplayValue() || "").trim();
- const updatedAt = rawMeta ? rawMeta.replace(/^updated:\s*/i, "") : null;
- const headers = data.length >= 2 ? data[1].map(h => String(h).trim()) : [];
- const rowsFull = data.length >= 3 ? data.slice(2).filter(r => r.some(c => c !== "")).map(r => {
- const obj = {};
- headers.forEach((h, i) => {
- const v = r[i];
- if (v instanceof Date && !isNaN(v)) {
- obj[h] = Utilities.formatDate(v, "Asia/Seoul", "yyyy-MM-dd");
- } else {
- obj[h] = v;
- }
- });
- return obj;
- }) : [];
- const rows = compact && Number.isFinite(maxRows) ? rowsFull.slice(0, maxRows) : rowsFull;
- return {
- sheet: name,
- gid,
- sheet_id: gid,
- hidden: sheet.isSheetHidden(),
- updated_at: updatedAt,
- count: rowsFull.length,
- headers,
- rows,
- compact: Boolean(compact),
- truncated: rows.length < rowsFull.length,
- };
- });
-
- return {
- mode: "raw_all",
- schema_version: SCHEMA_VERSION,
- sheet_count: payloadSheets.length,
- compact: Boolean(compact),
- sheets: payloadSheets,
- };
-}
-
-// 숫자 배열의 중앙값 (양수만, 빈 배열이면 null)
-function calcMedian_(arr) {
- const nums = arr.filter(v => Number.isFinite(v) && v > 0);
- if (!nums.length) return null;
- nums.sort((a, b) => a - b);
- const mid = Math.floor(nums.length / 2);
- return nums.length % 2 === 0 ? (nums[mid - 1] + nums[mid]) / 2 : nums[mid];
-}
-
-// float32 → float64 노이즈 제거: 숫자 값을 소수점 4자리로 정리
-function roundNum(v, digits) {
- if (typeof v !== "number" || isNaN(v)) return v;
- return parseFloat(v.toFixed(digits ?? 4));
-}
-
-function getMacroJson() {
- const macro = sheetToJson("macro").map(m => ({
- ...m,
- Close: roundNum(m.Close, 4),
- Ret1D: roundNum(m.Ret1D, 2),
- Ret5D: roundNum(m.Ret5D, 2),
- Ret20D: roundNum(m.Ret20D, 2),
- }));
- const byName = {};
- macro.forEach(m => { byName[m.Name] = m; });
- // MRS 요약 추출
- const mrsRow = byName["Market_Risk_Score"] ?? {};
- const regimeRow = byName["Market_Regime_Prelim"] ?? {};
- const bayesRow = byName["Bayesian_Multiplier"] ?? {};
- const heatRow = byName["Total_Heat_Pct"] ?? {};
- const fcRow = byName["FC_Loss_Budget_Monthly"] ?? {};
- const netRFRow = byName["Net_Return_Feedback"] ?? {};
- const orbitGapRow = byName["Orbit_Gap_Pct"] ?? {};
- const orbitStRow = byName["Orbit_State"] ?? {};
- const bucketRow = byName["Bucket_Allocation_Status"] ?? {};
- return {
- indicators: macro.filter(m => m.Category !== "Computed"),
- computed_summary: macro.filter(m => m.Category === "Computed"),
- vix: roundNum(byName["VIX"]?.Close, 2) ?? "N/A",
- usd_krw: roundNum(byName["USD_KRW"]?.Close, 2) ?? "N/A",
- kospi: roundNum(byName["KOSPI"]?.Close, 2) ?? "N/A",
- kospi_ma20: roundNum(byName["KOSPI"]?.MA20, 2) ?? "N/A",
- kospi_ma60: roundNum(byName["KOSPI"]?.MA60, 2) ?? "N/A",
- usd_jpy_ret2d: roundNum(byName["USD_JPY"]?.Ret2D, 2) ?? "N/A",
- hyg_ret5d: roundNum(byName["HYG_HY_Bond"]?.Ret5D, 2) ?? "N/A",
- sp500_ret5d: roundNum(byName["SP500"]?.Ret5D, 2) ?? "N/A",
- mrs_score: mrsRow.Close ?? "N/A",
- mrs_status: mrsRow.Status ?? "N/A",
- market_regime: regimeRow.Close ?? "N/A",
- credit_stress: String(regimeRow.Status ?? "").replace("credit_stress=", "") || "N/A",
- bayesian_multiplier: bayesRow.Close ?? "N/A",
- bayesian_label: bayesRow.Status ?? "N/A",
- // trades=0 이면 performance 탭 데이터 없는 기본값; 1건 이상이면 실제 거래 기반
- bayesian_data_source: (String(bayesRow.Status ?? "").match(/trades=(\d+)/)?.[1] ?? "0") !== "0" ? "actual" : "default",
- total_heat_pct: heatRow.Close ?? "N/A",
- total_heat_gate: heatRow.Status ?? "N/A",
- fc_budget_pct: fcRow.Close ?? "N/A",
- fc_budget_status: fcRow.Status ?? "N/A",
- net_return_feedback: netRFRow.Close ?? "N/A",
- net_return_detail: netRFRow.Status ?? "N/A",
- orbit_gap_pct: orbitGapRow.Close ?? "N/A",
- orbit_gap_detail: orbitGapRow.Status ?? "N/A",
- orbit_state: orbitStRow.Close ?? "N/A",
- orbit_slot_adj: String(orbitStRow.Status ?? "").match(/slot_adj=(-?\d+)/)?.[1] ?? "N/A",
- orbit_cash_adj: String(orbitStRow.Status ?? "").match(/cash_adj=(-?\d+)/)?.[1] ?? "N/A",
- bucket_status: bucketRow.Close ?? "N/A",
- bucket_detail: bucketRow.Status ?? "N/A",
- };
-}
-
-function getEventRiskJson() {
- const events = sheetToJson("event_risk");
- const urgent = events.filter(e => +e.DaysLeft >= 0 && +e.DaysLeft <= 7);
- return { events, upcoming_7d: urgent };
-}
-
-function getOrbitGapJson() {
- const history = sheetToJson("monthly_history");
- if (!history.length) return { history: [], current: null };
- const latest = history[history.length - 1];
- return {
- history,
- current: {
- month: latest.Month,
- orbit_gap_pct: latest.Orbit_Gap_Pct,
- orbit_state: latest.Orbit_State,
- offensive_slot_adj: latest.Slot_Adj,
- cash_floor_adj: latest.Cash_Floor_Adj,
- target_return_pct: latest.Target_Return_Pct,
- actual_return_pct: latest.Actual_Return_Pct,
- },
- };
-}
-
-// ── [2026-05-21_AFL_V1] ALPHA_FEEDBACK_LOOP_V1 -- monthly grade analysis ────────
-function runAlphaFeedbackLoop_() {
- var ss = getSpreadsheet_();
- var sheet = ss.getSheetByName("alpha_history");
- var today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
- var monthKey = today.substring(0, 7);
- var defaultPayload = {
- formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
- as_of: today,
- analysis_period: monthKey,
- status: 'DATA_MISSING',
- cases_analyzed: 0,
- grade_count: 0,
- eligible_t20_fail_rate: null,
- eligible_t60_fail_rate: null,
- recommended_filter_adjustments: [],
- grade_summary: []
- };
- if (!sheet) {
- writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(defaultPayload));
- Logger.log("[AFL] alpha_history sheet not found");
- return defaultPayload;
- }
- var data = sheet.getDataRange().getValues();
- if (data.length < 2) {
- writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(defaultPayload));
- Logger.log("[AFL] alpha_history has no data");
- return defaultPayload;
- }
-
- var hdrRow = data[0];
- var hdrMap = {};
- hdrRow.forEach(function(h, i) { hdrMap[h] = i; });
-
- var gradeStats = {};
- var analyzedCases = 0;
- for (var i = 1; i < data.length; i++) {
- var row = data[i];
- var grade = String(row[hdrMap['SAQG_Grade_At_Entry']] || '').trim();
- var t20g = String(row[hdrMap['T20_Alpha_Gate']] || '').trim();
- var t60g = String(row[hdrMap['T60_Alpha_Gate']] || '').trim();
- if (!grade) continue;
- if (!gradeStats[grade]) gradeStats[grade] = { t20_total: 0, t20_pass: 0, t60_total: 0, t60_pass: 0 };
- var s = gradeStats[grade];
- var skipVals = { 'NOT_YET': 1, 'EXEMPT': 1, 'DATA_MISSING': 1, '': 1 };
- var hasT20 = t20g && !skipVals[t20g];
- var hasT60 = t60g && !skipVals[t60g];
- if (hasT20) { s.t20_total++; if (t20g === 'T20_ALPHA_PASS') s.t20_pass++; }
- if (hasT60) { s.t60_total++; if (t60g === 'T60_ALPHA_PASS') s.t60_pass++; }
- if (hasT20 || hasT60) analyzedCases++;
- }
-
- var gradeSummary = [];
- Object.keys(gradeStats).sort().forEach(function(grade) {
- var s = gradeStats[grade];
- var t20FailRate = s.t20_total > 0 ? parseFloat((((s.t20_total - s.t20_pass) / s.t20_total) * 100).toFixed(2)) : null;
- var t60FailRate = s.t60_total > 0 ? parseFloat((((s.t60_total - s.t60_pass) / s.t60_total) * 100).toFixed(2)) : null;
- var t20PassRate = s.t20_total > 0 ? parseFloat(((s.t20_pass / s.t20_total) * 100).toFixed(2)) : null;
- var t60PassRate = s.t60_total > 0 ? parseFloat(((s.t60_pass / s.t60_total) * 100).toFixed(2)) : null;
- gradeSummary.push({
- grade: grade,
- t20_total: s.t20_total,
- t20_pass: s.t20_pass,
- t20_pass_rate: t20PassRate,
- t20_fail_rate: t20FailRate,
- t60_total: s.t60_total,
- t60_pass: s.t60_pass,
- t60_pass_rate: t60PassRate,
- t60_fail_rate: t60FailRate,
- status: (s.t20_total >= 10 || s.t60_total >= 10) ? 'ANALYZED' : 'DATA_INSUFFICIENT'
- });
- });
-
- var eligibleRow = gradeStats['ELIGIBLE'] || { t20_total: 0, t20_pass: 0, t60_total: 0, t60_pass: 0 };
- var eligibleT20FailRate = eligibleRow.t20_total > 0
- ? parseFloat((((eligibleRow.t20_total - eligibleRow.t20_pass) / eligibleRow.t20_total) * 100).toFixed(2))
- : null;
- var eligibleT60FailRate = eligibleRow.t60_total > 0
- ? parseFloat((((eligibleRow.t60_total - eligibleRow.t60_pass) / eligibleRow.t60_total) * 100).toFixed(2))
- : null;
- var eligibleT20PassRate = eligibleRow.t20_total > 0
- ? parseFloat(((eligibleRow.t20_pass / eligibleRow.t20_total) * 100).toFixed(2))
- : null;
-
- var recommendations = [];
- if (analyzedCases >= 10) {
- if (eligibleT20FailRate !== null && eligibleT20FailRate > 50) {
- recommendations.push({
- filter_id: 'SAQG_F2_RECOVERY_RATIO',
- current: '1.20',
- recommended: '1.35',
- rationale: 'ELIGIBLE T+20 fail rate > 50%',
- action: 'TIGHTEN'
- });
- recommendations.push({
- filter_id: 'SAQG_F3_EXCESS_DRAWDOWN',
- current: '5%p',
- recommended: '4%p',
- rationale: 'ELIGIBLE T+20 fail rate > 50%',
- action: 'TIGHTEN'
- });
- } else if (eligibleT20PassRate !== null && eligibleT20PassRate > 70 && eligibleRow.t20_total >= 12) {
- recommendations.push({
- filter_id: 'SAQG_F3_EXCESS_DRAWDOWN',
- current: '5%p',
- recommended: '7%p',
- rationale: 'ELIGIBLE T+20 success rate > 70% and cases >= 12',
- action: 'RELAX_REVIEW'
- });
- } else {
- recommendations.push({
- filter_id: 'SAQG_F1_F2_F3',
- current: 'UNCHANGED',
- recommended: 'HOLD',
- rationale: 'No threshold change supported by current sample',
- action: 'HOLD'
- });
- }
- }
-
- var payload = {
- formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
- as_of: today,
- analysis_period: monthKey,
- status: analyzedCases >= 10 ? 'ANALYZED' : 'DATA_INSUFFICIENT',
- cases_analyzed: analyzedCases,
- grade_count: Object.keys(gradeStats).length,
- eligible_t20_fail_rate: eligibleT20FailRate,
- eligible_t60_fail_rate: eligibleT60FailRate,
- recommended_filter_adjustments: analyzedCases >= 10 ? recommendations : [],
- grade_summary: gradeSummary
- };
- writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(payload));
- Logger.log('[AFL] done - ' + payload.grade_count + ' grades analyzed, cases=' + analyzedCases);
- return payload;
-}
-
-// ── E2: 월말 자산 스냅샷 → monthly_history 기록 ─────────────────────────────
-// 트리거: 매달 마지막 영업일 16:30 독립 실행 OR runDataFeed 완료 후 호출.
-function runMonthlySnapshot() {
- const settings = readSettingsTab_();
- const totalAsset = parseFloat(settings["total_asset_krw"]);
- if (!Number.isFinite(totalAsset) || totalAsset <= 0) {
- Logger.log("runMonthlySnapshot 스킵: total_asset_krw 미설정");
- return;
- }
- const month = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM");
-
- // macro에서 버킷·orbit 읽기
- const macro = getMacroJson();
- const bDetail = String(macro.bucket_detail ?? "");
- const corePct = parseFloat(bDetail.match(/core=([\d.]+)%/)?.[1] ?? "") || "";
- const satPct = parseFloat(bDetail.match(/sat=([\d.]+)%/)?.[1] ?? "") || "";
- const cashPct = parseFloat(bDetail.match(/cash=([\d.]+)%/)?.[1] ?? "") || "";
- const orbitGap = macro.orbit_gap_pct !== "N/A" ? macro.orbit_gap_pct : "";
- const orbitState = macro.orbit_state !== "N/A" ? macro.orbit_state : "";
-
- // MoM/YTD: monthly_history에서 이전 자산 읽기
- const ss = getSpreadsheet_();
- const histSheet = ss.getSheetByName("monthly_history");
- let prevAsset = null, jan1Asset = null;
- const thisYear = month.substring(0, 4);
- if (histSheet) {
- const hd = histSheet.getDataRange().getValues();
- const hdr = hd[0] ?? [];
- const mIdx = hdr.indexOf("Month");
- const aIdx = hdr.indexOf("Total_Asset");
- if (mIdx >= 0 && aIdx >= 0) {
- for (let i = 1; i < hd.length; i++) {
- const raw = hd[i][mIdx];
- const mStr = raw instanceof Date && !isNaN(raw.getTime())
- ? Utilities.formatDate(raw, "Asia/Seoul", "yyyy-MM")
- : String(raw ?? "").trim().substring(0, 7);
- if (mStr === month) continue;
- const a = parseFloat(hd[i][aIdx]);
- if (mStr && Number.isFinite(a)) {
- prevAsset = a;
- if (mStr === `${thisYear}-01`) jan1Asset = a;
- }
- }
- }
- }
-
- const momRet = (prevAsset && prevAsset > 0)
- ? parseFloat(((totalAsset / prevAsset - 1) * 100).toFixed(2)) : "";
- const ytdRet = (jan1Asset && jan1Asset > 0)
- ? parseFloat(((totalAsset / jan1Asset - 1) * 100).toFixed(2)) : "";
-
- // AEW aggregate: T+20/T+60 outcomes this month from alpha_history
- var satT20PassN = 0, satT20FailN = 0, satT60PassN = 0;
- var satT20AlphaSum = 0, satT20AlphaCount = 0;
- var alphaSheet = ss.getSheetByName("alpha_history");
- if (alphaSheet) {
- var aData = alphaSheet.getDataRange().getValues();
- if (aData.length > 1) {
- var aHdr = aData[0];
- var aMap = {};
- aHdr.forEach(function(h, i) { aMap[String(h)] = i; });
- var skipSet = { 'NOT_YET': 1, 'EXEMPT': 1, 'DATA_MISSING': 1, '': 1 };
- for (var ai = 1; ai < aData.length; ai++) {
- var ar = aData[ai];
- var t20cd = String(ar[aMap['T20_Check_Date']] || '');
- if (!t20cd || t20cd.substring(0, 7) !== month) continue;
- var t20g = String(ar[aMap['T20_Alpha_Gate']] || '');
- var t60g = String(ar[aMap['T60_Alpha_Gate']] || '');
- var t20v = parseFloat(ar[aMap['T20_Vs_Core_Pctp']]);
- if (t20g === 'T20_ALPHA_PASS') satT20PassN++;
- else if (t20g === 'T20_ALPHA_FAIL') satT20FailN++;
- if (t60g === 'T60_ALPHA_PASS') satT60PassN++;
- if (!skipSet[t20g] && Number.isFinite(t20v)) {
- satT20AlphaSum += t20v;
- satT20AlphaCount++;
- }
- }
- }
- }
- var satAvgT20Alpha = satT20AlphaCount > 0
- ? parseFloat((satT20AlphaSum / satT20AlphaCount).toFixed(2)) : '';
-
- try {
- runAlphaFeedbackLoop_();
- } catch (e) {
- Logger.log('[AFL] runAlphaFeedbackLoop_ in runMonthlySnapshot error: ' + e.message);
- }
-
- upsertMonthlyRow_(month, {
- Total_Asset: totalAsset,
- Core_Pct: corePct,
- Satellite_Pct: satPct,
- Cash_Pct: cashPct,
- MoM_Return_Pct: momRet,
- YTD_Return_Pct: ytdRet,
- Orbit_Gap_Pct: orbitGap,
- Orbit_State: orbitState,
- Sat_T20_Pass_N: satT20PassN || '',
- Sat_T20_Fail_N: satT20FailN || '',
- Sat_T60_Pass_N: satT60PassN || '',
- Sat_Avg_T20_Alpha_Pct: satAvgT20Alpha,
- });
- Logger.log(`monthly_history(snapshot): ${month} asset=${totalAsset.toLocaleString()} MoM=${momRet}% YTD=${ytdRet}%`);
-}
-
-// ── E4: 데이터 소스 정합성 주 1회 헬스체크 ──────────────────────────────────
-// 트리거: 주 1회 (매주 월요일 09:00) 독립 실행.
-// Naver 가격/수급 스크래핑 패턴 정상 여부를 확인하고 Logger에 리포트를 남긴다.
-// doGet(?view=source_health) 로도 조회 가능.
-function checkDataSourceHealth() {
- const PROBE_TICKER = Object.keys(TICKER_SECTOR_MAP)[0] ?? "005930"; // 첫 번째 종목(기본 삼성전자)
- const results = { checked_at: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm"), probe_ticker: PROBE_TICKER, checks: [] };
-
- const ok = (name, detail) => { results.checks.push({ name, status: "OK", detail: detail ?? "" }); };
- const fail = (name, detail) => { results.checks.push({ name, status: "FAIL", detail: detail ?? "" }); };
-
- // 1. Naver 종목 시세 (Close 패턴)
- try {
- beginFetchSession_();
- const url = `https://finance.naver.com/item/main.nhn?code=${PROBE_TICKER}`;
- const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
- const html = resp.getContentText("EUC-KR");
- const closeMatch = html.match(/]*>([\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(/([\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(/]*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 + '%';
-}
diff --git a/gas_report.gs b/gas_report.gs
deleted file mode 100644
index 7b5cb14..0000000
--- a/gas_report.gs
+++ /dev/null
@@ -1,446 +0,0 @@
-// gas_report.gs - Report & template generation
-// getDailyBrief, getSummaryJson, getTradeTemplate
-// Changes only when report format changes. Rarely touched during engine work.
-// GAS global scope: functions in gas_lib.gs / gas_data_feed.gs callable directly
-
-
-// ── E1: 일일 의사결정 브리핑 ─────────────────────────────────────────────────
-// 시장 상태·포트폴리오 건강·액션 목록·주의 종목·7일 이벤트를 한 JSON으로 통합.
-// doGet(?view=brief) 또는 cacheAllViews()에서 매일 1회 생성.
-function getDailyBrief(sellPriorityViewInput) {
- const macro = getMacroJson();
- const settings = readSettingsTab_();
- const port = getPortfolioJson();
- const events = getEventRiskJson();
- const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
- const holdings = port.holdings ?? [];
-
- // ── 액션 분류: Final_Action canonical 기준 (A-1/B-1 — Allowed_Action 기반 제거) ──
- // Final_Action이 canonical output field. Allowed_Action은 중간 계산값.
- const BUY_FINALS_ = new Set(["BUY_STAGE1_READY","BUY_BREAKOUT_PILOT_ONLY","BUY_PULLBACK_WAIT"]);
- const SELL_FINALS_ = new Set(["SELL_READY"]);
- const EXIT_FINALS_ = new Set(["EXIT_SIGNAL","EXIT_REVIEW"]);
-
- const sellList = holdings.filter(h => SELL_FINALS_.has(h.Final_Action));
- const exitList = holdings.filter(h => EXIT_FINALS_.has(h.Final_Action));
- const buyList = holdings.filter(h => BUY_FINALS_.has(h.Final_Action));
- const watchList = holdings.filter(h => h.Final_Action === "WATCH_TIMING_SETUP");
- const holdList = holdings.filter(h =>
- !SELL_FINALS_.has(h.Final_Action) && !EXIT_FINALS_.has(h.Final_Action) &&
- !BUY_FINALS_.has(h.Final_Action) && h.Final_Action !== "WATCH_TIMING_SETUP"
- );
-
- // 주의 종목
- const stage2Pass = holdings.filter(h => h.Stage2_Gate === "PASS");
- const timeStopNear= holdings.filter(h => Number.isFinite(+h.Days_To_Time_Stop)
- && +h.Days_To_Time_Stop >= 0
- && +h.Days_To_Time_Stop <= 7);
- const overweight = holdings.filter(h => h.Band_Status === "OVERWEIGHT");
- const tp1Near = holdings.filter(h => Number.isFinite(+h.Profit_Pct) && +h.Profit_Pct >= 10);
-
- // 포트폴리오 건강 판단
- const heatVal = parseFloat(macro.total_heat_pct);
- const fcVal = parseFloat(macro.fc_budget_pct);
- const heatOk = Number.isFinite(heatVal) && heatVal < 10;
- const heatCautionB= Number.isFinite(heatVal) && heatVal >= 7 && heatVal < 10;
- const heatBlockB = Number.isFinite(heatVal) && heatVal >= 10;
- const fcOk = Number.isFinite(fcVal) && fcVal < 100;
- const regimeStr = String(macro.market_regime ?? "");
- const isRiskOffB = regimeStr === "RISK_OFF" || regimeStr === "RISK_OFF_CANDIDATE";
- const nrf = macro.net_return_feedback;
- const orbitAdj= parseInt(macro.orbit_slot_adj) || 0;
-
- // account_snapshot freshness 체크
- const acctFresh = checkAccountSnapshotFreshness_();
-
- // 텍스트 브리핑 (ChatGPT 직접 복붙용)
- const L = [];
- const hardBlockWarn = String(settings["cash_floor_hard_block_warning"] ?? "").trim();
- const accountConfirmWarn = String(settings["account_snapshot_confirmation_warning"] ?? "").trim();
- const cashLedgerWarn = String(settings["cash_ledger_warning"] ?? "").trim();
- if (hardBlockWarn) L.push(`[긴급 경고] ${hardBlockWarn}`);
- if (accountConfirmWarn) L.push(`[운영 경고] ${accountConfirmWarn}`);
- if (cashLedgerWarn) L.push(`[운영 경고] ${cashLedgerWarn}`);
- L.push(`[시장] ${macro.market_regime} / MRS ${macro.mrs_score}/10 / VIX ${macro.vix} / KOSPI ${macro.kospi} / USD/KRW ${macro.usd_krw}`);
- const heatTag = heatBlockB ? "⚠HF005:BLOCK" : heatCautionB ? "⚠CAUTION:수량50%감액" : "OK";
- L.push(`[포트폴리오] HEAT ${macro.total_heat_pct}%(${heatTag}) / FC ${macro.fc_budget_pct}%(${fcOk?"OK":"⚠EXHAUSTED"}) / ${nrf} / BUCKET ${macro.bucket_status}`);
- if (isRiskOffB) L.push(`[⚠ 레짐 차단] ${regimeStr} — 신규 매수 전면 차단, 보유 종목 50% 단계 축소 검토`);
- const bayesSourceTag = macro.bayesian_data_source === "actual" ? "실제거래기반" : "기본값(거래이력없음)";
- L.push(`[Bayesian] ${macro.bayesian_label} (${macro.bayesian_multiplier}×) — ${bayesSourceTag}`);
- if (acctFresh.fresh === false) L.push(`[⚠ account_snapshot STALE] ${acctFresh.reason} — 손절가·수량 재확인 필요`);
- else if (acctFresh.fresh === null) L.push(`[⚠ account_snapshot] ${acctFresh.reason}`);
-
- // 데이터 신선도 경고 — PRICE_STALE / PRICE_QUOTE_ONLY / FLOW_STALE
- const priceStaleList_ = holdings.filter(h => h.Price_Status === "PRICE_STALE");
- const quoteOnlyList_ = holdings.filter(h => h.Price_Status === "PRICE_QUOTE_ONLY");
- const flowStaleList_ = holdings.filter(h => String(h.Missing_Fields ?? "").includes("FLOW_STALE"));
- if (priceStaleList_.length)
- L.push(`[⚠ 가격 스테일] ${priceStaleList_.map(h => h.Name).join(", ")} — OHLC 날짜 오래됨, runDataFeed 재실행 권장`);
- if (quoteOnlyList_.length)
- L.push(`[⚠ 호가전용] ${quoteOnlyList_.map(h => h.Name).join(", ")} — OHLC 수집 실패, MA/ATR 결측 → OBSERVE_ONLY 처리`);
- if (flowStaleList_.length)
- L.push(`[⚠ 수급 스테일] ${flowStaleList_.map(h => h.Name).join(", ")} — 외국인/기관 수급 날짜 오래됨`);
-
- if (orbitAdj !== 0)
- L.push(`[Orbit] ${macro.orbit_state} → 공격슬롯 ${orbitAdj>0?"+":""}${orbitAdj}개 / 현금조정 ${macro.orbit_cash_adj}%p`);
- // ── C-1: Final_Action 기준 단일 우선순위 목록 ─────────────────────────────
- // 우선순위 순서: SELL_READY > EXIT_* > BUY > WATCH > HOLD
- // 같은 그룹 내에서는 Final_Rank(Priority_Score) 오름차순
- const byRank = (arr) => [...arr].sort((a, b) => (+a.Final_Rank || 999) - (+b.Final_Rank || 999));
-
- L.push("─".repeat(44));
- L.push(`[오늘 액션] — ${today} (Final_Action 기준, 우선순위 정렬)`);
-
- if (sellList.length) {
- L.push(" ▶ SELL_READY (즉시 HTS 주문 가능)");
- byRank(sellList).forEach((h, i) => {
- const r = h.Action_Reason || `${h.Sell_Action} ${h.Sell_Qty}주 @${h.Sell_Limit_Price}`;
- const p = h.Action_Params ? `\n ${h.Action_Params}` : "";
- L.push(` ${i+1}. ${h.Name} → ${r}${p}`);
- });
- }
- if (exitList.length) {
- L.push(" ▶ EXIT_SIGNAL / REVIEW (캡처 → ChatGPT 수량 계산 후 매도)");
- byRank(exitList).forEach((h, i) => {
- const r = h.Action_Reason || `${h.Final_Action}(RW${h.RW_Partial})`;
- const p = h.Action_Params ? ` | ${h.Action_Params}` : "";
- L.push(` ${sellList.length+i+1}. ${h.Name}[${h.Final_Action}] → ${r}${p}`);
- });
- }
- if (buyList.length) {
- L.push(" ▶ BUY (진입 조건 충족)");
- byRank(buyList).forEach((h, i) => {
- const constr = h.Pos_Size_Constraint || "미계산*";
- const rank_ = sellList.length + exitList.length + i + 1;
- L.push(` ${rank_}. ${h.Name}[${h.Final_Action}] → ${h.Action_Reason || ""}`);
- const params_ = h.Action_Params || `목표 ${h.Pos_Size_Qty}주[${constr}]`;
- L.push(` ${params_}`);
- });
- }
- if (watchList.length) {
- L.push(" ▶ WATCH (타이밍 대기)");
- byRank(watchList).forEach((h, i) => {
- const rank_ = sellList.length + exitList.length + buyList.length + i + 1;
- L.push(` ${rank_}. ${h.Name} → ${h.Action_Reason || `SS001:${h.SS001_Grade} 타이밍미충족`}`);
- });
- }
- if (holdList.length) {
- L.push(" ▶ HOLD / BLOCK");
- byRank(holdList).forEach((h, i) => {
- const rank_ = sellList.length + exitList.length + buyList.length + watchList.length + i + 1;
- L.push(` ${rank_}. ${h.Name}[${h.Allowed_Action}] → ${h.Action_Reason || h.Allowed_Action}`);
- });
- }
- if (!sellList.length && !exitList.length && !buyList.length && !watchList.length)
- L.push(" HOLD — 오늘 액션 없음");
-
- // 단일 진실원천: sell_priority는 반드시 runSellPriority() 결과만 사용
- const sellPriorityView_ = sellPriorityViewInput || runSellPriority();
- const _cashRaiseCands_ = Array.isArray(sellPriorityView_.sell_priority_table)
- ? sellPriorityView_.sell_priority_table
- : [];
-
- const _cashBelowTgt_ = isRiskOffB || (() => {
- const cp = parseFloat(macro.immediate_cash_pct ?? macro.cash_pct ?? "");
- const tp = parseFloat(macro.target_cash_pct ?? settings["weekly_target_cash_pct"] ?? "10");
- return Number.isFinite(cp) && Number.isFinite(tp) && cp < tp;
- })();
-
- if (_cashBelowTgt_ && _cashRaiseCands_.length) {
- L.push("─".repeat(44));
- const gapReason = isRiskOffB
- ? `REGIME_TRIM_50 발동(${regimeStr})`
- : `현금 부족 → sell_priority_engine`;
- L.push(`[현금확보 매도우선순위] — ${gapReason}`);
- L.push(" spec: ①하드스탑>②매도신호>③중복ETF>④손실위성>⑥익절>⑨코어주도주(마지막)");
- L.push(" ⚠ 매도수량은 HTS 캡처 제공 후 결정 — 수량 미제공 시 수량 산출 금지(P1규칙)");
- _cashRaiseCands_.slice(0, 8).forEach((c, i) => {
- const pStr = (c.profit_pct !== "" && c.profit_pct !== null)
- ? ` (${Number(c.profit_pct) >= 0 ? "+" : ""}${Number(c.profit_pct).toFixed(1)}%)`
- : "";
- const etfTag = c.is_etf ? "[ETF]" : "";
- const clTag = c.is_core_leader ? "[주도주⛔매도금지]" : "";
- L.push(` ${i+1}. ${c.tier_label} ${c.name}${etfTag}${clTag} W:${c.weight_pct}%${pStr} RW:${c.rw_partial} Score:${c.sell_priority_score}`);
- if (c.trim_style || c.rebound_holdback_score)
- L.push(` └ trim=${c.trim_style || "N/A"} rebound_holdback=${c.rebound_holdback_score ?? 0}${c.rebound_holdback_reason ? ` | ${c.rebound_holdback_reason}` : ""}`);
- if (c.action_params) L.push(` └ ${c.action_params}`);
- if (c.hold_reason) L.push(` └ ⚠ ${c.hold_reason}`);
- });
- }
-
- // 주의 종목 섹션
- if (stage2Pass.length || timeStopNear.length || overweight.length || tp1Near.length) {
- L.push("[주의]");
- stage2Pass.forEach(h => L.push(` ${h.Name} Stage2_Gate=PASS → 2단계 진입 검토 (진입가 ${h.Limit_Price_Est ?? "N/A"})`));
- timeStopNear.forEach(h => L.push(` ${h.Name} Time_Stop ${h.Days_To_Time_Stop}일 남음 (${h.Time_Stop_Date})`));
- overweight.forEach(h => L.push(` ${h.Name} OVERWEIGHT ${h.Weight_Pct}% (상한 7%)`));
- tp1Near.forEach(h => L.push(` ${h.Name} +${h.Profit_Pct}% → TP1(${h.TP1_Price}원) 근접`));
- }
- if (events.upcoming_7d?.length) {
- L.push("[7일 이벤트]");
- events.upcoming_7d.forEach(ev => L.push(` ${ev.Date}(D+${ev.DaysLeft}) ${ev.Event} [${ev.Impact}]`));
- }
-
- // brief_ — holdings row → JSON 요약 (API 소비자용)
- const brief_ = (h) => ({
- ticker: h.Ticker, name: h.Name,
- final_action: h.Final_Action, // canonical output field
- action_reason: h.Action_Reason, // 왜 이 액션인가
- action_params: h.Action_Params, // 실행 파라미터 압축 (C-3)
- final_rank: h.Final_Rank,
- allowed_action: h.Allowed_Action,
- ss001_grade: h.SS001_Grade, ss001_norm_score: h.SS001_Norm_Score,
- rw_partial: h.RW_Partial,
- weight_pct: h.Weight_Pct, profit_pct: h.Profit_Pct,
- stage2_gate: h.Stage2_Gate, band_status: h.Band_Status,
- limit_price_est: h.Limit_Price_Est,
- stop_price_est: h.Stop_Price_Est, stop_price_source: h.Stop_Price_Source,
- pos_size_qty: h.Pos_Size_Qty, pos_size_constraint: h.Pos_Size_Constraint,
- tp1_price: h.TP1_Price, tp1_qty: h.TP1_Qty,
- tp2_price: h.TP2_Price, tp2_qty: h.TP2_Qty,
- entry_mode: h.Entry_Mode, entry_mode_gate: h.Entry_Mode_Gate,
- entry_mode_reason: h.Entry_Mode_Reason,
- timing_score_entry: h.Timing_Score_Entry,
- timing_score_exit: h.Timing_Score_Exit,
- timing_action: h.Timing_Action,
- timing_block_reason: h.Timing_Block_Reason,
- sell_action: h.Sell_Action,
- sell_ratio_pct: h.Sell_Ratio_Pct,
- sell_limit_price: h.Sell_Limit_Price,
- sell_reason: h.Sell_Reason,
- sell_validation: h.Sell_Validation,
- cash_preserve_style: h.Cash_Preserve_Style || "",
- cash_preserve_ratio: h.Cash_Preserve_Ratio || "",
- cash_preserve_reason: h.Cash_Preserve_Reason || "",
- rsi14: h.RSI14, disparity: h.Disparity, ma20_slope: h.MA20_Slope,
- exit_signal_detail: h.Exit_Signal_Detail,
- });
-
- return {
- date: today,
- brief_text: L.join("\n"),
- market: {
- regime: macro.market_regime, mrs_score: macro.mrs_score,
- vix: macro.vix, kospi: macro.kospi, usd_krw: macro.usd_krw,
- sp500_ret5d: macro.sp500_ret5d,
- },
- portfolio_health: {
- heat_pct: macro.total_heat_pct, heat_ok: heatOk,
- heat_tag: heatTag,
- heat_block: heatBlockB, heat_caution: heatCautionB,
- fc_budget_pct: macro.fc_budget_pct, fc_ok: fcOk,
- net_return_feedback: nrf,
- bucket_status: macro.bucket_status,
- regime_buy_blocked: isRiskOffB,
- bayesian_label: macro.bayesian_label,
- bayesian_multiplier: macro.bayesian_multiplier,
- },
- orbit: {
- gap_pct: macro.orbit_gap_pct, state: macro.orbit_state,
- slot_adjustment: orbitAdj, cash_adjustment: macro.orbit_cash_adj,
- },
- // Final_Action canonical 분류 (A-1/B-1)
- actions: {
- sell_ready: sellList.map(brief_),
- exit_signals: exitList.map(brief_),
- buy_signals: buyList.map(brief_),
- watch_signals: watchList.map(brief_),
- hold_signals: holdList.map(brief_),
- },
- alerts: {
- stage2_ready: stage2Pass.map(h=>({ticker:h.Ticker,name:h.Name,profit_pct:h.Profit_Pct,limit_price_est:h.Limit_Price_Est})),
- time_stop_near: timeStopNear.map(h=>({ticker:h.Ticker,name:h.Name,days_left:h.Days_To_Time_Stop,stop_date:h.Time_Stop_Date})),
- overweight: overweight.map(h=>({ticker:h.Ticker,name:h.Name,weight_pct:h.Weight_Pct})),
- tp1_near: tp1Near.map(h=>({ticker:h.Ticker,name:h.Name,profit_pct:h.Profit_Pct,tp1_price:h.TP1_Price,tp2_price:h.TP2_Price})),
- },
- upcoming_events: events.upcoming_7d,
- account_snapshot_freshness: acctFresh,
- data_quality: {
- price_stale: priceStaleList_.map(h=>({ticker:h.Ticker,name:h.Name,price_date:h.Price_Date})),
- quote_only: quoteOnlyList_.map(h=>({ticker:h.Ticker,name:h.Name})),
- flow_stale: flowStaleList_.map(h=>({ticker:h.Ticker,name:h.Name,missing_fields:h.Missing_Fields})),
- },
- // sell_priority_engine 출력 (spec: portfolio_exposure.yaml:sell_priority_engine)
- // 활성화: REGIME_TRIM_50 또는 현금 부족. ETF→손실위성→코어주도주 순서로 정렬.
- cash_raise: _cashBelowTgt_ ? {
- active: true,
- reason: isRiskOffB ? `REGIME_TRIM_50(${regimeStr})` : "cash_below_target",
- prohibition: "매도수량은 HTS 캡처 제공 후 결정. 수량 미제공 시 수량 기재 금지(spec:P1규칙).",
- sell_priority_table: _cashRaiseCands_,
- sector_exposure_summary: sellPriorityView_.sector_exposure ?? sellPriorityView_.sector_exposure_summary ?? {},
- } : { active: false },
- };
-}
-
-// ── E3: 거래 진입 템플릿 생성 ────────────────────────────────────────────────
-// BUY_CANDIDATE/WATCH_CANDIDATE 종목에 대해 performance 탭 입력 행 + 진입 체크리스트 반환.
-// doGet(?view=trade_template&ticker=064350)
-function getTradeTemplate(ticker) {
- if (!ticker) return { error: "ticker 파라미터 필요 (?view=trade_template&ticker=XXXXXX)" };
- const allData = sheetToJson("data_feed");
- const row = allData.find(r => String(r.Ticker) === String(ticker) || r.Name === ticker);
- if (!row) return { error: `ticker ${ticker} not found in data_feed` };
-
- const macro = getMacroJson();
- const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
- const sector = TICKER_SECTOR_MAP[ticker] ?? "N/A";
-
- // 진입 체크리스트 — 각 항목 true/false
- const checklist = {
- data_quality: row.Price_Status === "PRICE_OK",
- no_dart_risk: !row.DART_Risk || row.DART_Risk === "" || row.DART_Risk === "N",
- liquidity_ok: row.Liquidity_Status === "OK",
- timing_ready: ["BUY_STAGE1_READY","BUY_PULLBACK_WAIT","BUY_BREAKOUT_PILOT_ONLY"].includes(row.Timing_Action),
- leader_gate: ["PASS","EXPLORE_CANDIDATE","WATCH_ONLY"].includes(row.Leader_Gate),
- ac_gate: row.AC_Gate === "CLEAR",
- flow_credit_ok: parseFloat(row.Flow_Credit) >= 0.4,
- regime_ok: ["RISK_ON","SECULAR_LEADER_RISK_ON","LEADER_CONCENTRATION"].includes(macro.market_regime),
- heat_ok: Number.isFinite(parseFloat(macro.total_heat_pct)) && parseFloat(macro.total_heat_pct) < 10,
- fc_budget_ok: Number.isFinite(parseFloat(macro.fc_budget_pct)) && parseFloat(macro.fc_budget_pct) < 100,
- nr_feedback_ok: macro.net_return_feedback !== "REDUCED",
- ee_positive: parseFloat(row.EE_Est) > 0,
- ss001_grade_ok: ["A","B"].includes(row.SS001_Grade),
- };
- const passCount = Object.values(checklist).filter(Boolean).length;
- const totalCheck = Object.keys(checklist).length;
- const gateStatus = passCount === totalCheck ? "ALL_PASS"
- : passCount >= totalCheck - 2 ? "MINOR_ISSUES"
- : "BLOCK";
-
- return {
- ticker,
- name: row.Name,
- sector,
- generated_at: today,
- gate_status: gateStatus,
- gate_score: `${passCount}/${totalCheck}`,
- checklist,
- // performance 탭에 바로 붙여넣을 수 있는 행 템플릿
- performance_tab_template: {
- trade_id: `${today.replace(/-/g,"")}${ticker}`,
- ticker,
- sector,
- entry_date: today,
- entry_price: row.Limit_Price_Est ?? "",
- entry_stage: "stage_1",
- quantity: row.Pos_Size_Qty ?? "",
- stop_price_at_entry: row.Stop_Price_Est ?? "",
- target_price_at_entry: row.Target_Price ?? "",
- exit_date: "",
- exit_price: "",
- exit_reason: "",
- pnl_pct: "",
- holding_days: "",
- entry_c1_score: row.C1_Price ?? "",
- entry_c2_score: row.C2_RelStr ?? "",
- entry_c3_score: row.C3_VolSurge ?? "",
- entry_c4_score: row.C4_Flow ?? "",
- entry_c5_score: row.C5_Sector ?? "",
- entry_mode: row.Entry_Mode ?? "",
- entry_gate: row.Entry_Mode_Gate ?? "",
- timing_action: row.Timing_Action ?? "",
- timing_score_entry: row.Timing_Score_Entry ?? "",
- timing_score_exit: row.Timing_Score_Exit ?? "",
- anti_climax_gate: row.AC_Gate ?? "",
- flow_credit: row.Flow_Credit ?? "",
- entry_mrs_score: macro.mrs_score ?? "",
- fc_bucket: "",
- },
- current_state: {
- close: row.Close,
- allowed_action: row.Allowed_Action,
- timing_action: row.Timing_Action,
- timing_score_entry: row.Timing_Score_Entry,
- timing_score_exit: row.Timing_Score_Exit,
- timing_block_reason: row.Timing_Block_Reason,
- sell_action: row.Sell_Action,
- sell_ratio_pct: row.Sell_Ratio_Pct,
- sell_qty: row.Sell_Qty,
- sell_limit_price: row.Sell_Limit_Price,
- sell_price_source: row.Sell_Price_Source,
- sell_reason: row.Sell_Reason,
- sell_validation: row.Sell_Validation,
- ss001_grade: row.SS001_Grade,
- ss001_total: row.SS001_Total,
- flow_credit: row.Flow_Credit,
- rw_partial: row.RW_Partial,
- limit_price_est: row.Limit_Price_Est,
- stop_price_est: row.Stop_Price_Est,
- stop_price_source: row.Stop_Price_Source,
- ee_est: row.EE_Est,
- pos_size_qty: row.Pos_Size_Qty,
- upside_pct: row.Upside_Pct,
- atr20: row.ATR20,
- tp1_price: row.TP1_Price,
- tp1_qty: row.TP1_Qty,
- tp2_price: row.TP2_Price,
- tp2_qty: row.TP2_Qty,
- dart_risk: row.DART_Risk,
- days_to_earnings: row.Days_To_Earnings,
- },
- };
-}
-
-function getSummaryJson() {
- // ChatGPT 포트폴리오 분석에 최적화된 통합 뷰
- const sectors = getSectorFlowJson();
- const port = getPortfolioJson();
- const macro = getMacroJson();
- const events = getEventRiskJson();
-
- // 포트폴리오 전체 수급 요약
- 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;
-
- // SS001 등급 분포 및 Allowed_Action 집계
- 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;
- });
-
- return {
- portfolio_flow_summary: {
- total_holdings: holdings.length,
- data_ok_count: flowOkCount,
- portfolio_frg_5d_total: totalFrg5,
- portfolio_inst_5d_total: totalInst5,
- portfolio_indiv_5d_total: -(totalFrg5 + totalInst5),
- },
- 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,
- bucket_status: macro.bucket_status,
- bucket_detail: macro.bucket_detail,
- },
- event_alerts: events.upcoming_7d,
- holdings_detail: holdings,
- sector_detail: sectors.sectors,
- macro_detail: macro.indicators,
- macro_computed: macro.computed_summary,
- };
-}
diff --git a/src/gas/core/gas_lib.gs b/src/gas/core/gas_lib.gs
index 59814c4..19bb224 100644
--- a/src/gas/core/gas_lib.gs
+++ b/src/gas/core/gas_lib.gs
@@ -1,5 +1,5 @@
// gas_lib.gs - Common utilities & static features
-// Last Updated: 2026-06-14 13:11:22 KST
+// Last Updated: 2026-06-14 17:23:33 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
//
@@ -474,31 +474,6 @@ function appendAlphaHistory_(ss, aewRows, holdings, dfMap, marketRegime) {
});
}
-function getAlphaFeedbackJson_() {
- var defaultPayload = {
- formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
- as_of: '',
- analysis_period: '',
- status: 'DATA_MISSING',
- cases_analyzed: 0,
- grade_count: 0,
- eligible_t20_fail_rate: null,
- eligible_t60_fail_rate: null,
- recommended_filter_adjustments: [],
- grade_summary: []
- };
- try {
- var settings = readSettingsTab_();
- var raw = settings['afl_v1_last_result'];
- if (!raw) return defaultPayload;
- var payload = typeof raw === 'string' ? JSON.parse(raw) : raw;
- return payload && typeof payload === 'object' ? payload : defaultPayload;
- } catch (e) {
- Logger.log('[AFL] getAlphaFeedbackJson_ error: ' + e.message);
- return defaultPayload;
- }
-}
-
// ── settings 탭 읽기 → 사용자 입력 파라미터 (total_asset 등) ────────────────
// settings 탭: row2=헤더(key|value|note), row3+=데이터
// 없으면 빈 객체 반환 (각 호출처에서 null 처리)
@@ -2406,143 +2381,6 @@ function getOrbitGapJson() {
};
}
-// ── [2026-05-21_AFL_V1] ALPHA_FEEDBACK_LOOP_V1 -- monthly grade analysis ────────
-function runAlphaFeedbackLoop_() {
- var ss = getSpreadsheet_();
- var sheet = ss.getSheetByName("alpha_history");
- var today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
- var monthKey = today.substring(0, 7);
- var defaultPayload = {
- formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
- as_of: today,
- analysis_period: monthKey,
- status: 'DATA_MISSING',
- cases_analyzed: 0,
- grade_count: 0,
- eligible_t20_fail_rate: null,
- eligible_t60_fail_rate: null,
- recommended_filter_adjustments: [],
- grade_summary: []
- };
- if (!sheet) {
- writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(defaultPayload));
- Logger.log("[AFL] alpha_history sheet not found");
- return defaultPayload;
- }
- var data = sheet.getDataRange().getValues();
- if (data.length < 2) {
- writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(defaultPayload));
- Logger.log("[AFL] alpha_history has no data");
- return defaultPayload;
- }
-
- var hdrRow = data[0];
- var hdrMap = {};
- hdrRow.forEach(function(h, i) { hdrMap[h] = i; });
-
- var gradeStats = {};
- var analyzedCases = 0;
- for (var i = 1; i < data.length; i++) {
- var row = data[i];
- var grade = String(row[hdrMap['SAQG_Grade_At_Entry']] || '').trim();
- var t20g = String(row[hdrMap['T20_Alpha_Gate']] || '').trim();
- var t60g = String(row[hdrMap['T60_Alpha_Gate']] || '').trim();
- if (!grade) continue;
- if (!gradeStats[grade]) gradeStats[grade] = { t20_total: 0, t20_pass: 0, t60_total: 0, t60_pass: 0 };
- var s = gradeStats[grade];
- var skipVals = { 'NOT_YET': 1, 'EXEMPT': 1, 'DATA_MISSING': 1, '': 1 };
- var hasT20 = t20g && !skipVals[t20g];
- var hasT60 = t60g && !skipVals[t60g];
- if (hasT20) { s.t20_total++; if (t20g === 'T20_ALPHA_PASS') s.t20_pass++; }
- if (hasT60) { s.t60_total++; if (t60g === 'T60_ALPHA_PASS') s.t60_pass++; }
- if (hasT20 || hasT60) analyzedCases++;
- }
-
- var gradeSummary = [];
- Object.keys(gradeStats).sort().forEach(function(grade) {
- var s = gradeStats[grade];
- var t20FailRate = s.t20_total > 0 ? parseFloat((((s.t20_total - s.t20_pass) / s.t20_total) * 100).toFixed(2)) : null;
- var t60FailRate = s.t60_total > 0 ? parseFloat((((s.t60_total - s.t60_pass) / s.t60_total) * 100).toFixed(2)) : null;
- var t20PassRate = s.t20_total > 0 ? parseFloat(((s.t20_pass / s.t20_total) * 100).toFixed(2)) : null;
- var t60PassRate = s.t60_total > 0 ? parseFloat(((s.t60_pass / s.t60_total) * 100).toFixed(2)) : null;
- gradeSummary.push({
- grade: grade,
- t20_total: s.t20_total,
- t20_pass: s.t20_pass,
- t20_pass_rate: t20PassRate,
- t20_fail_rate: t20FailRate,
- t60_total: s.t60_total,
- t60_pass: s.t60_pass,
- t60_pass_rate: t60PassRate,
- t60_fail_rate: t60FailRate,
- status: (s.t20_total >= 10 || s.t60_total >= 10) ? 'ANALYZED' : 'DATA_INSUFFICIENT'
- });
- });
-
- var eligibleRow = gradeStats['ELIGIBLE'] || { t20_total: 0, t20_pass: 0, t60_total: 0, t60_pass: 0 };
- var eligibleT20FailRate = eligibleRow.t20_total > 0
- ? parseFloat((((eligibleRow.t20_total - eligibleRow.t20_pass) / eligibleRow.t20_total) * 100).toFixed(2))
- : null;
- var eligibleT60FailRate = eligibleRow.t60_total > 0
- ? parseFloat((((eligibleRow.t60_total - eligibleRow.t60_pass) / eligibleRow.t60_total) * 100).toFixed(2))
- : null;
- var eligibleT20PassRate = eligibleRow.t20_total > 0
- ? parseFloat(((eligibleRow.t20_pass / eligibleRow.t20_total) * 100).toFixed(2))
- : null;
-
- var recommendations = [];
- if (analyzedCases >= 10) {
- if (eligibleT20FailRate !== null && eligibleT20FailRate > 50) {
- recommendations.push({
- filter_id: 'SAQG_F2_RECOVERY_RATIO',
- current: '1.20',
- recommended: '1.35',
- rationale: 'ELIGIBLE T+20 fail rate > 50%',
- action: 'TIGHTEN'
- });
- recommendations.push({
- filter_id: 'SAQG_F3_EXCESS_DRAWDOWN',
- current: '5%p',
- recommended: '4%p',
- rationale: 'ELIGIBLE T+20 fail rate > 50%',
- action: 'TIGHTEN'
- });
- } else if (eligibleT20PassRate !== null && eligibleT20PassRate > 70 && eligibleRow.t20_total >= 12) {
- recommendations.push({
- filter_id: 'SAQG_F3_EXCESS_DRAWDOWN',
- current: '5%p',
- recommended: '7%p',
- rationale: 'ELIGIBLE T+20 success rate > 70% and cases >= 12',
- action: 'RELAX_REVIEW'
- });
- } else {
- recommendations.push({
- filter_id: 'SAQG_F1_F2_F3',
- current: 'UNCHANGED',
- recommended: 'HOLD',
- rationale: 'No threshold change supported by current sample',
- action: 'HOLD'
- });
- }
- }
-
- var payload = {
- formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
- as_of: today,
- analysis_period: monthKey,
- status: analyzedCases >= 10 ? 'ANALYZED' : 'DATA_INSUFFICIENT',
- cases_analyzed: analyzedCases,
- grade_count: Object.keys(gradeStats).length,
- eligible_t20_fail_rate: eligibleT20FailRate,
- eligible_t60_fail_rate: eligibleT60FailRate,
- recommended_filter_adjustments: analyzedCases >= 10 ? recommendations : [],
- grade_summary: gradeSummary
- };
- writeSettingValue_(ss, 'afl_v1_last_result', JSON.stringify(payload));
- Logger.log('[AFL] done - ' + payload.grade_count + ' grades analyzed, cases=' + analyzedCases);
- return payload;
-}
-
// ── E2: 월말 자산 스냅샷 → monthly_history 기록 ─────────────────────────────
// 트리거: 매달 마지막 영업일 16:30 독립 실행 OR runDataFeed 완료 후 호출.
function runMonthlySnapshot() {
diff --git a/src/gas/engines/gas_apex_alpha_watch.gs b/src/gas/engines/gas_apex_alpha_watch.gs
index ce480e6..542e880 100644
--- a/src/gas/engines/gas_apex_alpha_watch.gs
+++ b/src/gas/engines/gas_apex_alpha_watch.gs
@@ -91,94 +91,6 @@ function calcAntiLateEntryGateV2Impl_(holdings, dfMap) {
return results;
}
-/**
- * PA5: CONSISTENCY_VALIDATOR_V2
- * [P0 GAP 해소 - 데이터 정합성 검증]
- */
-function calcConsistencyValidatorV2Impl_(hApex, asResult, cashFloorInfo, capturedAtIso, now) {
- var checks = [];
- var passed = [];
- var failed = [];
- var gapList = [];
-
- // CV_01: sell_priority 방향 일관성
- var sellCandidates = hApex.sell_candidates_json || [];
- var tierOk = true;
- for (var i = 1; i < sellCandidates.length; i++) {
- if (sellCandidates[i].tier < sellCandidates[i-1].tier) {
- tierOk = false;
- break;
- }
- }
- if (tierOk) passed.push('CV_01'); else failed.push({check_id: 'CV_01', reason: 'tier_reversal'});
-
- // CV_02: 가격 순서 검증
- var prices = hApex.prices_json || [];
- var priceOk = true;
- for (var i = 0; i < prices.length; i++) {
- var p = prices[i];
- if (p.stop_price && p.current_price && p.stop_price >= p.current_price) priceOk = false;
- }
- if (priceOk) passed.push('CV_02'); else failed.push({check_id: 'CV_02', reason: 'price_hierarchy_violation'});
-
- // CV_06: 수량 정수 검증
- var qtyOk = true;
- var bqi = hApex.buy_qty_inputs_json || [];
- for (var i = 0; i < bqi.length; i++) {
- if (bqi[i].final_qty && bqi[i].final_qty % 1 !== 0) qtyOk = false;
- }
- if (qtyOk) passed.push('CV_06'); else failed.push({check_id: 'CV_06', reason: 'float_quantity'});
-
- // CV_08: 현금 계산 경로
- if (hApex.cash_ledger_basis === 'D2_ONLY') passed.push('CV_08');
- else failed.push({check_id: 'CV_08', reason: 'invalid_cash_basis'});
-
- // Score 계산
- var score = Math.floor((passed.length / 12) * 100);
- var status = score >= 90 ? (score === 100 ? 'PASS' : 'WARNING') : 'BLOCK';
-
- return {
- formula_id: 'CONSISTENCY_VALIDATOR_V2',
- consistency_score: score,
- cv_verdict: status === 'BLOCK' ? 'ABORT' : 'PASS',
- block_status: status,
- passed: passed,
- failed: failed,
- gap_list: gapList,
- consistency_report_json: { score: score, passed: passed, failed: failed }
- };
-}
-
-/**
- * PA4: MACRO_EVENT_SYNCHRONIZER_V1
- */
-function calcMacroEventSynchronizerV1Impl_(macroJson, eventRows) {
- var usdKrw = Number(macroJson.usd_krw || 0);
- var foreignSellDays = Number(macroJson.foreign_sell_consecutive_days || 0);
-
- var score = 0;
- if (usdKrw > 1500) score += 20;
- else if (usdKrw > 1480) score += 15;
-
- if (foreignSellDays >= 10) score += 20;
- else if (foreignSellDays >= 5) score += 15;
-
- var regime = 'MACRO_NEUTRAL';
- var heatAdj = 0;
- if (score >= 60) { regime = 'MACRO_CRITICAL'; heatAdj = -3; }
- else if (score >= 40) { regime = 'MACRO_ELEVATED'; heatAdj = -1; }
- else if (score < 20) { regime = 'MACRO_FAVORABLE'; heatAdj = 1; }
-
- return {
- formula_id: 'MACRO_EVENT_SYNCHRONIZER_V1',
- macro_risk_score: score,
- macro_risk_regime: regime,
- effective_heat_gate_adjustment: heatAdj,
- mega_sell_alert: false,
- macro_event_json: { score: score, regime: regime, heat_gate_adj: heatAdj }
- };
-}
-
/**
* PA1: PREDICTIVE_ALPHA_ENGINE_V1
*/
@@ -216,29 +128,6 @@ function calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult,
return results;
}
-/**
- * MACRO_REGIME_ADAPTIVE_GATE_V2
- */
-function calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex) {
- var totalScore = mesResult.macro_risk_score || 0;
- var regime = 'MODERATE_RISK';
- var heatThreshold = 10.0;
- var sizeScale = 1.0;
-
- if (totalScore >= 75) { regime = 'EXTREME_RISK'; heatThreshold = 5.0; sizeScale = 0.25; }
- else if (totalScore >= 50) { regime = 'HIGH_RISK'; heatThreshold = 7.0; sizeScale = 0.50; }
- else if (totalScore < 25) { regime = 'LOW_RISK'; heatThreshold = 12.0; sizeScale = 1.10; }
-
- return {
- formula_id: 'MACRO_REGIME_ADAPTIVE_GATE_V2',
- total_mrag_score: totalScore,
- regime_label: regime,
- effective_heat_gate_threshold: heatThreshold,
- effective_position_size_scale: sizeScale,
- mrag_v2_json: { score: totalScore, regime: regime }
- };
-}
-
/**
* applyAlegGate4And5Impl_
*/
@@ -263,19 +152,6 @@ function applyAlegGate4And5Impl_(alegRows, paeRows, hApex) {
return results;
}
-/**
- * Suite Aggregators
- */
-function applyApexMacroAlphaSuiteImpl_(holdings, dfMap, hApex) {
- // Placeholder for macro alpha suite
- return hApex;
-}
-
-function applyApexMacroEventSuiteImpl_(hApex) {
- // Placeholder for macro event suite
- return hApex;
-}
-
function applyApexPredictiveAlphaSuiteImpl_(holdings, dfMap, hApex) {
var macroJson = hApex.macro_event_json || {};
var mesResult = hApex.macro_event_json || {};