Files
QuantEngineByItz/gas_data_collect.gs
T
kjh2064 af1236202d WBS-7.3: GAS→Python 마이그레이션 5개 항목 완료 (F14, F02-F06)
- F14: late_chase_risk_score 검증
  * GAS가 유일한 생산처 (Python canonical 없음)
  * migration_action: KEEP_IN_GAS로 정정, status: DONE

- F02/F03/F04/F06: priceBasis 로직 포팅
  * formulas/price_basis_v1.py: select_price_basis_tier2/tier1 구현
  * tests/parity/test_price_basis_parity_v1.py: 8 parity 테스트 (모두 PASS)
  * GAS Number.isFinite() 의미론 정확히 재현 (math.isfinite 사용)
  * 모든 테스트 112/112 PASS

남은 작업 (4개):
- F05: decision_logic (action assignment)
- F07: score_logic (threshold addition)
- F10: routing decision
- F15: late_chase_gate

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-22 22:45:00 +09:00

4832 lines
232 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =========================================================================
// GENERATED BUNDLE - DO NOT EDIT THIS FILE MANUALLY
// Generated At: 2026-06-22 02:21:03 KST
// Source Files: src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs, src/gas_adapter_parts/gdc_02_account_satellite.gs
// Source Hash: 9018659b3190a98307df69862d2bbdf877a195bf3d1494a2161cc1869533e82a
// =========================================================================
// --- Source: src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs ---
// gas_data_collect.gs - Data collection & assembly layer
// Fetch infrastructure, data fetchers, buildTickerRow_, runDataFeed
// GAS global scope: functions in gas_data_feed.gs / gas_lib.gs callable directly
function beginFetchSession_(label = "manual") {
const props = PropertiesService.getScriptProperties();
try {
const keys = props.getKeys();
let budgetCleared = 0;
let circuitExpired = 0;
const now = Date.now();
for (const k of keys) {
if (k.startsWith("fetch_budget_")) {
props.deleteProperty(k);
budgetCleared++;
} else if (k.startsWith("fetch_circuit_")) {
// 만료된 circuit breaker 자동 정리: until < now인 경우 제거.
// isFetchCircuitOpen_()도 자가 치유하지만, 세션 시작 시 선제 정리로
// 불필요한 PropertiesService read를 줄이고 상태를 명시적으로 초기화.
try {
const raw = props.getProperty(k);
if (raw) {
const data = JSON.parse(raw);
if (!data?.until || now >= Number(data.until)) {
props.deleteProperty(k);
const failKey = k.replace("fetch_circuit_", "fetch_fail_");
props.deleteProperty(failKey);
circuitExpired++;
}
}
} catch (_) {
props.deleteProperty(k);
circuitExpired++;
}
}
}
if (budgetCleared > 0 || circuitExpired > 0) {
Logger.log("[beginFetchSession_] budget_cleared=" + budgetCleared + " circuit_expired=" + circuitExpired);
}
} catch (e) {
Logger.log("[beginFetchSession_] Error clearing old properties: " + e.message);
}
props.setProperty("fetch_session_id", Utilities.getUuid());
props.setProperty("fetch_session_label", String(label ?? "manual"));
props.setProperty("fetch_session_started_at", new Date().toISOString());
props.setProperty("fetch_session_updated_at", new Date().toISOString());
}
function setFetchSessionLabel_(label = "manual") {
const props = PropertiesService.getScriptProperties();
let sid = props.getProperty("fetch_session_id");
if (!sid) {
beginFetchSession_(label);
return;
}
props.setProperty("fetch_session_label", String(label ?? "manual"));
props.setProperty("fetch_session_updated_at", new Date().toISOString());
}
function clearFetchCache() {
const props = PropertiesService.getScriptProperties();
const keys = props.getKeys();
for (const k of keys) {
if (k.startsWith("fetch_fail_") || k.startsWith("fetch_circuit_") || k.startsWith("fetch_budget_") || k.startsWith("cs_")) {
props.deleteProperty(k);
}
}
// Note: CacheService doesn't have a flushAll, but since we rely heavily on PropertiesService for circuit breakers,
// clearing the circuits will force a fresh fetch attempt and overwrite the cache.
Logger.log("Fetch cache and circuit breakers cleared.");
}
// 일부 배포본에서 gas_lib.gs 로딩이 누락돼도 runDataFeed 초기화를 살리기 위한 안전 경로.
// gas_lib.gs의 동일 함수가 존재하면 그 구현을 우선 사용한다.
const _gasCompatRoot_ = (typeof globalThis !== "undefined") ? globalThis : this;
function _installCompat_(name, fn) {
if (typeof _gasCompatRoot_[name] !== "function") {
_gasCompatRoot_[name] = fn;
_gasCompatRoot_._gasCompatFallbackUsed_ = true;
}
}
const _gasCompatFallbacks_ = {
getSpreadsheet_: function() {
let _ssCacheDataCollect_ = _gasCompatRoot_._ssCacheDataCollect_ || null;
if (_ssCacheDataCollect_) return _ssCacheDataCollect_;
try {
if (typeof SPREADSHEET_ID !== "undefined" && SPREADSHEET_ID) {
_ssCacheDataCollect_ = SpreadsheetApp.openById(SPREADSHEET_ID);
_gasCompatRoot_._ssCacheDataCollect_ = _ssCacheDataCollect_;
return _ssCacheDataCollect_;
}
} catch (e) {
Logger.log(`getSpreadsheet_ fallback openById failed: ${e.message}`);
}
_ssCacheDataCollect_ = SpreadsheetApp.getActiveSpreadsheet();
_gasCompatRoot_._ssCacheDataCollect_ = _ssCacheDataCollect_;
return _ssCacheDataCollect_;
},
readSettingsTab_: function() {
const result = {};
try {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName("settings");
if (!sheet) {
Logger.log("readSettingsTab_: settings 탭 없음");
return result;
}
const data = sheet.getDataRange().getValues();
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;
}
} catch (e) {
Logger.log(`readSettingsTab_ fallback error: ${e.message}`);
}
return result;
},
readPerformanceSheet_: function() {
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;
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(x => x > 0).length;
const losses = recent.filter(x => x < 0).length;
const sum = recent.reduce((a, b) => a + b, 0);
const winRate = (wins / n) * 100;
const avg = sum / n;
const label = avg >= 2 ? "high_confidence" : avg >= 0 ? "medium_confidence" : "low_confidence";
return {
bayesian_multiplier: label === "high_confidence" ? 1.0 : label === "medium_confidence" ? 0.5 : 0.25,
bayesian_label: label,
trades_used: n,
win_rate_30: winRate,
net_expectancy_30: avg,
consecutive_losses: losses,
bayesian_data_source: "actual",
};
} catch (e) {
Logger.log(`readPerformanceSheet_ fallback error: ${e.message}`);
return DEFAULT;
}
},
calcKrxBizDaysDiff_: function(dateStr) {
if (!dateStr) return 999;
const norm = String(dateStr).replace(/\./g, "-");
if (!/^\d{4}-\d{2}-\d{2}$/.test(norm)) return 999;
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;
},
isStalePriceDate_: function(dateStr, bizDaysThreshold = 1) {
const diff = calcKrxBizDaysDiff_(dateStr);
return diff > bizDaysThreshold;
},
calcValSurgeStatus: function(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";
},
calcLiquidityStatus: function(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";
},
calcSpreadStatus: function(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";
}
};
for (const [name, fn] of Object.entries(_gasCompatFallbacks_)) {
_installCompat_(name, fn);
}
function getFetchSessionId_() {
const props = PropertiesService.getScriptProperties();
let sid = props.getProperty("fetch_session_id");
if (!sid) {
sid = Utilities.getUuid();
props.setProperty("fetch_session_id", sid);
props.setProperty("fetch_session_label", "auto");
props.setProperty("fetch_session_started_at", new Date().toISOString());
props.setProperty("fetch_session_updated_at", new Date().toISOString());
}
return sid;
}
function cacheJsonGet_(key) {
const raw = CacheService.getScriptCache().get(key);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (_) {
return null;
}
}
function cacheJsonSet_(key, value, ttlSeconds) {
try {
CacheService.getScriptCache().put(key, JSON.stringify(value), ttlSeconds);
} catch (_) {}
}
function fetchBudgetKey_(source, bucket) {
const safeBucket = String(bucket ?? "global").replace(/[^A-Za-z0-9_.%-]/g, "_");
return `fetch_budget_${getFetchSessionId_()}_${source}_${safeBucket}`;
}
function fetchFailureKey_(source) {
return `fetch_fail_${source}`;
}
function fetchCircuitKey_(source) {
return `fetch_circuit_${source}`;
}
function isFetchCircuitOpen_(source) {
const props = PropertiesService.getScriptProperties();
const raw = props.getProperty(fetchCircuitKey_(source));
if (!raw) return false;
try {
const data = JSON.parse(raw);
if (!data?.until) {
props.deleteProperty(fetchCircuitKey_(source));
return false;
}
if (Date.now() >= Number(data.until)) {
props.deleteProperty(fetchCircuitKey_(source));
props.deleteProperty(fetchFailureKey_(source));
return false;
}
return true;
} catch (_) {
props.deleteProperty(fetchCircuitKey_(source));
return false;
}
}
function consumeFetchBudget_(source, bucket) {
const props = PropertiesService.getScriptProperties();
const budget = FETCH_GOVERNANCE.budget[source] ?? 1;
const key = fetchBudgetKey_(source, bucket);
const used = Number(props.getProperty(key) ?? "0") + 1;
props.setProperty(key, String(used));
return used <= budget;
}
function recordFetchSuccess_(source) {
const props = PropertiesService.getScriptProperties();
props.deleteProperty(fetchFailureKey_(source));
props.deleteProperty(fetchCircuitKey_(source));
}
function recordFetchFailure_(source) {
const props = PropertiesService.getScriptProperties();
const key = fetchFailureKey_(source);
const failures = Number(props.getProperty(key) ?? "0") + 1;
props.setProperty(key, String(failures));
if (failures >= FETCH_GOVERNANCE.failureLimit) {
props.setProperty(fetchCircuitKey_(source), JSON.stringify({
until: Date.now() + FETCH_GOVERNANCE.coolDownMs,
failures,
}));
}
}
const CACHE_VERSION = "v3_";
function getCachedFetchResult_(cacheKey) {
return cacheJsonGet_(CACHE_VERSION + cacheKey);
}
function setCachedFetchResult_(cacheKey, result, ok, ttlOkKey) {
const ttl = ok ? (FETCH_GOVERNANCE.ttl[ttlOkKey] ?? FETCH_GOVERNANCE.ttl.naver_quote_ok) : FETCH_GOVERNANCE.ttl.failure;
cacheJsonSet_(CACHE_VERSION + cacheKey, result, ttl);
}
function annotateFetchValue_(result, source, bucket) {
const annotated = { ...(result || {}) };
const now = new Date();
const fetchedAt = annotated.fetched_at ? new Date(annotated.fetched_at) : now;
annotated.fetched_at = Utilities.formatDate(fetchedAt, "Asia/Seoul", "yyyy-MM-dd'T'HH:mm:ss");
const ageMinutes = Math.max(0, Math.round((now.getTime() - fetchedAt.getTime()) / 60000));
annotated.value_age_minutes = ageMinutes;
let dataStatus = "UNKNOWN";
let stale = false;
const dateCandidate = annotated.priceDate || annotated.date || annotated.updated_at || null;
if (typeof dateCandidate === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dateCandidate)) {
stale = isStalePriceDate_(dateCandidate);
dataStatus = stale ? "STALE" : "FRESH";
annotated.value_date = dateCandidate;
}
if (annotated.rows && Array.isArray(annotated.rows) && annotated.rows.length > 0) {
const firstRow = annotated.rows[0] || {};
const rowDate = firstRow.date || firstRow.Date || firstRow.priceDate || firstRow.updated_at;
if (typeof rowDate === "string" && /^\d{4}[-.]\d{2}[-.]\d{2}$/.test(rowDate)) {
const normalized = rowDate.replace(/\./g, "-");
stale = stale || isStalePriceDate_(normalized);
dataStatus = stale ? "STALE" : dataStatus === "UNKNOWN" ? "FRESH" : dataStatus;
annotated.value_date = annotated.value_date || normalized;
}
}
if (dataStatus === "UNKNOWN") {
dataStatus = ageMinutes <= 180 ? "FRESH" : "STALE";
}
annotated.data_value_status = dataStatus;
annotated.scrape_block_risk = source.startsWith("naver_") || source.startsWith("yahoo_")
? (dataStatus === "STALE" ? "HIGH" : ageMinutes > 720 ? "MEDIUM" : "LOW")
: "LOW";
annotated.used_for = dataStatus === "STALE" ? "REFERENCE_ONLY" : "EXECUTION";
annotated.data_value_reason = dataStatus === "STALE"
? `stale_or_old:${source}/${bucket}`
: `fresh:${source}/${bucket}`;
return annotated;
}
// ── Fetch 공통 래퍼 (P2-C) ─────────────────────────────────────────────────
// cache 확인 → stale 재수집 판단 → circuit 확인 → budget 소비 → fetchFn 실행 → 결과 캐싱.
// source: FETCH_GOVERNANCE 의 source 키 (예: "naver_flow")
// bucket: consumeFetchBudget_ 의 bucket 파라미터 (종목코드 또는 심볼)
// emptyFallback: circuit/budget 차단 시 반환할 기본값 객체 ({ ok:false, ... })
// fetchFn: () → 결과 객체. try/catch 불필요 (래퍼가 처리). ok 필드로 성공/실패 판단.
function withFetchCache_(cacheKey, source, bucket, emptyFallback, fetchFn) {
const cached = getCachedFetchResult_(cacheKey);
if (cached) {
const annotated = annotateFetchValue_(cached, source, bucket);
// Stale-revalidate: 캐시 데이터가 영업일 기준 오래됐으면 캐시 무효화 후 즉시 re-fetch.
// 주가·수급 데이터는 D-1(STALE)이면 당일 데이터로 교체해야 의사결정에 사용 가능.
// 호가(quote)는 30분 TTL이 짧아서 자연 만료되므로 stale revalidate 불필요.
if (annotated.data_value_status === "STALE"
&& source !== "naver_quote"
&& source !== "yahoo_quote") {
try { CacheService.getScriptCache().remove(CACHE_VERSION + cacheKey); } catch (_) {}
Logger.log("[STALE_REVALIDATE] " + source + "/" + bucket + " — 캐시 무효화 후 re-fetch");
// fall through to re-fetch below
} else {
return annotated;
}
}
if (isFetchCircuitOpen_(source)) {
return annotateFetchValue_({ ...emptyFallback, ok: false, error: "SOURCE_CIRCUIT_OPEN", source }, source, bucket);
}
if (!consumeFetchBudget_(source, bucket)) {
return annotateFetchValue_({ ...emptyFallback, ok: false, error: "SOURCE_BUDGET_EXCEEDED", source }, source, bucket);
}
let result;
try {
result = fetchFn();
} catch (e) {
result = { ...emptyFallback, ok: false, error: e.message, source };
}
result = annotateFetchValue_(result, source, bucket);
if (result.ok) {
recordFetchSuccess_(source);
setCachedFetchResult_(cacheKey, result, true, `${source}_ok`);
} else {
recordFetchFailure_(source);
setCachedFetchResult_(cacheKey, result, false, "failure");
}
return result;
}
// ── Naver frgn.naver 파서 ─────────────────────────────────────────────────
function fetchNaverFlow(code) {
const ticker = normalizeTickerCode(code);
const cacheKey = `naver_flow_${ticker}`;
return withFetchCache_(cacheKey, "naver_flow", ticker, { ok: false, rows: [], isFlowStale: false }, () => {
const resp = UrlFetchApp.fetch(`https://finance.naver.com/item/frgn.naver?code=${code}&page=1`, {
headers: { "Accept-Language": "ko-KR,ko;q=0.9" },
muteHttpExceptions: true
});
const html = resp.getContentText("EUC-KR");
const rows = [];
const trPattern = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
let trMatch;
while ((trMatch = trPattern.exec(html)) !== null) {
const tds = [];
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
let td;
while ((td = tdPattern.exec(trMatch[1])) !== null) {
tds.push(td[1].replace(/<[^>]+>/g, "").replace(/&nbsp;/g, "").trim());
}
if (tds.length < 7 || !/^\d{4}\.\d{2}\.\d{2}$/.test(tds[0])) continue;
const n = s => { const v = s.replace(/,/g,"").replace(/[+]/g,"").trim(); return isNaN(+v)||!v ? 0 : +v; };
const inst = n(tds[5]), frgn = n(tds[6]);
rows.push({ date: tds[0], inst, frgn, indiv: -(frgn + inst) });
if (rows.length >= 20) break;
}
const isFlowStale = rows.length > 0 && isStalePriceDate_(rows[0].date.replace(/\./g, "-"));
return { ok: rows.length >= 5, rows, source: "naver_flow", isFlowStale };
});
}
// ── Yahoo Finance 가격 조회 ───────────────────────────────────────────────
function fetchYahooPrice(code) {
// 한국 종목/ETF 코드: 6자리 알파뉴메릭 → .KS suffix. ^ 기호로 시작하는 글로벌 지수는 제외.
const sym0 = /^[A-Z0-9]{6}$/i.test(code) && !code.startsWith("^") ? `${code}.KS` : code;
const sym = sym0.replace(/\^/g, "%5E");
const cacheKey = `yahoo_price_${sym}`;
return withFetchCache_(cacheKey, "yahoo_price", sym0, { ok: false }, () => {
const resp = UrlFetchApp.fetch(`https://query2.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=3mo`, {
muteHttpExceptions: true,
headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
});
if (resp.getResponseCode() !== 200) return { ok: false, error: `HTTP ${resp.getResponseCode()}`, source: "yahoo_price" };
const closes = JSON.parse(resp.getContentText())
?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.filter(c => c != null) ?? [];
if (closes.length < 5) return { ok: false, source: "yahoo_price" };
const last = closes[closes.length-1];
const d5 = closes[Math.max(0, closes.length-6)];
const d10 = closes[Math.max(0, closes.length-11)];
const d20 = closes[Math.max(0, closes.length-21)];
return { ok: true, close: last,
ret5D: ((last/d5 -1)*100).toFixed(2),
ret10D: ((last/d10-1)*100).toFixed(2),
ret20D: ((last/d20-1)*100).toFixed(2),
source: "yahoo_price" };
});
}
function fetchYahooMarketMetrics(code) {
const sym = normalizeYahooSymbol(code);
const cacheKey = `yahoo_quote_${sym}`;
const cached = getCachedFetchResult_(cacheKey);
if (cached) return cached;
if (isFetchCircuitOpen_("yahoo_quote")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "yahoo_quote", quoteStatus: "QUOTE_CIRCUIT_OPEN" };
if (!consumeFetchBudget_("yahoo_quote", sym)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "yahoo_quote", quoteStatus: "QUOTE_BUDGET_EXCEEDED" };
const apiUrl = `https://query1.finance.yahoo.com/v7/finance/quote?symbols=${encodeURIComponent(sym)}`;
let apiError = "";
let apiHttpStatus = null;
function extractQuotedNumber_(text, marker, limitChars) {
const start = text.indexOf(marker);
if (start < 0) return null;
const segment = text.slice(start + marker.length, start + marker.length + limitChars);
const match = segment.match(/([0-9,]+(?:\.[0-9]+)?)\s*x/i);
return match ? parseKrNum_(match[1]) : null;
}
try {
const resp = UrlFetchApp.fetch(apiUrl, {
muteHttpExceptions: true,
headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
});
apiHttpStatus = resp.getResponseCode();
let data = null;
if (apiHttpStatus === 200) {
try {
data = JSON.parse(resp.getContentText());
} catch (e) {
apiError = `JSON_${e.message}`;
}
} else {
apiError = `HTTP ${apiHttpStatus}`;
}
const item = data?.quoteResponse?.result?.[0];
const marketPrice = Number(item?.regularMarketPrice) || null;
// Yahoo v7 quote API에서 추가 기본 지표 추출 (이미 수신된 응답 재활용)
const yahooBeta = Number.isFinite(Number(item?.beta)) ? Number(item?.beta) : null;
const yahooH52 = Number.isFinite(Number(item?.fiftyTwoWeekHigh)) ? Number(item?.fiftyTwoWeekHigh) : null;
const yahooL52 = Number.isFinite(Number(item?.fiftyTwoWeekLow)) ? Number(item?.fiftyTwoWeekLow) : null;
const yahooDiv = Number.isFinite(Number(item?.trailingAnnualDividendYield)) ? Number(item?.trailingAnnualDividendYield) * 100 : null;
let source = "yahoo_quote_api";
let quoteStatus = "QUOTE_API_NO_MATCH";
let resolvedBid = Number.isFinite(Number(item?.bid)) ? Number(item?.bid) : null;
let resolvedAsk = Number.isFinite(Number(item?.ask)) ? Number(item?.ask) : null;
if (!(Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0)) {
const htmlUrl = `https://finance.yahoo.com/quote/${encodeURIComponent(sym)}?webview=1`;
const htmlResp = UrlFetchApp.fetch(htmlUrl, {
muteHttpExceptions: true,
headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
});
if (htmlResp.getResponseCode() === 200) {
const text = htmlResp.getContentText();
const bidFromTitle = extractQuotedNumber_(text, 'title="Bid"', 160);
const askFromTitle = extractQuotedNumber_(text, 'title="Ask"', 160);
if (Number.isFinite(bidFromTitle) && Number.isFinite(askFromTitle) && bidFromTitle > 0 && askFromTitle > 0) {
resolvedBid = bidFromTitle;
resolvedAsk = askFromTitle;
source = "yahoo_quote_html";
quoteStatus = "QUOTE_HTML_FALLBACK";
} else {
const rawBid = text.match(/"bid"[^0-9]*([0-9.]+)/i);
const rawAsk = text.match(/"ask"[^0-9]*([0-9.]+)/i);
const candidateBid = rawBid ? parseKrNum_(rawBid[1]) : null;
const candidateAsk = rawAsk ? parseKrNum_(rawAsk[1]) : null;
if (Number.isFinite(candidateBid) && Number.isFinite(candidateAsk) && candidateBid > 0 && candidateAsk > 0) {
resolvedBid = candidateBid;
resolvedAsk = candidateAsk;
source = "yahoo_quote_html";
quoteStatus = "QUOTE_HTML_FALLBACK";
}
}
if (!(Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0)) {
quoteStatus = apiError ? "QUOTE_BLOCKED" : "QUOTE_HTML_NO_MATCH";
}
} else {
quoteStatus = apiError ? "QUOTE_BLOCKED" : "QUOTE_HTML_BLOCKED";
}
}
const spreadPct = Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0
? ((resolvedAsk - resolvedBid) / ((resolvedAsk + resolvedBid) / 2)) * 100
: null;
const ok = Number.isFinite(resolvedBid) && Number.isFinite(resolvedAsk) && resolvedBid > 0 && resolvedAsk > 0;
const result = {
ok,
source,
quoteStatus,
bid: resolvedBid,
ask: resolvedAsk,
spreadPct,
marketPrice,
beta: yahooBeta,
high52W: yahooH52,
low52W: yahooL52,
divYield: yahooDiv,
httpStatus: apiHttpStatus,
error: apiError || ""
};
if (ok) {
recordFetchSuccess_("yahoo_quote");
setCachedFetchResult_(cacheKey, result, true, "yahoo_quote_ok");
} else {
recordFetchFailure_("yahoo_quote");
setCachedFetchResult_(cacheKey, result, false, "failure");
}
return result;
} catch (e) {
const result = { ok: false, error: e.message, source: "yahoo_quote", quoteStatus: "QUOTE_ERROR" };
recordFetchFailure_("yahoo_quote");
setCachedFetchResult_(cacheKey, result, false, "failure");
return result;
}
}
function fetchNaverMarketMetrics(code) {
const ticker = normalizeTickerCode(code);
const cacheKey = `naver_quote_${ticker}`;
const cached = getCachedFetchResult_(cacheKey);
if (cached) return cached;
if (isFetchCircuitOpen_("naver_quote")) return { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_CIRCUIT_OPEN", httpStatus: null };
if (!consumeFetchBudget_("naver_quote", ticker)) return { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_BUDGET_EXCEEDED", httpStatus: null };
const url = `https://finance.naver.com/item/main.naver?code=${encodeURIComponent(code)}`;
try {
const resp = UrlFetchApp.fetch(url, {
muteHttpExceptions: true,
headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)", "Referer": "https://finance.naver.com/" }
});
const httpStatus = resp.getResponseCode();
if (httpStatus !== 200) {
const result = { ok: false, source: "naver_main", quoteStatus: `NAVER_QUOTE_HTTP_${httpStatus}`, httpStatus };
recordFetchFailure_("naver_quote");
setCachedFetchResult_(cacheKey, result, false, "failure");
return result;
}
const html = resp.getContentText("EUC-KR");
const currentMatch = html.match(/오늘의시세\s+([0-9,]+)\s+포인트/i) || html.match(/현재가\s+([0-9,]+)/i);
const currentPrice = currentMatch ? parseKrNum_(currentMatch[1]) : null;
const perMatch = html.match(/<em id="_per">([\d,.]+)<\/em>/);
const pbrMatch = html.match(/<em id="_pbr">([\d,.]+)<\/em>/);
const epsMatch = html.match(/<em id="_eps">([\d,.-]+)<\/em>/);
const per = perMatch ? parseKrNum_(perMatch[1]) : null;
const pbr = pbrMatch ? parseKrNum_(pbrMatch[1]) : null;
const eps = epsMatch ? parseKrNum_(epsMatch[1]) : null;
// 배당수익률 — Naver main 페이지 _dvr ID
const dvrMatch = html.match(/<em id="_dvr">([\d,.]+)<\/em>/);
const dvr = dvrMatch ? parseKrNum_(dvrMatch[1]) : null;
// 52주 최고/최저 — Naver main 페이지 여러 패턴 시도
const parseNum_ = s => { const v = parseFloat(String(s ?? "").replace(/,/g, "")); return Number.isFinite(v) && v > 0 ? v : null; };
const h52m = html.match(/52[주週]최고[^<]*<[^>]*>\s*<[^>]*>\s*([\d,]+)/) ||
html.match(/52주\s*최고가?[\s\S]{0,100}?<em[^>]*>([\d,]+)<\/em>/) ||
html.match(/high52[^>]*>\s*([\d,]+)/) ||
html.match(/<em id="_high52">([\d,]+)<\/em>/);
const l52m = html.match(/52[주週]최저[^<]*<[^>]*>\s*<[^>]*>\s*([\d,]+)/) ||
html.match(/52주\s*최저가?[\s\S]{0,100}?<em[^>]*>([\d,]+)<\/em>/) ||
html.match(/low52[^>]*>\s*([\d,]+)/) ||
html.match(/<em id="_low52">([\d,]+)<\/em>/);
const naverHigh52W = h52m ? parseNum_(h52m[1]) : null;
const naverLow52W = l52m ? parseNum_(l52m[1]) : null;
const askPrices = [];
const bidPrices = [];
const askRowPattern = /<tr class="f_down(?:\s+strong)?">[\s\S]*?<td>\s*([0-9,]+)\s*<\/td>\s*<td>\s*([0-9,]+)\s*<\/td>/g;
const bidRowPattern = /<tr class="f_up(?:\s+strong)?">[\s\S]*?<td>\s*<\/td>\s*<td>\s*([0-9,]+)\s*<\/td>\s*<td class="td_r">\s*([0-9,]+)\s*<\/td>/g;
let m;
while ((m = askRowPattern.exec(html)) !== null) {
const ask = parseKrNum_(m[2]);
if (Number.isFinite(ask) && ask > 0) askPrices.push(ask);
}
while ((m = bidRowPattern.exec(html)) !== null) {
const bid = parseKrNum_(m[1]);
if (Number.isFinite(bid) && bid > 0) bidPrices.push(bid);
}
const bid = bidPrices.length ? Math.max(...bidPrices) : null;
const ask = askPrices.length ? Math.min(...askPrices) : null;
const spreadPct = Number.isFinite(bid) && Number.isFinite(ask) && bid > 0 && ask > 0
? ((ask - bid) / ((ask + bid) / 2)) * 100
: null;
const ok = Number.isFinite(bid) && Number.isFinite(ask) && bid > 0 && ask > 0;
const result = {
ok,
source: "naver_main",
quoteStatus: ok ? "NAVER_QUOTE_OK" : "NAVER_QUOTE_NO_MATCH",
bid,
ask,
spreadPct,
marketPrice: Number.isFinite(currentPrice) ? currentPrice : null,
per,
pbr,
eps,
dvr,
high52W: naverHigh52W,
low52W: naverLow52W,
httpStatus
};
if (ok) {
recordFetchSuccess_("naver_quote");
setCachedFetchResult_(cacheKey, result, true, "naver_quote_ok");
} else {
recordFetchFailure_("naver_quote");
setCachedFetchResult_(cacheKey, result, false, "failure");
}
return result;
} catch (e) {
const result = { ok: false, source: "naver_main", quoteStatus: "NAVER_QUOTE_ERROR", error: e.message };
recordFetchFailure_("naver_quote");
setCachedFetchResult_(cacheKey, result, false, "failure");
return result;
}
}
// Backward-compatible thin wrapper.
// Older callers still expect fetchNaverQuoteMetrics().
function fetchNaverQuoteMetrics(code) {
return fetchNaverMarketMetrics(code);
}
function fetchNaverOhlcMetrics(code) {
const ticker = normalizeTickerCode(code);
const cacheKey = `naver_ohlc_${ticker}`;
const cached = getCachedFetchResult_(cacheKey);
if (cached) return cached;
if (isFetchCircuitOpen_("naver_ohlc")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "naver_ohlc" };
if (!consumeFetchBudget_("naver_ohlc", ticker)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "naver_ohlc" };
const rows = [];
try {
for (let page = 1; page <= 7 && rows.length < 65; page++) {
const url = `https://finance.naver.com/item/sise_day.naver?code=${encodeURIComponent(ticker)}&page=${page}`;
const resp = UrlFetchApp.fetch(url, {
muteHttpExceptions: true,
headers: {
"User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)",
"Referer": `https://finance.naver.com/item/main.naver?code=${encodeURIComponent(ticker)}`
}
});
if (resp.getResponseCode() !== 200) continue;
const html = resp.getContentText("EUC-KR");
const trPattern = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
let trMatch;
while ((trMatch = trPattern.exec(html)) !== null) {
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
const tds = [];
let td;
while ((td = tdPattern.exec(trMatch[1])) !== null) {
tds.push(td[1].replace(/<[^>]+>/g, "").replace(/&nbsp;/g, "").replace(/\s+/g, " ").trim());
}
if (tds.length < 7) continue;
if (!/^\d{4}\.\d{2}\.\d{2}$/.test(tds[0])) continue;
const n = (s) => {
const v = String(s ?? "").replace(/,/g, "").replace(/[+]/g, "").trim();
return v && !isNaN(+v) ? +v : null;
};
const close = n(tds[1]);
const open = n(tds[3]);
const high = n(tds[4]);
const low = n(tds[5]);
const volume = n(tds[6]);
if ([close, open, high, low, volume].some(v => v == null)) continue;
rows.push({
date: tds[0],
open,
high,
low,
close,
volume
});
if (rows.length >= 65) break;
}
}
if (rows.length < 21) {
const result = { ok: false, error: `NAVER_OHLC_ROWS_${rows.length}`, source: "naver_ohlc" };
recordFetchFailure_("naver_ohlc");
setCachedFetchResult_(cacheKey, result, false, "failure");
return result;
}
const latest = rows[0];
const derived = calcDerivedPriceMetrics(rows, true);
const atr20 = calcAtr20(rows.slice().reverse());
const avg5 = avgTradingValueM(rows.slice(1).reverse(), 5);
const avg20 = avgTradingValueM(rows.slice(1).reverse(), 20);
const currentValue = tradingValueM(latest);
const quote = fetchNaverMarketMetrics(ticker);
const valSurge = Number.isFinite(currentValue) && Number.isFinite(avg5) && avg5 !== 0
? ((currentValue / avg5) - 1) * 100
: null;
const isPriceStale = isStalePriceDate_(latest.date);
const result = {
ok: true,
source: "Naver Finance sise_day.naver",
rows: rows.slice().reverse(),
priceDate: latest.date,
isPriceStale,
close: latest.close,
open: derived.open,
high: derived.high,
low: derived.low,
volume: derived.volume,
prevClose: derived.prevClose,
avgVolume5D: derived.avgVolume5D,
ma20: derived.ma20,
ma60: derived.ma60,
ret5D: derived.ret5D,
ret10D: derived.ret10D,
ret20D: derived.ret20D,
ret60D: derived.ret60D,
atr20,
atr20Pct: Number.isFinite(atr20) && latest.close ? (atr20 / latest.close) * 100 : null,
valSurge,
avgTradingValue5D: avg5,
avgTradingValue20D: avg20,
bid: Number.isFinite(quote.bid) ? quote.bid : null,
ask: Number.isFinite(quote.ask) ? quote.ask : null,
spreadPct: Number.isFinite(quote.spreadPct) ? quote.spreadPct : null,
marketPrice: Number.isFinite(quote.marketPrice) ? quote.marketPrice : latest.close,
quoteSource: quote.source ?? "naver_main",
quoteStatus: quote.quoteStatus ?? "NAVER_QUOTE_NO_MATCH",
quoteHttpStatus: quote.httpStatus ?? null
};
recordFetchSuccess_("naver_ohlc");
setCachedFetchResult_(cacheKey, result, true, "naver_ohlc_ok");
return result;
} catch (e) {
const result = { ok: false, error: e.message, source: "naver_ohlc" };
recordFetchFailure_("naver_ohlc");
setCachedFetchResult_(cacheKey, result, false, "failure");
return result;
}
}
// ── 에러 처리 레이어 ─────────────────────────────────────────────────────────
// severity: "CRITICAL" | "WARN" | "INFO"
// CRITICAL: 시트 쓰기 실패, pre-read 실패 → 전체 실행 영향
// WARN: 개별 종목 fetch 실패 → 해당 종목만 영향
// INFO: 캐시 관련 오류 → 무시 가능
function handleFetchError_(context, e, severity) {
Logger.log(`[${severity}] ${context}: ${e}`);
}
function normalizeYahooSymbol(code) {
let sym = /^[A-Z0-9]{6}$/i.test(code) && !code.startsWith("^") ? `${code}.KS` : code;
return sym.replace(/\^/g, "%5E");
}
function normalizeTickerCode(code) {
const raw = String(code ?? "").trim();
if (!raw) return "";
if (/^[0-9]+$/.test(raw)) return raw.padStart(6, "0");
if (/^[0-9A-Z]+$/i.test(raw) && raw.length < 6) return raw.padStart(6, "0");
return raw;
}
function fetchYahooOhlcMetrics(code) {
const sym = normalizeYahooSymbol(code);
const cacheKey = `yahoo_chart_${sym}`;
const cached = getCachedFetchResult_(cacheKey);
if (cached) return cached;
if (isFetchCircuitOpen_("yahoo_chart")) return { ok: false, error: "SOURCE_CIRCUIT_OPEN", source: "yahoo_chart" };
if (!consumeFetchBudget_("yahoo_chart", sym)) return { ok: false, error: "SOURCE_BUDGET_EXCEEDED", source: "yahoo_chart" };
const url = `https://query2.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=6mo`;
try {
const resp = UrlFetchApp.fetch(url, {
muteHttpExceptions: true,
headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" }
});
if (resp.getResponseCode() !== 200) {
const result = { ok: false, error: `HTTP ${resp.getResponseCode()}`, source: "yahoo_chart" };
recordFetchFailure_("yahoo_chart");
setCachedFetchResult_(cacheKey, result, false, "failure");
return result;
}
const data = JSON.parse(resp.getContentText());
const chartResult = data?.chart?.result?.[0];
const ts = chartResult?.timestamp ?? [];
const q = chartResult?.indicators?.quote?.[0] ?? {};
const rows = [];
for (let i = 0; i < ts.length; i++) {
const open = q.open?.[i];
const high = q.high?.[i];
const low = q.low?.[i];
const close = q.close?.[i];
const volume = q.volume?.[i];
if ([open, high, low, close, volume].some(v => v == null || isNaN(+v))) continue;
const d = new Date(ts[i] * 1000);
rows.push({
date: Utilities.formatDate(d, "Asia/Seoul", "yyyy-MM-dd"),
open: +open,
high: +high,
low: +low,
close: +close,
volume: +volume
});
}
if (rows.length < 21) {
const result = { ok: false, error: `OHLC_ROWS_${rows.length}`, source: "yahoo_chart" };
recordFetchFailure_("yahoo_chart");
setCachedFetchResult_(cacheKey, result, false, "failure");
return result;
}
const latest = rows[rows.length - 1];
const derived = calcDerivedPriceMetrics(rows, false);
const atr20 = calcAtr20(rows);
const avg5 = avgTradingValueM(rows.slice(0, -1), 5);
const avg20 = avgTradingValueM(rows.slice(0, -1), 20);
const currentValue = tradingValueM(latest);
let quote = fetchNaverMarketMetrics(code);
if (!quote.ok) quote = fetchYahooMarketMetrics(code);
const valSurge = Number.isFinite(currentValue) && Number.isFinite(avg5) && avg5 !== 0
? ((currentValue / avg5) - 1) * 100
: null;
const result = {
ok: true,
source: "Yahoo Finance chart",
rows,
priceDate: latest.date,
isPriceStale: isStalePriceDate_(latest.date),
close: latest.close,
open: derived.open,
high: derived.high,
low: derived.low,
volume: derived.volume,
prevClose: derived.prevClose,
avgVolume5D: derived.avgVolume5D,
ma20: derived.ma20,
ma60: derived.ma60,
ret5D: derived.ret5D,
ret10D: derived.ret10D,
ret20D: derived.ret20D,
ret60D: derived.ret60D,
atr20,
atr20Pct: Number.isFinite(atr20) && latest.close ? (atr20 / latest.close) * 100 : null,
valSurge,
avgTradingValue5D: avg5,
avgTradingValue20D: avg20,
bid: Number.isFinite(quote.bid) ? quote.bid : null,
ask: Number.isFinite(quote.ask) ? quote.ask : null,
spreadPct: Number.isFinite(quote.spreadPct) ? quote.spreadPct : null,
marketPrice: Number.isFinite(quote.marketPrice) ? quote.marketPrice : null,
quoteSource: quote.source ?? "QUOTE_NO_MATCH",
quoteStatus: quote.quoteStatus ?? "QUOTE_NO_MATCH",
quoteHttpStatus: quote.httpStatus ?? null
};
recordFetchSuccess_("yahoo_chart");
setCachedFetchResult_(cacheKey, result, true, "yahoo_chart_ok");
return result;
} catch (e) {
const result = { ok: false, error: e.message, source: "yahoo_chart" };
recordFetchFailure_("yahoo_chart");
setCachedFetchResult_(cacheKey, result, false, "failure");
return result;
}
}
function fetchNaverDisclosureNotices(code) {
const ticker = normalizeTickerCode(code);
const cacheKey = `naver_notice_${ticker}`;
const cached = getCachedFetchResult_(cacheKey);
if (cached) return cached;
if (isFetchCircuitOpen_("naver_notice")) return { status: "NAVER_NOTICE_CIRCUIT_OPEN", source: "Naver Finance news_notice.naver", list: [] };
if (!consumeFetchBudget_("naver_notice", ticker)) return { status: "NAVER_NOTICE_BUDGET_EXCEEDED", source: "Naver Finance news_notice.naver", list: [] };
const url = `https://finance.naver.com/item/news_notice.naver?code=${code}&page=1`;
try {
const resp = UrlFetchApp.fetch(url, {
headers: {
Referer: `https://finance.naver.com/item/main.naver?code=${code}`,
Accept: "text/html,application/xhtml+xml"
},
muteHttpExceptions: true
});
if (resp.getResponseCode() !== 200) {
const result = { status: `NAVER_NOTICE_HTTP_${resp.getResponseCode()}`, source: "Naver Finance news_notice.naver", list: [] };
recordFetchFailure_("naver_notice");
setCachedFetchResult_(cacheKey, result, false, "failure");
return result;
}
const html = resp.getContentText("EUC-KR");
const rows = [];
const trMatches = html.match(/<tr[\s\S]*?<\/tr>/gi) || [];
for (const tr of trMatches) {
const text = tr
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
const m = text.match(/^(.+?)\s+(KOSCOM|연합뉴스|이데일리|머니투데이|한국경제|매일경제|뉴스핌|아시아경제|서울경제|파이낸셜뉴스)\s+(\d{4}\.\d{2}\.\d{2})$/);
if (!m) continue;
rows.push({
report_nm: m[1].trim(),
source: m[2],
rcept_dt: m[3].replace(/\./g, "")
});
}
const result = {
status: rows.length ? "NAVER_NOTICE_OK" : "NAVER_NOTICE_EMPTY",
source: "Naver Finance news_notice.naver",
list: rows
};
if (rows.length) {
recordFetchSuccess_("naver_notice");
setCachedFetchResult_(cacheKey, result, true, "naver_notice_ok");
} else {
recordFetchFailure_("naver_notice");
setCachedFetchResult_(cacheKey, result, false, "failure");
}
return result;
} catch (e) {
const result = { status: `NAVER_NOTICE_ERROR:${e.message}`, source: "Naver Finance news_notice.naver", list: [] };
recordFetchFailure_("naver_notice");
setCachedFetchResult_(cacheKey, result, false, "failure");
return result;
}
}
function summarizeDisclosureNotices(disclosureResult) {
const list = disclosureResult.list || [];
const names = list.map(x => String(x.report_nm ?? ""));
const catalyst = names.filter(nm => DART_CATALYST_KEYWORDS.some(k => nm.includes(k))).slice(0, 3);
const risk = names.filter(nm => DART_RISK_KEYWORDS.some(k => nm.includes(k))).slice(0, 3);
const recentDate = list[0]?.rcept_dt ? `${list[0].rcept_dt.slice(0,4)}-${list[0].rcept_dt.slice(4,6)}-${list[0].rcept_dt.slice(6,8)}` : "";
return {
status: disclosureResult.status,
source: disclosureResult.source,
recentDate,
catalyst: catalyst.join(" | "),
risk: risk.join(" | "),
count: list.length
};
}
function mapLatestPerformanceByTicker_(performanceRows) {
const map = {};
(performanceRows || []).forEach(function(row) {
const ticker = normalizeTickerCode(row.ticker || row.Ticker || "");
if (!ticker) return;
const exitDate = parseIsoDateYmd_(row.exit_date || row.Exit_Date || row.exitDate);
const exitMs = exitDate ? Date.parse(exitDate + "T00:00:00+09:00") : 0;
const current = map[ticker];
if (!current || exitMs >= current.exitMs) {
map[ticker] = { row: row, exitMs: exitMs };
}
});
return map;
}
function buildBackdataFeatureBankRowsV1_(nowIso, holdings, dfMap, hAlpha, hApex) {
const perfMap = mapLatestPerformanceByTicker_(sheetToJson("performance"));
const alphaMap = {};
((hAlpha || {}).per_holding || []).forEach(function(row) {
const ticker = normalizeTickerCode(row.ticker || row.Ticker || "");
if (ticker) alphaMap[ticker] = row;
});
const followMap = {};
((hApex || {}).follow_through_json || []).forEach(function(row) {
const ticker = normalizeTickerCode(row.ticker || row.Ticker || "");
if (ticker) followMap[ticker] = row;
});
const profitMap = {};
((hApex || {}).profit_preservation_json || []).forEach(function(row) {
const ticker = normalizeTickerCode(row.ticker || row.Ticker || "");
if (ticker) profitMap[ticker] = row;
});
const distributionMap = {};
((hApex || {}).distribution_risk_json || []).forEach(function(row) {
const ticker = normalizeTickerCode(row.ticker || row.Ticker || "");
if (ticker) distributionMap[ticker] = row;
});
const rows = [];
(holdings || []).forEach(function(h) {
const ticker = normalizeTickerCode(h.ticker || h.Ticker || "");
if (!ticker) return;
const df = dfMap[ticker] || {};
const perf = perfMap[ticker] ? perfMap[ticker].row : {};
const alpha = alphaMap[ticker] || {};
const follow = followMap[ticker] || {};
const profit = profitMap[ticker] || {};
const dist = distributionMap[ticker] || {};
const entryDate = parseIsoDateYmd_(h.entry_date || h.entryDate || h.entry_date_iso);
const recordDate = parseIsoDateYmd_(perf.exit_date || perf.exitDate || h.last_updated || nowIso);
const holdingDays = perf.holding_days != null && perf.holding_days !== ""
? parseInt(perf.holding_days, 10)
: (entryDate ? daysBetweenIso_(entryDate, nowIso) : null);
const sourceOrigin = "GAS_AUTO";
const entryPrice = Number.isFinite(h.avgCost) ? h.avgCost
: Number.isFinite(h.average_cost) ? h.average_cost
: Number.isFinite(df.limitPriceEst) ? df.limitPriceEst : null;
const closeAtEntry = Number.isFinite(df.close) ? df.close : null;
const maePct = perf.max_adverse_excursion_pct != null ? parseFloat(perf.max_adverse_excursion_pct)
: (perf.mae_pct != null ? parseFloat(perf.mae_pct) : null);
const mfePct = perf.max_favorable_excursion_pct != null ? parseFloat(perf.max_favorable_excursion_pct)
: (perf.mfe_pct != null ? parseFloat(perf.mfe_pct) : null);
rows.push([
recordDate,
`BK-${ticker}-${String(recordDate || nowIso).replace(/-/g, "")}`,
entryDate || parseIsoDateYmd_(df.priceDate || df.updatedAt || nowIso),
ticker,
h.name || df.name || "",
h.account || h.account_type || "",
h.entry_stage || h.entryStage || df.entryModeGate || "",
sourceOrigin,
entryPrice,
closeAtEntry,
Number.isFinite(df.ma20) ? df.ma20 : null,
Number.isFinite(df.ma60) ? df.ma60 : null,
Number.isFinite(df.atr20) ? df.atr20 : null,
Number.isFinite(df.volumeRatio) ? df.volumeRatio : (Number.isFinite(df.avgVolume5d) && Number.isFinite(df.volume) && df.avgVolume5d > 0 ? round2_(df.volume / df.avgVolume5d) : null),
Number.isFinite(df.flowCredit) ? round2_(df.flowCredit) : (Number.isFinite(alpha.flow_credit) ? round2_(alpha.flow_credit) : null),
Number.isFinite(df.rsi14) ? round2_(df.rsi14) : null,
Number.isFinite(alpha["late_chase_risk_score"]) ? alpha["late_chase_risk_score"] : null,
Number.isFinite(follow["follow_through_score"]) ? follow["follow_through_score"] : null,
Number.isFinite(df.breakoutScore) ? round2_(df.breakoutScore) : (Number.isFinite(df["breakout_score"]) ? round2_(df["breakout_score"]) : null),
Number.isFinite(profit["rebound_preservation_score"]) ? profit["rebound_preservation_score"] : null,
follow.follow_through_state || alpha.buy_permission_state || df.timingAction || df.Timing_Action || "",
perf.exit_reason || perf.exitReason || df.sellReason || df.Sell_Reason || "",
perf.pnl_pct != null ? parseFloat(perf.pnl_pct) : (Number.isFinite(h.return_pct) ? h.return_pct : (Number.isFinite(df.profitPct) ? df.profitPct : null)),
holdingDays,
maePct,
mfePct
]);
});
rows.sort(function(a, b) {
const ao = String(a[7] || "");
const bo = String(b[7] || "");
if (ao !== bo) return ao < bo ? -1 : 1;
const ar = String(a[0] || "");
const br = String(b[0] || "");
if (ar !== br) return ar < br ? 1 : -1;
return String(a[1] || "").localeCompare(String(b[1] || ""));
});
return rows;
}
function syncBackdataFeatureBank_(now, holdings, dfMap, hAlpha, hApex) {
const rows = buildBackdataFeatureBankRowsV1_(formatIso_(now), holdings, dfMap, hAlpha, hApex);
const headers = [
"Record_Date","Trade_ID","Signal_Date","Ticker","Name","Account","Entry_Stage","Source_Origin",
"Entry_Price","Close_At_Entry","MA20_At_Entry","MA60_At_Entry","ATR20_At_Entry",
"Volume_Ratio_5D","Flow_Credit","RSI14_At_Entry","Late_Chase_Risk_Score","Follow_Through_Score",
"Breakout_Score","Rebound_Preservation_Score","Setup_Decision","Exit_Reason","PnL_Pct",
"Holding_Days","MAE_Pct","MFE_Pct"
];
const totalRows = upsertToSheetByKey("backdata_feature_bank", headers, rows, "Trade_ID");
Logger.log(`backdata_feature_bank 완료: 실행반영=${rows.length}행, 누적총계=${totalRows}행`);
return rows;
}
// Naver 컨센서스 페이지에서 EPS 추정치 변화 방향(UP/FLAT/DOWN) 자동 판별
function fetchNaverConsensusData(code) {
const ticker = normalizeTickerCode(code);
const cacheKey = `naver_consensus_${ticker}`;
return withFetchCache_(cacheKey, "naver_consensus", ticker, { ok: false, epsRevisionStatus: "DATA_MISSING" }, () => {
const resp = UrlFetchApp.fetch(
`https://finance.naver.com/item/coinfo.naver?code=${encodeURIComponent(code)}&target=estimate`,
{ muteHttpExceptions: true, followRedirects: true,
headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)", "Referer": "https://finance.naver.com/" } }
);
if (resp.getResponseCode() !== 200) return { ok: false, epsRevisionStatus: "DATA_MISSING" };
const html = resp.getContentText("EUC-KR");
return { ok: true, epsRevisionStatus: parseNaverEpsRevision_(html), targetPrice: parseNaverTargetPrice_(html) };
});
}
// Naver 컨센서스 HTML에서 EPS 개정 방향 파싱
// 전략1: EPS 행(현재) vs 1개월 전 행 수치 비교 (1% 이상 변화 기준)
// 전략2: 3개월 전 vs 현재 비교 (전략1 실패 시)
// 전략3: 방향 아이콘 클래스 감지 (ico_up / ico_dn 계열)
function parseNaverEpsRevision_(html) {
// 전략1: 현재 vs 1개월 전 EPS 수치 직접 비교
const epsRowMatch = html.match(/EPS(?:\s*\(원\))?[^<]*(?:<[^>]+>)*[\s\S]*?<\/tr>/i);
const prev1mMatch = html.match(/1개월\s*전[\s\S]*?<\/tr>/);
const prev3mMatch = html.match(/3개월\s*전[\s\S]*?<\/tr>/);
const compareEps_ = (row1, row2) => {
if (!row1 || !row2) return null;
const curr = extractLastTdNum_(row1);
const prev = extractLastTdNum_(row2);
if (!Number.isFinite(curr) || !Number.isFinite(prev) || prev === 0) return null;
const chg = (curr - prev) / Math.abs(prev) * 100;
if (chg > 1) return "UP";
if (chg < -1) return "DOWN";
return "FLAT";
};
const r1 = compareEps_(epsRowMatch?.[0], prev1mMatch?.[0]);
if (r1) return r1;
const r3 = compareEps_(epsRowMatch?.[0], prev3mMatch?.[0]);
if (r3) return r3;
// 전략2: 아이콘 클래스 패턴 (Naver HTML 버전별 차이 대응)
if (/ico_up\b|class="up"|blind[^"]*상향|상향\s*조정/.test(html)) return "UP";
if (/ico_dn\b|class="dn"|blind[^"]*하향|하향\s*조정/.test(html)) return "DOWN";
// 전략3: 추정치 테이블에서 현재/이전 컬럼 비교 (th 기반)
const tblMatch = html.match(/추정치[\s\S]{0,3000}?<\/table>/i);
if (tblMatch) {
const nums = [...tblMatch[0].matchAll(/<td[^>]*>\s*([-\d,]+)\s*<\/td>/g)]
.map(m => parseKrNum_(m[1]))
.filter(v => v !== null && Math.abs(v) > 10);
if (nums.length >= 2) {
const chg = (nums[0] - nums[1]) / Math.abs(nums[1]) * 100;
if (chg > 1) return "UP";
if (chg < -1) return "DOWN";
return "FLAT";
}
}
return "DATA_MISSING";
}
// Naver 컨센서스 HTML에서 목표주가 파싱
function parseNaverTargetPrice_(html) {
const candidates = [
/목표주가[\s\S]{0,400}?<em[^>]*>\s*([\d,]+)\s*<\/em>/,
/목표주가[\s\S]{0,400}?<td[^>]*>\s*([\d,]+)\s*<\/td>/,
/목표주가[\s\S]{0,300}?>\s*([\d,]+)\s*원/,
/"targetPrice"[^>]*>\s*([\d,]+)/i,
];
for (const pat of candidates) {
const m = html.match(pat);
if (m) {
const val = parseKrNum_(m[1]);
if (val !== null && val > 1000) return val; // 1000원 이상만 유효
}
}
return null;
}
function extractLastTdNum_(rowHtml) {
const matches = [...rowHtml.matchAll(/<td[^>]*>\s*([-\d,]+)\s*<\/td>/g)];
if (!matches.length) return null;
return parseKrNum_(matches[matches.length - 1][1]);
}
// Yahoo Finance earningsTrend에서 EPS 추정치 변화 방향 판별 (Naver 실패 시 fallback)
function fetchYahooConsensusEps(code) {
const sym = normalizeYahooSymbol(code);
const cacheKey = `yahoo_consensus_${sym}`;
return withFetchCache_(cacheKey, "yahoo_quote", sym, { ok: false, epsRevisionStatus: "DATA_MISSING" }, () => {
const resp = UrlFetchApp.fetch(
`https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(sym)}?modules=earningsTrend`,
{ muteHttpExceptions: true, headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" } }
);
if (resp.getResponseCode() !== 200) return { ok: false, epsRevisionStatus: "DATA_MISSING" };
const data = JSON.parse(resp.getContentText());
const trends = data?.quoteSummary?.result?.[0]?.earningsTrend?.trend ?? [];
// 가장 가까운 분기(0q) 우선, 없으면 내년(+1y)
const trend = trends.find(t => t.period === "0q") || trends.find(t => t.period === "+1y") || trends[0];
if (!trend) return { ok: false, epsRevisionStatus: "DATA_MISSING", epsGrowth1y: null };
const current = trend.epsTrend?.current?.raw ?? null;
const ago30 = trend.epsTrend?.["30daysAgo"]?.raw ?? null;
let epsRevisionStatus = "DATA_MISSING";
if (Number.isFinite(current) && Number.isFinite(ago30) && ago30 !== 0) {
const chg = (current - ago30) / Math.abs(ago30) * 100;
epsRevisionStatus = chg > 1 ? "UP" : chg < -1 ? "DOWN" : "FLAT";
}
// A2: EPS 1년 성장률 — KOSDAQ PEG 게이트 입력값
// earningsTrend +1y 의 earningsEstimate.growth (직접 제공되는 경우)
// 없으면 (+1y avg - 0y avg) / abs(0y avg) * 100 으로 계산
let epsGrowth1y = null;
const trend1y = trends.find(t => t.period === "+1y");
const trend0y = trends.find(t => t.period === "0y");
if (trend1y) {
const directGrowth = trend1y.earningsEstimate?.growth?.raw;
if (Number.isFinite(directGrowth)) {
epsGrowth1y = parseFloat((directGrowth * 100).toFixed(1));
} else {
const eps1y = trend1y.earningsEstimate?.avg?.raw ?? null;
const eps0y = trend0y?.earningsEstimate?.avg?.raw ?? null;
if (Number.isFinite(eps1y) && Number.isFinite(eps0y) && eps0y !== 0) {
epsGrowth1y = parseFloat(((eps1y - eps0y) / Math.abs(eps0y) * 100).toFixed(1));
}
}
}
return { ok: true, epsRevisionStatus, epsGrowth1y };
});
}
// Yahoo Finance quoteSummary — 목표주가·Beta 폴백 (Naver 실패 시)
// financialData: targetMeanPrice / defaultKeyStatistics: beta
function fetchYahooTargetPrice(code) {
const sym = normalizeYahooSymbol(code);
const cacheKey = `yahoo_target_${sym}`;
return withFetchCache_(cacheKey, "yahoo_financials", sym,
{ ok: false, targetPrice: null, beta: null, earningsDate: null, exDividendDate: null, dividendPerShare: null }, () => {
const resp = UrlFetchApp.fetch(
`https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(sym)}?modules=financialData,defaultKeyStatistics,calendarEvents`,
{ muteHttpExceptions: true, headers: { "User-Agent": "Mozilla/5.0 (compatible; GAS/1.0)" } }
);
if (resp.getResponseCode() !== 200) {
return { ok: false, targetPrice: null, beta: null, earningsDate: null };
}
const data = JSON.parse(resp.getContentText());
const qr = data?.quoteSummary?.result?.[0] ?? {};
const fd = qr.financialData ?? {};
const dks = qr.defaultKeyStatistics ?? {};
const cal = qr.calendarEvents ?? {};
const earningsDates = cal.earnings?.earningsDate ?? [];
// ── 재무 건전성 필드 (2026-05-18_FINANCIAL_HEALTH_V1 + OCF 추가) ──────────
const roePctRaw = fd.returnOnEquity?.raw;
const opMarginRaw = fd.operatingMargins?.raw;
const deRaw = fd.debtToEquity?.raw ?? dks.debtToEquity?.raw;
const currentRatioRaw = fd.currentRatio?.raw;
const fcfRaw = fd.freeCashflow?.raw;
const ocfRaw = fd.operatingCashflow?.raw; // OCF 추가
const revGrowthRaw = fd.revenueGrowth?.raw;
return {
ok: true,
targetPrice: fd.targetMeanPrice?.raw ?? null,
beta: dks.beta?.raw ?? null,
earningsDate: earningsDates.length > 0 ? (earningsDates[0].fmt ?? null) : null,
exDividendDate: cal.exDividendDate?.fmt ?? null,
dividendPerShare: dks.lastDividendValue?.raw ?? null,
// 재무 건전성 — Yahoo financialData / defaultKeyStatistics
roePct: Number.isFinite(roePctRaw) ? roePctRaw * 100 : null,
operatingMarginPct: Number.isFinite(opMarginRaw) ? opMarginRaw * 100 : null,
debtToEquity: Number.isFinite(deRaw) ? deRaw : null,
currentRatio: Number.isFinite(currentRatioRaw) ? currentRatioRaw : null,
fcfB: Number.isFinite(fcfRaw) ? fcfRaw / 1e8 : null, // 억원
ocfB: Number.isFinite(ocfRaw) ? ocfRaw / 1e8 : null, // 억원 (OCF)
revenueGrowthPct: Number.isFinite(revGrowthRaw) ? revGrowthRaw * 100 : null,
};
});
}
// ── 펀더멘털 7일 장기 캐시 레이어 ────────────────────────────────────────────
// 분기별 지표(ROE/OPM/OCF/FCF)는 자주 바뀌지 않으므로 7일 캐시로 호출 횟수를 절감해
// 스크래핑 차단 위험을 사전에 방지한다.
// PropertiesService를 사용(CacheService 최대 6시간 한계 극복).
const FUNDAMENTAL_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7일(ms)
function getFundamentalCache_(ticker) {
try {
const props = PropertiesService.getScriptProperties();
const raw = props.getProperty('fund_cache_' + ticker);
if (!raw) return null;
const entry = JSON.parse(raw);
if (!entry || !entry.ts || !entry.data) return null;
if (Date.now() - entry.ts > FUNDAMENTAL_CACHE_TTL_MS) {
props.deleteProperty('fund_cache_' + ticker);
return null;
}
return entry.data;
} catch(_) { return null; }
}
function setFundamentalCache_(ticker, data) {
try {
PropertiesService.getScriptProperties().setProperty(
'fund_cache_' + ticker,
JSON.stringify({ ts: Date.now(), data: data })
);
} catch(_) {}
}
// ── 네이버 금융 ROE/OPM fallback ────────────────────────────────────────────
// 야후 Finance가 차단됐을 때 네이버 main.naver HTML에서 ROE/OPM을 보완한다.
// 호출 후 결과는 fundamentalCache에 병합되어 7일 캐시에 저장된다.
function fetchNaverFundamentals_(ticker) {
const cacheKey = 'naver_fund_' + ticker;
const cached = getCachedFetchResult_(cacheKey);
if (cached) return cached;
const emptyFallback = { ok: false, roePct: null, operatingMarginPct: null, source: 'naver_fund' };
if (isFetchCircuitOpen_('naver_fund')) return emptyFallback;
if (!consumeFetchBudget_('naver_fund', ticker)) return emptyFallback;
try {
const url = 'https://finance.naver.com/item/main.naver?code=' + encodeURIComponent(ticker);
const resp = UrlFetchApp.fetch(url, {
muteHttpExceptions: true,
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
followRedirects: true
});
if (resp.getResponseCode() !== 200) {
recordFetchFailure_('naver_fund');
return emptyFallback;
}
const html = resp.getContentText('UTF-8');
// ROE: 2024년 이후 네이버 "ROE(지배주주)" 키워드로 변경됨 (구: 자기자본이익률)
// OPM: <strong>키워드</strong></th> 이후 여러 줄 아래 <td>에 수치 위치
let roePct = null, opMarginPct = null;
const roeM = html.match(/ROE\(지배주주\)<\/strong><\/th>[\s\S]*?<td[^>]*>[\s\S]*?([\d.-]+)/);
if (roeM) roePct = parseFloat(roeM[1]);
const opM = html.match(/영업이익률<\/strong><\/th>[\s\S]*?<td[^>]*>[\s\S]*?([\d.-]+)/);
if (opM) opMarginPct = parseFloat(opM[1]);
const result = {
ok: roePct !== null || opMarginPct !== null,
roePct: Number.isFinite(roePct) ? roePct : null,
operatingMarginPct: Number.isFinite(opMarginPct) ? opMarginPct : null,
source: 'naver_fund'
};
if (result.ok) {
recordFetchSuccess_('naver_fund');
setCachedFetchResult_(cacheKey, result, true, 'naver_fund_ok');
} else {
recordFetchFailure_('naver_fund');
}
return result;
} catch(e) {
recordFetchFailure_('naver_fund');
return emptyFallback;
}
}
// ── 펀더멘털 통합 수집 (캐시 → 야후 → 네이버 fallback) ──────────────────────
// 호출처: _addTickerFundamentals_ 내에서 t.code 기준 호출
// sym 인자는 사용되지 않으므로 무시 (하위 호환 유지)
function fetchFundamentalsWithCache_(ticker, sym, yahooFin) {
// 1) 7일 캐시 확인
const cached = getFundamentalCache_(ticker);
if (cached) {
Logger.log('[FUND_CACHE_HIT] ' + ticker + ' ttl=7d');
return Object.assign({}, yahooFin, cached, { source: 'fund_cache' });
}
// 한국 종목코드 패턴 (6자리 숫자 기반) — Yahoo는 ROE/OPM 미제공 → 네이버 직접 호출
const isKrMarket = /^\d{5,6}[A-Z0-9]*$/.test(ticker);
// 숫자+문자 혼합의 국내 ETF/특수코드(예: 0117V0)는 Yahoo보다 Naver 우선 시도
const isDomesticEtfLike = /^\d{4,6}[A-Z][A-Z0-9]*$/.test(ticker);
const preferNaver = isKrMarket || isDomesticEtfLike;
// 2) 야후 수집 판단 — 한국 종목은 스킵
const hasYahoo = !preferNaver && yahooFin && yahooFin.ok &&
(Number.isFinite(yahooFin.roePct) || Number.isFinite(yahooFin.operatingMarginPct));
let fund = yahooFin || { ok: false };
// 3) 야후 미보유 또는 한국 종목 → 네이버 직접 호출
if (!hasYahoo) {
if (isKrMarket) {
Logger.log('[DEBUG][FUND_NAVER_KR] ' + ticker + ' — 한국 종목, 네이버 직접 호출');
} else if (isDomesticEtfLike) {
Logger.log('[DEBUG][FUND_NAVER_ETF] ' + ticker + ' — 국내 ETF/특수코드, 네이버 우선 호출');
} else {
Logger.log('[DEBUG][FUND_NAVER_FALLBACK] ' + ticker + ' — 야후 데이터 없음, 네이버 시도');
}
const naverFund = fetchNaverFundamentals_(ticker);
if (naverFund.ok) {
fund = Object.assign({}, fund, {
roePct: fund.roePct ?? naverFund.roePct,
operatingMarginPct: fund.operatingMarginPct ?? naverFund.operatingMarginPct,
ok: true,
source: isKrMarket ? 'naver_fund_kr' : (isDomesticEtfLike ? 'naver_fund_etf' : 'naver_fund_fallback')
});
Logger.log('[DEBUG][FUND_NAVER_OK] ' + ticker + ' source=' + fund.source + ' ROE=' + fund.roePct + ' OPM=' + fund.operatingMarginPct);
} else if (isDomesticEtfLike) {
Logger.log('[INFO][FUND_ETF_NO_DATA] ' + ticker + ' — ETF/특수코드, 재무제표 없음 (정상)');
} else {
Logger.log('[WARN][FUND_NAVER_FAIL] ' + ticker + ' source=' + (isKrMarket ? 'naver_fund_kr' : 'naver_fund_fallback') + ' reason=' + String(naverFund.error || naverFund.quoteStatus || 'NO_DATA'));
}
}
// 4) 수집 결과를 7일 캐시에 저장 (ok=true인 경우만)
if (fund.ok && (Number.isFinite(fund.roePct) || Number.isFinite(fund.operatingMarginPct))) {
setFundamentalCache_(ticker, {
roePct: fund.roePct,
operatingMarginPct: fund.operatingMarginPct,
debtToEquity: fund.debtToEquity,
currentRatio: fund.currentRatio,
fcfB: fund.fcfB,
ocfB: fund.ocfB,
revenueGrowthPct: fund.revenueGrowthPct,
cached_at: new Date().toISOString(),
});
Logger.log('[FUND_CACHE_SET] ' + ticker + ' ROE=' + fund.roePct + ' OPM=' + fund.operatingMarginPct);
}
return fund;
}
// EPS_Revision_Status 기존 입력값 보존 — writeToSheet의 clearContents 전에 호출
function readExistingEpsRevision_(sheetName) {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName(sheetName);
if (!sheet || sheet.getLastRow() < 3) return {};
const data = sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn()).getValues();
const headers = data[0];
const tickerIdx = headers.indexOf("Ticker");
const epsRevIdx = headers.indexOf("EPS_Revision_Status");
if (tickerIdx < 0 || epsRevIdx < 0) return {};
const valid = new Set(["UP", "FLAT", "DOWN"]);
const result = {};
for (let i = 1; i < data.length; i++) {
const ticker = String(data[i][tickerIdx]).trim();
const val = String(data[i][epsRevIdx]).trim().toUpperCase();
if (ticker && valid.has(val)) result[ticker] = val;
}
return result;
}
// ── FC(Frontier/탐색) 손실 예산 월별 집계 ────────────────────────────────────
// performance 탭의 fc_bucket=Y 거래 중 당월 청산 건의 손실합 계산.
// 반환: { fc_used_pct: number|null, fc_budget_pct: 2.5, fc_status: string, trades: integer }
function calcFcBudget_(totalAssetKrw, budgetPctOverride) {
const BUDGET_PCT = Number.isFinite(budgetPctOverride) && budgetPctOverride > 0 ? budgetPctOverride : 2.5;
const result = { fc_used_pct: null, fc_budget_pct: BUDGET_PCT, fc_status: "UNKNOWN (performance 탭 없음)", trades: 0 };
try {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName("performance");
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 pnlIdx = hdr.indexOf("pnl_pct");
const exitIdx = hdr.indexOf("exit_date");
const fcIdx = hdr.indexOf("fc_bucket");
const entryIdx= hdr.indexOf("entry_price");
const qtyIdx = hdr.indexOf("quantity");
if (pnlIdx < 0 || exitIdx < 0 || fcIdx < 0) return { ...result, fc_status: "UNKNOWN (performance 탭 구조 불일치)" };
const thisMonth = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM");
let fcLossKrw = 0;
let fcTrades = 0;
for (let i = 2; i < data.length; i++) {
const fcBucket = String(data[i][fcIdx]).trim().toUpperCase();
if (fcBucket !== "Y") continue;
const exitVal = data[i][exitIdx];
if (!exitVal || String(exitVal).trim() === "") continue;
// 당월 청산 건만
const exitStr = exitVal instanceof Date
? Utilities.formatDate(exitVal, "Asia/Seoul", "yyyy-MM")
: String(exitVal).trim().substring(0, 7);
if (exitStr !== thisMonth) continue;
const pnl = parseFloat(data[i][pnlIdx]);
if (!Number.isFinite(pnl) || pnl >= 0) continue; // 손실만 집계
// KRW 손실 계산: entry_price × qty × |pnl_pct/100|
const entry = parseFloat(data[i][entryIdx]);
const qty = parseInt(data[i][qtyIdx]);
if (Number.isFinite(entry) && Number.isFinite(qty) && qty > 0) {
fcLossKrw += entry * qty * Math.abs(pnl) / 100;
}
fcTrades++;
}
if (fcTrades === 0) {
return { fc_used_pct: 0, fc_budget_pct: BUDGET_PCT, fc_status: `OK (0% / ${BUDGET_PCT}% 사용)`, trades: 0 };
}
if (!Number.isFinite(totalAssetKrw) || totalAssetKrw <= 0) {
return { fc_used_pct: null, fc_budget_pct: BUDGET_PCT,
fc_status: `UNKNOWN (총자산 미제공, 손실${fcTrades}건)`, trades: fcTrades };
}
const usedPct = (fcLossKrw / totalAssetKrw) * 100;
const remaining = BUDGET_PCT - usedPct;
const fcStatus = usedPct >= BUDGET_PCT
? `EXHAUSTED (${usedPct.toFixed(1)}% >= ${BUDGET_PCT}% 예산 초과)`
: `OK (${usedPct.toFixed(1)}% / ${BUDGET_PCT}% — 잔여 ${remaining.toFixed(1)}%)`;
return { fc_used_pct: parseFloat(usedPct.toFixed(2)), fc_budget_pct: BUDGET_PCT, fc_status: fcStatus, trades: fcTrades };
} catch(e) {
handleFetchError_("calcFcBudget_", e, "WARN");
return { ...result, fc_status: "ERROR: " + e.message };
}
}
// ── orbit_gap 계산 — spec/01_objective_profile.yaml:orbit_monthly_tracker ──
// orbit_gap(%) = 목표누적수익률_to_date - 실제누적수익률_to_date
// 목표누적수익률 = (target/start)^(elapsed_months/total_months) - 1 (기하평균 보간)
// 반환: { ok, orbit_gap_pct, orbit_state, offensive_slot_adj, cash_floor_adj, detail, ... }
function calcOrbitGap_(settings) {
const NONE = (detail) => ({
ok: false, orbit_gap_pct: null, orbit_state: "UNKNOWN",
offensive_slot_adj: 0, cash_floor_adj: 0, detail,
});
const startAsset = parseFloat(settings["orbit_start_asset_krw"]);
const targetAsset = parseFloat(settings["orbit_target_asset_krw"]);
const currentAsset = parseFloat(settings["total_asset_krw"]);
const startYMRaw = settings["orbit_start_yyyymm"]; // "2026-01" or Sheets date
const endYMRaw = settings["orbit_end_yyyymm"]; // "2028-12" or Sheets date
if (!Number.isFinite(startAsset) || startAsset <= 0) return NONE("orbit_start_asset_krw 미설정");
if (!Number.isFinite(targetAsset) || targetAsset <= 0) return NONE("orbit_target_asset_krw 미설정");
if (!Number.isFinite(currentAsset) || currentAsset <= 0) return NONE("total_asset_krw 미설정");
const parseYM = value => {
if (value instanceof Date && !isNaN(value.getTime())) {
return value.getFullYear() * 12 + (value.getMonth() + 1);
}
const s = String(value ?? "").trim();
if (!s) return null;
const m = s.match(/^(\d{4})[-./년\s]*(\d{1,2})/);
if (!m) return null;
const y = parseInt(m[1], 10);
const mo = parseInt(m[2], 10);
if (!Number.isFinite(y) || !Number.isFinite(mo) || mo < 1 || mo > 12) return null;
return y * 12 + mo;
};
const now = new Date();
const nowYM = now.getFullYear() * 12 + (now.getMonth() + 1);
const startYM_int = parseYM(startYMRaw);
const endYM_int = parseYM(endYMRaw);
if (startYM_int === null || endYM_int === null) return NONE("orbit_start/end_yyyymm 파싱 실패");
const totalMonths = endYM_int - startYM_int;
const elapsedMonths = Math.max(0, nowYM - startYM_int);
if (totalMonths <= 0) return NONE("orbit_end_yyyymm이 orbit_start_yyyymm 이전");
const frac = Math.min(elapsedMonths / totalMonths, 1);
const targetCumRet = Math.pow(targetAsset / startAsset, frac) - 1;
const actualCumRet = currentAsset / startAsset - 1;
const orbitGap = parseFloat(((targetCumRet - actualCumRet) * 100).toFixed(2));
let orbitState, slotAdj, cashAdj;
if (orbitGap > 3) {
orbitState = "significantly_behind"; slotAdj = 2; cashAdj = -2;
} else if (orbitGap > 1) {
orbitState = "mild_behind"; slotAdj = 1; cashAdj = -1;
} else if (orbitGap < -2) {
orbitState = "ahead_of_target"; slotAdj = 0; cashAdj = 1;
} else {
orbitState = "on_track"; slotAdj = 0; cashAdj = 0;
}
return {
ok: true,
elapsed_months: elapsedMonths,
total_months: totalMonths,
target_cum_return_pct: parseFloat((targetCumRet * 100).toFixed(2)),
actual_cum_return_pct: parseFloat((actualCumRet * 100).toFixed(2)),
orbit_gap_pct: orbitGap,
orbit_state: orbitState,
offensive_slot_adj: slotAdj,
cash_floor_adj: cashAdj,
detail: `gap=${orbitGap}%p target=${(targetCumRet*100).toFixed(1)}% actual=${(actualCumRet*100).toFixed(1)}% (${elapsedMonths}/${totalMonths}개월)`,
};
}
// ── orbit_gap → monthly_history 기록 ─────────────────────────────────────────
// settings/info를 인자로 받으면 재사용 (runMacro 연쇄 시), 없으면 직접 읽기 (독립 실행 시).
function runOrbitGap(settings, info) {
if (!settings) settings = readSettingsTab_();
if (!info) info = calcOrbitGap_(settings);
if (!info.ok) {
Logger.log("runOrbitGap 스킵: " + info.detail);
return;
}
const currentMonth = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM");
upsertMonthlyRow_(currentMonth, {
Total_Asset: parseFloat(settings["total_asset_krw"]),
Start_Asset: parseFloat(settings["orbit_start_asset_krw"]),
Target_Asset: parseFloat(settings["orbit_target_asset_krw"]),
Target_Return_Pct: info.target_cum_return_pct,
Actual_Return_Pct: info.actual_cum_return_pct,
Orbit_Gap_Pct: info.orbit_gap_pct,
Orbit_State: info.orbit_state,
Slot_Adj: info.offensive_slot_adj,
Cash_Floor_Adj: info.cash_floor_adj,
});
Logger.log(`monthly_history(orbit): ${currentMonth} gap=${info.orbit_gap_pct}%p (${info.orbit_state}) slot_adj=${info.offensive_slot_adj}`);
}
// 동일 티커 복수 행(소수 분리 등) 합산 — ex 를 in-place 갱신
function _mergePositionRecord_(ex, incoming) {
// THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/convert_xlsx_to_json.py:normalize_backdata_harness_payload
const newQty = ex.quantity + incoming.quantity;
const newAvail = (ex.available_quantity || 0) + (incoming.available_quantity || 0);
const newMV = (ex.market_value || 0) + (incoming.market_value || 0);
const newCost = (ex.total_cost || 0) + (incoming.total_cost || 0);
const newPL = (ex.profit_loss || 0) + (incoming.profit_loss || 0);
ex.quantity = newQty;
ex.available_quantity = newAvail;
ex.market_value = newMV;
ex.total_cost = newCost;
ex.profit_loss = newPL;
ex.average_cost = newQty > 0 ? Math.round(newCost / newQty) : ex.average_cost;
ex.entry_price = ex.average_cost;
ex.return_pct = newCost > 0
? parseFloat(((newPL / newCost) * 100).toFixed(2))
: ex.return_pct;
// 이름: "(소수)" 접미사 없는 쪽 우선
if (ex.name.includes('소수') && !incoming.name.includes('소수')) {
ex.name = incoming.name;
}
// 기타 스칼라 필드: 기존 값이 null 이면 incoming 값으로 채움
['stop_price','highest_price','entry_date','entry_stage','position_type'].forEach(k => {
if (ex[k] == null && incoming[k] != null) ex[k] = incoming[k];
});
}
// ── account_snapshot 탭 읽기 → 계좌 캡처 확정 원장 ─────────────────────────
// - 일반계좌: 종목별 보유수량·평단 + 현금 모두 제공 → Sell_Qty 직접 산출 가능
// - ISA/연금저축: 캡처 금액은 투자완료 계좌잔액 reference. 일반계좌 현금원장 합산 금지.
// 개별 종목수량 미제공 시 Sell_Qty 산출 불가
// parse_status=CAPTURE_READ_OK + user_confirmed=Y 행만 실 데이터로 인정.
function readAccountSnapshotMap_() {
const settings_ = readSettingsTab_();
const confirmModeRaw_ = String((settings_ && settings_["account_snapshot_confirm_mode"]) || "STRICT_Y").trim().toUpperCase();
const allowAutoConfirm_ = confirmModeRaw_ === "AUTO_IF_PARSE_OK";
const makeCashBucket = () => ({ immediate_cash: null, settlement_cash_d2: null, available_cash: null, open_order_amount: 0 });
const result = {
positions: {}, // 일반계좌 개별주 (Sell_Qty·stop_price 계산에 사용)
isa_positions: {}, // ISA 개별주 (PCL 카운트 전용 — Sell_Qty 미사용)
cash: makeCashBucket(), // 일반계좌 합산 (매수 가용 현금 기준)
cash_by_account: { // 계좌 유형별 현금 분리 추적
"일반계좌": makeCashBucket(),
"ISA": makeCashBucket(),
"연금저축": makeCashBucket(),
},
rows_read: 0,
rows_confirmed: 0,
rows_parse_ok_unconfirmed: 0,
rows_auto_confirmed: 0,
confirm_mode: confirmModeRaw_,
account_types_seen: new Set(), // 캡처된 계좌 유형 목록
};
try {
const sheet = getSpreadsheet_().getSheetByName("account_snapshot");
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("ticker");
const nameIdx = idx("name");
const accountIdx = idx("account");
const acctTypeIdx = idx("account_type"); // ISA / 연금저축 / 일반계좌 구분
const qtyIdx = idx("holding_quantity");
const availQtyIdx = idx("available_quantity");
const avgIdx = idx("average_cost");
const totalCostIdx = idx("total_cost");
const curIdx = idx("current_price");
const mvIdx = idx("market_value");
const profitIdx = idx("profit_loss");
const retPctIdx = idx("return_pct");
const immIdx = idx("immediate_cash");
const d2Idx = idx("settlement_cash_d2");
const availIdx = idx("available_cash");
const openIdx = idx("open_order_amount");
const statusIdx = idx("parse_status");
const confirmedIdx = idx("user_confirmed");
const capturedIdx = idx("captured_at");
const stopIdx = idx("stop_price");
const highIdx = idx("highest_price_since_entry");
const entryDateIdx = idx("entry_date");
const stageIdx = idx("entry_stage");
const posTypeIdx = idx("position_type");
const lastUpdIdx = idx("last_updated");
for (let i = 2; i < data.length; i++) {
const row = data[i];
const parseStatus = statusIdx >= 0 ? String(row[statusIdx] ?? "").trim() : "";
const confirmed = confirmedIdx >= 0 ? String(row[confirmedIdx] ?? "").trim().toUpperCase() : "";
if (!parseStatus && !confirmed) continue;
result.rows_read++;
const isParseOk = parseStatus === "CAPTURE_READ_OK";
const hasConfirm = ["Y", "YES", "TRUE", "1"].includes(confirmed);
const isConfirmed = isParseOk && (hasConfirm || (allowAutoConfirm_ && !confirmed));
if (isParseOk && !hasConfirm) {
result.rows_parse_ok_unconfirmed++;
}
if (isParseOk && !hasConfirm && allowAutoConfirm_ && !confirmed) {
result.rows_auto_confirmed++;
}
if (!isConfirmed) continue;
result.rows_confirmed++;
const acctType = acctTypeIdx >= 0 ? String(row[acctTypeIdx] ?? "").trim() : "일반계좌";
const isRestrictedAcct = acctType === "ISA" || acctType === "연금저축";
result.account_types_seen.add(acctType);
// 현금/잔액 — 계좌 유형별 버킷에 기록
const immediateCash = immIdx >= 0 ? parseFloat(row[immIdx]) : NaN;
const settlementCash = d2Idx >= 0 ? parseFloat(row[d2Idx]) : NaN;
const availableCash = availIdx >= 0 ? parseFloat(row[availIdx]) : NaN;
const openOrderAmount = openIdx >= 0 ? parseFloat(row[openIdx]) : NaN;
const cashBucket = result.cash_by_account[acctType] ?? makeCashBucket();
if (!result.cash_by_account[acctType]) result.cash_by_account[acctType] = cashBucket;
if (Number.isFinite(immediateCash)) cashBucket.immediate_cash = (cashBucket.immediate_cash ?? 0) + immediateCash;
if (Number.isFinite(settlementCash)) cashBucket.settlement_cash_d2 = (cashBucket.settlement_cash_d2 ?? 0) + settlementCash;
if (Number.isFinite(availableCash)) cashBucket.available_cash = (cashBucket.available_cash ?? 0) + availableCash;
if (Number.isFinite(openOrderAmount)) cashBucket.open_order_amount = (cashBucket.open_order_amount ?? 0) + openOrderAmount;
// result.cash: 일반계좌 현금만 포트폴리오 cash ledger로 사용
// ISA/연금저축 값은 투자완료 계좌잔액 reference로만 보관
if (!isRestrictedAcct) {
if (Number.isFinite(immediateCash)) result.cash.immediate_cash = (result.cash.immediate_cash ?? 0) + immediateCash;
if (Number.isFinite(settlementCash)) result.cash.settlement_cash_d2 = (result.cash.settlement_cash_d2 ?? 0) + settlementCash;
if (Number.isFinite(availableCash)) result.cash.available_cash = (result.cash.available_cash ?? 0) + availableCash;
if (Number.isFinite(openOrderAmount)) result.cash.open_order_amount = (result.cash.open_order_amount ?? 0) + openOrderAmount;
}
// 연금저축 = ETF 전용 계좌 → 개별주 포지션 매핑 완전 스킵
if (acctType === "연금저축") continue;
const ticker = tickerIdx >= 0 ? normalizeTickerCode(row[tickerIdx]) : "";
const qty = qtyIdx >= 0 ? parseFloat(row[qtyIdx]) : NaN;
if (!ticker || !Number.isFinite(qty) || qty <= 0) continue;
const availQty = availQtyIdx >= 0 ? parseFloat(row[availQtyIdx]) : NaN;
const totalCost = totalCostIdx >= 0 ? parseFloat(row[totalCostIdx]) : NaN;
const profitLoss = profitIdx >= 0 ? parseFloat(row[profitIdx]) : NaN;
const retPct = retPctIdx >= 0 ? parseFloat(row[retPctIdx]) : NaN;
const stopPrice = stopIdx >= 0 ? parseFloat(row[stopIdx]) : NaN;
const highPrice = highIdx >= 0 ? parseFloat(row[highIdx]) : NaN;
const entryDateRaw = entryDateIdx >= 0 ? row[entryDateIdx] : "";
const lastUpdRaw = lastUpdIdx >= 0 ? row[lastUpdIdx] : "";
const normalizeDateCell = value => value instanceof Date
? Utilities.formatDate(value, "Asia/Seoul", "yyyy-MM-dd")
: String(value ?? "").trim().substring(0, 10);
const posRecord = {
ticker,
name: nameIdx >= 0 ? String(row[nameIdx] ?? "").trim() : "",
account: accountIdx >= 0 ? String(row[accountIdx] ?? "").trim() : "",
account_type: acctType,
quantity: qty,
available_quantity: Number.isFinite(availQty) ? availQty : null,
average_cost: avgIdx >= 0 ? parseFloat(row[avgIdx]) : null,
entry_price: avgIdx >= 0 ? parseFloat(row[avgIdx]) : null,
total_cost: Number.isFinite(totalCost) ? totalCost : null,
current_price: curIdx >= 0 ? parseFloat(row[curIdx]) : null,
market_value: mvIdx >= 0 ? parseFloat(row[mvIdx]) : null,
profit_loss: Number.isFinite(profitLoss) ? profitLoss : null,
return_pct: Number.isFinite(retPct) ? retPct : null,
stop_price: Number.isFinite(stopPrice) && stopPrice > 0 ? stopPrice : null,
highest_price: Number.isFinite(highPrice) && highPrice > 0 ? highPrice : null,
entry_date: entryDateRaw ? normalizeDateCell(entryDateRaw) : "",
entry_stage: stageIdx >= 0 ? String(row[stageIdx] ?? "").trim() : "",
position_type: posTypeIdx >= 0 && String(row[posTypeIdx] ?? "").trim().toLowerCase() === "core" ? "core" : "satellite",
last_updated: lastUpdRaw ? normalizeDateCell(lastUpdRaw) : "",
parse_status: parseStatus,
user_confirmed: "Y",
captured_at: capturedIdx >= 0 ? row[capturedIdx] : "",
};
if (acctType === "ISA") {
// ISA 개별주: PCL 카운트 전용. Sell_Qty·stop_price·Total_Heat 계산에는 미사용.
if (result.isa_positions[ticker]) {
_mergePositionRecord_(result.isa_positions[ticker], posRecord);
} else {
result.isa_positions[ticker] = posRecord;
}
} else {
// 일반계좌: 전체 기능(stop_price, Sell_Qty, Total_Heat 등) 활성
// 동일 티커 중복 행(소수 분리 계좌 등) → 수량·평가액·원가 합산
if (result.positions[ticker]) {
_mergePositionRecord_(result.positions[ticker], posRecord);
} else {
result.positions[ticker] = posRecord;
}
}
}
result.account_types_seen = [...result.account_types_seen]; // Set → Array
} catch(e) {
handleFetchError_("readAccountSnapshotMap_", e, "WARN");
}
if (result.rows_read > 0 && result.rows_confirmed === 0 && result.rows_parse_ok_unconfirmed > 0) {
Logger.log(
"[ACCOUNT_SNAPSHOT_CONFIRMATION_BLOCK] parse_ok_unconfirmed=" + result.rows_parse_ok_unconfirmed
+ " mode=" + result.confirm_mode
+ " (hint: settings.account_snapshot_confirm_mode=AUTO_IF_PARSE_OK 또는 user_confirmed=Y 입력)"
);
}
return result;
}
// ── account_snapshot 탭 초기화 (캡처 원장 + 선택 포지션 상태 컬럼) ───────────────
// runDataFeed() 시작 시 자동 호출 — 탭 없으면 생성, 있으면 헤더 점검 후 즉시 반환.
// 샘플 행(parse_status="SAMPLE")은 GAS가 읽지 않으므로 실 데이터에 영향 없음.
function initAccountSnapshotTemplate_() {
const SS = getSpreadsheet_();
const SHEET_NAME = "account_snapshot";
const HEADERS = [
"captured_at", "account", "account_type", "ticker", "name",
"holding_quantity", "available_quantity", "average_cost", "total_cost",
"current_price", "market_value", "profit_loss", "return_pct",
"immediate_cash", "settlement_cash_d2", "available_cash", "open_order_amount",
"monthly_contribution_limit", "monthly_contribution_used",
"parse_status", "user_confirmed",
"stop_price", "highest_price_since_entry", "entry_date", "entry_stage", "position_type", "last_updated",
];
const TEXT_COLS = new Set(["captured_at","ticker","parse_status","user_confirmed","account","account_type","name","entry_date","entry_stage","position_type","last_updated"]);
const H = {}; // 컬럼명 → 인덱스 빠른 참조
HEADERS.forEach((h, i) => { H[h] = i; });
const numCols = HEADERS.length;
let sheet = SS.getSheetByName(SHEET_NAME);
const existed = !!sheet;
// ① 탭 존재 + 헤더 행이 있으면 → 누락 컬럼만 뒤에 추가하고 즉시 반환 (데이터 보호)
if (existed) {
const existingData = sheet.getDataRange().getValues();
const hasHeader = existingData.length >= 2 && existingData[1].some(v => String(v).trim() !== "");
if (hasHeader) {
const existingHdr = existingData[1].map(h => String(h).trim());
const missing = HEADERS.filter(h => !existingHdr.includes(h));
if (missing.length > 0) {
const startCol = existingHdr.length + 1;
sheet.getRange(2, startCol, 1, missing.length).setValues([missing]);
sheet.getRange(2, startCol, 1, missing.length).setFontWeight("bold").setBackground("#d9ead3");
missing.forEach((h, i) => {
if (TEXT_COLS.has(h)) sheet.getRange(2, startCol + i, 100, 1).setNumberFormat("@");
});
Logger.log(`initAccountSnapshotTemplate_: account_snapshot 누락컬럼 추가=${missing.join(",")}`);
}
return { action: "skipped_existing", sheet: SHEET_NAME, rows: existingData.length - 2, missing_headers: missing };
}
}
// ② 탭 없으면 생성, 있지만 비어 있으면 초기화
if (!sheet) sheet = SS.insertSheet(SHEET_NAME);
sheet.clearContents();
sheet.clearFormats();
// 행1: 안내 메모
const now = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
sheet.getRange(1, 1).setValue(
`[account_snapshot] HTS 캡처->ChatGPT 파싱->A3 붙여넣기 탭 | ${numCols}컬럼 | 초기화: ${now} KST | SAMPLE 행은 실제 캡처 후 삭제`
);
// 행2: 컬럼 헤더 (볼드)
sheet.getRange(2, 1, 1, numCols).setValues([HEADERS]);
sheet.getRange(2, 1, 1, numCols).setFontWeight("bold").setBackground("#d9ead3");
// 텍스트 포맷 — setValues 전 적용 필수 (Ticker 등 숫자 변환 방지)
HEADERS.forEach((h, i) => {
if (TEXT_COLS.has(h)) sheet.getRange(2, i + 1, 100, 1).setNumberFormat("@");
});
// ── 샘플 데이터 (parse_status="SAMPLE" → GAS readAccountSnapshotMap_ 무시) ──
const sampleDate = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd") + " 09:30";
function makeRow(fields) {
const row = new Array(numCols).fill("");
Object.entries(fields).forEach(([k, v]) => { if (H[k] !== undefined) row[H[k]] = v; });
return row;
}
const sampleRows = [
makeRow({
captured_at: sampleDate, account: "일반계좌", account_type: "일반계좌",
ticker: "005930", name: "삼성전자",
holding_quantity: 100, available_quantity: 100,
average_cost: 68000, total_cost: 6800000,
current_price: 75000, market_value: 7500000,
profit_loss: 700000, return_pct: 10.3,
immediate_cash: 3500000, settlement_cash_d2: 4200000,
available_cash: 3500000, open_order_amount: 0,
parse_status: "SAMPLE", user_confirmed: "N",
}),
makeRow({
captured_at: sampleDate, account: "일반계좌", account_type: "일반계좌",
ticker: "000660", name: "SK하이닉스",
holding_quantity: 30, available_quantity: 30,
average_cost: 180000, total_cost: 5400000,
current_price: 210000, market_value: 6300000,
profit_loss: 900000, return_pct: 16.7,
parse_status: "SAMPLE", user_confirmed: "N",
}),
makeRow({
captured_at: sampleDate, account: "ISA", account_type: "ISA",
ticker: "012450", name: "한화에어로스페이스",
holding_quantity: 10, available_quantity: 10,
average_cost: 980000, total_cost: 9800000,
current_price: 1216000, market_value: 12160000,
profit_loss: 2360000, return_pct: 24.1,
monthly_contribution_limit: 4000000, monthly_contribution_used: 1500000,
parse_status: "SAMPLE", user_confirmed: "N",
}),
// 현금 전용 행 (보유종목 없음)
makeRow({
captured_at: sampleDate, account: "일반계좌", account_type: "일반계좌",
name: "현금",
immediate_cash: 3500000, settlement_cash_d2: 4200000,
available_cash: 3500000, open_order_amount: 0,
parse_status: "SAMPLE", user_confirmed: "N",
}),
];
sheet.getRange(3, 1, sampleRows.length, numCols).setValues(sampleRows);
// 샘플 행 배경색 — 실제 데이터와 구분
sheet.getRange(3, 1, sampleRows.length, numCols).setBackground("#fff2cc");
Logger.log(`initAccountSnapshotTemplate_: ${existed ? "재초기화" : "신규생성"} | 탭="${SHEET_NAME}" | 샘플행=${sampleRows.length}`);
return {
action: existed ? "reinit" : "created",
sheet: SHEET_NAME,
columns: numCols,
sample_rows: sampleRows.length,
note: "SAMPLE 행은 parse_status=SAMPLE → GAS가 무시. 실제 HTS 캡처 붙여넣기 후 삭제.",
next_steps: [
"HTS 보유종목 화면 캡처 → ChatGPT 첨부 (capture_parse_prompt.md 포함)",
"ChatGPT TSV 출력 → account_snapshot 탭 A3 셀 선택 → Ctrl+V",
"SAMPLE 행 삭제 후 runDataFeed() 재실행",
],
};
}
function calcPerformanceBuyBias_(performance) {
const p = performance || {};
const multiplier = Number.isFinite(p.bayesian_multiplier) ? p.bayesian_multiplier : 0.5;
const tradesUsed = Number.isFinite(p.trades_used) ? p.trades_used : 0;
const netExp = Number.isFinite(p.net_expectancy_30) ? p.net_expectancy_30 : null;
const consLoss = Number.isFinite(p.consecutive_losses) ? p.consecutive_losses : 0;
// [Phase 4] CAPITAL_STYLE_ALLOCATION_V3 연계: 데이터 품질 갭 분석
const legacyQuality = Number.isFinite(p.legacy_investment_quality_score) ? p.legacy_investment_quality_score : 13;
const modernQuality = Number.isFinite(p.modern_investment_quality_score) ? p.modern_investment_quality_score : 69;
const qualityGap = Math.abs(modernQuality - legacyQuality);
let entryBlock = false;
let quantityMult = multiplier;
let reason = "performance_default";
let confidenceCap = 1.0;
if (qualityGap >= 20) {
confidenceCap = 0.5;
reason = "quality_gap_penalty";
quantityMult = Math.min(quantityMult, 0.5);
}
if (consLoss >= 5) {
entryBlock = true;
quantityMult = 0;
reason = "no_bet";
} else if (tradesUsed < 5) {
quantityMult = Math.min(quantityMult, 0.5);
reason = (reason === "quality_gap_penalty") ? reason + "|data_short" : "data_short";
} else if (Number.isFinite(netExp) && netExp < 0) {
quantityMult = Math.min(quantityMult, 0.25);
reason = "negative_expectancy";
} else if (Number.isFinite(netExp) && netExp >= 3.0 && multiplier >= 1.0) {
quantityMult = (confidenceCap < 1.0) ? confidenceCap : 1.0;
reason = (reason === "quality_gap_penalty") ? reason + "|high_bet_capped" : "high_bet";
} else if (multiplier >= 0.5) {
reason = (reason === "quality_gap_penalty") ? reason : "standard";
}
return {
entry_block: entryBlock,
quantity_multiplier: quantityMult,
effective_confidence_cap: confidenceCap,
label: String(p.bayesian_label ?? "medium_confidence"),
reason: reason,
};
}
function runDataFeed() {
if (typeof isRunAllOrchestrated_ === "function" && isRunAllOrchestrated_()) {
setFetchSessionLabel_("runDataFeed");
} else {
beginFetchSession_("runDataFeed");
}
// [PROPOSAL50] P2-2: YAML-GAS 커버리지 감사 — 실행마다 커버리지 기록 갱신
try { auditYamlGasCoverage_(); } catch(e) { Logger.log('[YGCA] audit error: ' + e.message); }
if (_gasCompatRoot_._gasCompatFallbackUsed_) {
Logger.log("[GAS_COMPAT_FALLBACK] gas_lib.gs helper fallback activated — redeploy full Apps Script project to restore canonical helpers.");
}
// account_snapshot 탭 없으면 자동 생성 (캡처 원장 + 선택 포지션 상태 헤더)
initAccountSnapshotTemplate_();
// settings 탭 — 사용자 입력 파라미터 (total_asset_krw, risk_budget_override 등)
const settings = readSettingsTab_();
ensureAccountSnapshotConfirmModeSetting_(settings);
let totalAssetKrw_ = Number.isFinite(parseFloat(settings["total_asset_krw"]))
? parseFloat(settings["total_asset_krw"]) : null;
const riskBudget_ = Number.isFinite(parseFloat(settings["risk_budget_override"]))
? Math.min(0.02, Math.max(0, parseFloat(settings["risk_budget_override"])))
: 0.007; // POSITION_SIZE_V1 기본값
// Bayesian multiplier — performance 탭 기반 자동 계산 (spec/17_performance_contract.yaml)
const bayesian = readPerformanceSheet_();
Logger.log(`Bayesian: ${bayesian.bayesian_label} (${bayesian.bayesian_multiplier}×) trades=${bayesian.trades_used}`);
const accountSnapshot_ = readAccountSnapshotMap_();
Logger.log(
"[ACCOUNT_SNAPSHOT_STATUS] rows_read=" + accountSnapshot_.rows_read
+ " confirmed=" + accountSnapshot_.rows_confirmed
+ " parse_ok_unconfirmed=" + (accountSnapshot_.rows_parse_ok_unconfirmed || 0)
+ " auto_confirmed=" + (accountSnapshot_.rows_auto_confirmed || 0)
+ " mode=" + (accountSnapshot_.confirm_mode || "STRICT_Y")
);
if (accountSnapshot_.rows_read > 0 && accountSnapshot_.rows_confirmed === 0 && (accountSnapshot_.rows_parse_ok_unconfirmed || 0) > 0) {
upsertOperationalWarningSetting_(
"account_snapshot_confirmation_warning",
"[ACCOUNT_CONFIRMATION_REQUIRED] parse_ok_unconfirmed=" + accountSnapshot_.rows_parse_ok_unconfirmed
+ ", mode=" + (accountSnapshot_.confirm_mode || "STRICT_Y")
+ ", action=user_confirmed=Y 입력 또는 account_snapshot_confirm_mode=AUTO_IF_PARSE_OK"
);
} else {
upsertOperationalWarningSetting_("account_snapshot_confirmation_warning", "");
}
const settlementCashD2_ = Number.isFinite(parseFloat(settings["settlement_cash_d2_krw"]))
? parseFloat(settings["settlement_cash_d2_krw"])
: accountSnapshot_.cash.settlement_cash_d2;
if (String((settings["cash_floor_status_override"] || "")).trim()) {
// no-op: 수동 오버라이드가 있으면 런타임 경고 판단을 덮지 않음
}
if (settlementCashD2_ === 0 || settlementCashD2_ === null || settlementCashD2_ === undefined) {
// 현금 원장 정보 부족을 별도 경고로 남긴다.
upsertOperationalWarningSetting_(
"cash_ledger_warning",
"[CASH_LEDGER_WARNING] settlement_cash_d2_krw가 0 또는 미입력 상태"
);
} else {
upsertOperationalWarningSetting_("cash_ledger_warning", "");
}
const weeklyTargetCashPct_ = Number.isFinite(parseFloat(settings["weekly_target_cash_pct"]))
? parseFloat(settings["weekly_target_cash_pct"])
: null;
const headers = [
// ── 기본 수급·가격 ─────────────────────────────────────────────────────
"Ticker","Name","Price_Date","Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_OK","Flow_Rows","Updated_At",
"Price_Status","Close","Open","PrevClose","High","Low","Volume","AvgVolume_5D",
"MA20","MA60","Ret5D","Ret10D","Ret20D","Ret60D",
"ATR20","ATR20_Pct","Val_Surge_Pct",
"AvgTradeValue_5D_M","AvgTradeValue_20D_M","AvgTradeValue_5D_KRW","AvgTradeValue_20D_KRW","TradeValue_Unit",
"Bid","Ask","Spread_Pct","Spread_Status","Spread_Source","Quote_Source","Quote_Status","Liquidity_Status",
"Flow5D_Status","Flow20D_Status","Ind5D_Status","Val_Surge_Status",
"DART_Status","DART_Source","DART_Catalyst","DART_Risk",
// ── 밸류에이션 ─────────────────────────────────────────────────────────
"Forward_PE","PBR","EPS","EPS_Revision_Status","EPS_Growth_1Y_Pct",
"DividendYield","DPS","Beta","High52W","Low52W","Pct_52W_High","Pct_From_52W_Low","Target_Price","Upside_Pct",
"Earnings_Date","Days_To_Earnings","Ex_Dividend_Date","Days_To_Ex_Div",
// ── 재무 건전성 (2026-05-18_FINANCIAL_HEALTH_V1 + OCF_B 추가) ────────────
"ROE_Pct","Operating_Margin_Pct","Debt_To_Equity","Current_Ratio","FCF_B","OCF_B","Revenue_Growth_Pct",
// ── 진입 가격·기대우위·수량 자동 추정 ───────────────────────────────────
"Limit_Price_Est","Stop_Price_Est","Stop_Price_Source","EE_Est","Pos_Size_Qty","Pos_Size_Constraint",
// ── 익절 사다리·타임스탑 자동 계산 (TAKE_PROFIT_LADDER_V1) ──────────────
"TP1_Price","TP1_Qty","TP2_Price","TP2_Qty","Time_Stop_Date","Days_To_Time_Stop",
// ── 포지션 모니터링 ──────────────────────────────────────────────────────
"Weight_Pct","Profit_Pct","Unrealized_PnL","Stage2_Gate","Band_Status","Position_Count_Status",
// ── F1 기술적 타이밍 지표 ────────────────────────────────────────────────
"MA20_Slope","Disparity","RSI14","BB_Width","BB_Position","BB_Upper","BB_Lower",
// ── F2 진입 모드 게이트 ──────────────────────────────────────────────────
"Entry_Mode","Entry_Mode_Gate","Entry_Mode_Reason",
// ── F3 매도 타이밍 신호 ──────────────────────────────────────────────────
"Exit_Signal_Detail",
// ── F5 타이밍 종합 액션 ────────────────────────────────────────────────
"Timing_Score_Entry","Timing_Score_Exit","Timing_Action","Timing_Block_Reason",
// ── F6 매도 액션·수량·가격 ───────────────────────────────────────────
"Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Price_Source","Sell_Price_Basis",
"Sell_Execution_Window","Sell_Order_Type","Sell_Reason","Sell_Validation",
"Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason",
// ── F6A 계좌 캡처·주간 리밸런싱 검증 ────────────────────────────────
"Account_Holding_Qty","Account_Avg_Cost","Account_Market_Value","Account_Parse_Status",
"Rule_Sell_Qty","Rebalance_Target_Cash_Pct","Rebalance_Need_KRW","Override_Sell_Qty","Override_Reason","Override_Validation",
// ── F7 최종 룰엔진 액션·우선순위 ─────────────────────────────────────
"Final_Action","Action_Priority","Priority_Score","Final_Rank","Decision_Source",
// ── 수급·점수 자동 계산 ────────────────────────────────────────────────
"Flow_Credit","Trailing_Stop_Price",
"SS001_P","SS001_V","SS001_F","SS001_E","SS001_M","SS001_VAL","SS001_Total","SS001_Norm_Score","SS001_Grade",
"PEG","PEG_Gate",
// ── 돌파 파일럿 게이트 ─────────────────────────────────────────────────
"Breakout_Score","Breakout_Gate",
// ── anti_climax_buy_gate S1~S5 ────────────────────────────────────────
"AC_S1","AC_S2","AC_S3","AC_S4","AC_S5","AC_Total","AC_Gate",
// ── daily_leader_scan C1~C5 ────────────────────────────────────────────
"C1_Price","C2_RelStr","C3_VolSurge","C4_Flow","C5_Sector","Leader_Scan_Total","Leader_Gate",
// ── 상대약세 청산 신호 RW1~RW5 (RW1·RW3은 sector_flow 이력 기반) ─────
"RW1","RW2","RW3","RW4","RW5","RW_Partial",
// ── BRT_V1 + RS_VERDICT_V2 + COMPOSITE_VERDICT_V1 + SAQG/RAG ───────
"Stock_Drawdown_From_High_Pct","Excess_Drawdown_PctP","Recovery_Ratio_5D","Recovery_Ratio_20D",
"Downside_Beta","RS_Line_20D_Slope","RS_Line_60D_Slope","BRT_Verdict","BRT_Method",
"Excess_Ret_10D","RS_Verdict_V1_Raw","RS_Verdict","Composite_Verdict",
"SAQG_V1","SAQG_Penalty","SAQG_Failed_Filters","RAG_Verdict","RAG_Reason",
// ── 데이터 품질 ────────────────────────────────────────────────────────
"Missing_Fields","Next_Source_To_Check","Action_Reason","Action_Params","Allowed_Action",
// ── 포트폴리오 레벨 매도 우선순위 (sell_priority_engine) ──────────────
"Sell_Priority_Score"
];
const rows = [];
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
const savedEpsRevision = readExistingEpsRevision_("data_feed");
// 버킷 할당 누산기 (루프 종료 후 _bucketSnapshot_에 기록)
let _coreTotalPct = 0, _satTotalPct = 0;
// F4 trailing stop 갱신 대기열 초기화
_trailingStopUpdates_ = [];
// account_snapshot pre-read — 보유수량·평단·선택 stop/highest/stage 상태의 단일 원장
const positionStopMap_ = {}; // ticker → { stop_price, entry_price, quantity, entry_date, entry_stage, position_type, highest_price }
Object.keys(accountSnapshot_.positions).forEach(ticker => {
const snap = accountSnapshot_.positions[ticker];
positionStopMap_[ticker] = {
stop_price: Number.isFinite(snap.stop_price) && snap.stop_price > 0 ? snap.stop_price : null,
entry_price: Number.isFinite(snap.average_cost) && snap.average_cost > 0 ? snap.average_cost : null,
quantity: snap.quantity,
highest_price: Number.isFinite(snap.highest_price) && snap.highest_price > 0 ? snap.highest_price : null,
entry_date: snap.entry_date || snap.last_updated || null,
entry_stage: snap.entry_stage || null,
position_type: snap.position_type || "satellite",
account_quantity: snap.quantity,
account_average_cost: Number.isFinite(snap.average_cost) ? snap.average_cost : null,
account_market_value: Number.isFinite(snap.market_value) ? snap.market_value : null,
account_parse_status: snap.parse_status,
account_user_confirmed: snap.user_confirmed,
account: snap.account || "",
};
});
// WBS-1.2: total_asset_krw 실시간 재계산 (2-pass differential: HTS 기준 + Naver 가격 델타)
// 구 방식(D2현금 + Naver 주가 합산)은 ISA·연금저축·CMA ~10M을 누락해 과소계상됨.
// 수정: HTS 캡처 총액을 기준으로 개별주 Naver-HTS 가격 차이(delta)만 반영한다.
if (Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0) {
let priceUpdateKrw = 0, updateCount = 0;
for (const ticker of Object.keys(positionStopMap_)) {
const priceMetrics = resolveDataFeedPriceMetrics(ticker);
const pos = positionStopMap_[ticker];
const qty = pos.quantity;
const htsMv = pos.account_market_value;
if (priceMetrics.ok && Number.isFinite(priceMetrics.close) && Number.isFinite(qty)
&& Number.isFinite(htsMv) && htsMv > 0) {
priceUpdateKrw += (priceMetrics.close * qty) - htsMv;
updateCount++;
}
}
if (updateCount > 0) {
const liveTotal = totalAssetKrw_ + priceUpdateKrw;
if (liveTotal > 0) {
totalAssetKrw_ = liveTotal;
Logger.log(`[WBS-1.2] total_asset_krw 재계산 완료: ${totalAssetKrw_} KRW (delta: ${priceUpdateKrw}, ${updateCount}종목 반영)`);
}
}
}
// Total_Heat 사전 계산 — HF005(≥10% 매수 차단) + caution(7~10% 수량 감액)에 사용
// positionStopMap_ 완성 후 즉시 계산. ATR 추정 폴백: entry_price × 8% (보수적)
let globalHeatPct_ = null; // null = 계산 불가, number = heat%
if (Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0) {
let heatKrw = 0;
for (const [, pos] of Object.entries(positionStopMap_)) {
const qty = pos.quantity;
const ep = pos.entry_price;
if (!Number.isFinite(qty) || qty <= 0 || !Number.isFinite(ep) || ep <= 0) continue;
const sp = Number.isFinite(pos.stop_price) && pos.stop_price > 0 && pos.stop_price < ep
? pos.stop_price : ep * 0.92; // spec: 미설정 시 8% 고정(보수적 추정)
heatKrw += (ep - sp) * qty;
}
globalHeatPct_ = parseFloat((heatKrw / totalAssetKrw_ * 100).toFixed(2));
Logger.log(`Total_Heat pre-calc: ${globalHeatPct_}% (${Object.keys(positionStopMap_).length}개 포지션)`);
}
// ── 종목 수 집계 → PCL 상태 산출 (2026-05-18_POSITION_STRATEGY_V1) ──────────
// positionStopMap_ = 일반계좌 개별주 (stop_price·Sell_Qty·Total_Heat 계산용)
// accountSnapshot_.isa_positions = ISA 개별주 (PCL 카운트 전용)
// 연금저축 = ETF 전용, 카운트 제외
// 일반계좌 하드 상한: 8종목(ROTATE_REQUIRED), 경보: 7종목(CAUTION)
// ISA 하드 상한: 4종목(ROTATE_REQUIRED), 경보: 3종목(CAUTION)
const _taxCore_ = Object.values(positionStopMap_).filter(p => p.position_type === "core").length;
const _taxSat_ = Object.values(positionStopMap_).filter(p => p.position_type !== "core").length;
const _taxTotal_ = Object.keys(positionStopMap_).length;
const _isaTotal_ = Object.keys(accountSnapshot_.isa_positions ?? {}).length;
const _taxStatus_ = _taxTotal_ >= 8 ? "ROTATE_REQUIRED"
: _taxTotal_ === 7 ? "CAUTION"
: "PASS";
const _isaStatus_ = _isaTotal_ >= 4 ? "ROTATE_REQUIRED"
: _isaTotal_ === 3 ? "CAUTION"
: "PASS";
const positionCountStatus_ =
`일반계좌:${_taxStatus_}(코어${_taxCore_}/위성${_taxSat_}/계${_taxTotal_}) | ISA:${_isaStatus_}(계${_isaTotal_})`;
// macro 탭 pre-read — KOSPI Ret5/10/20/60D(BRT/RS용) + REGIME_PRELIM(SS001_M용)
let globalKospiRet5D_ = null;
let globalKospiRet10D_ = null;
let globalKospiRet20D_ = null;
let globalKospiRet60D_ = null;
let globalKospiDrawdown_ = null;
let globalRegimePrelim_ = null;
try {
const macroSheet = getSpreadsheet_().getSheetByName("macro");
if (macroSheet) {
const mData = macroSheet.getDataRange().getValues();
let headerRowIdx = 0;
for (let r = 0; r < Math.min(5, mData.length); r++) {
const row = mData[r] ?? [];
if (row.indexOf("Symbol") >= 0 && row.indexOf("Name") >= 0) {
headerRowIdx = r;
break;
}
}
const mHdr = mData[headerRowIdx] ?? [];
const symIdx = mHdr.indexOf("Symbol");
const nameIdx = mHdr.indexOf("Name");
const closeIdx = mHdr.indexOf("Close");
const ma60Idx = mHdr.indexOf("MA60");
const ret5DIdx = mHdr.indexOf("Ret5D");
const ret10DIdx = mHdr.indexOf("Ret10D");
const ret20DIdx = mHdr.indexOf("Ret20D");
const ret60DIdx = mHdr.indexOf("Ret60D");
for (let i = headerRowIdx + 1; i < mData.length; i++) {
const sym = symIdx >= 0 ? String(mData[i][symIdx]).trim() : "";
const name = nameIdx >= 0 ? String(mData[i][nameIdx]).trim() : "";
if (name === "KOSPI") {
const r5 = ret5DIdx >= 0 ? parseFloat(mData[i][ret5DIdx]) : NaN;
const r10 = ret10DIdx >= 0 ? parseFloat(mData[i][ret10DIdx]) : NaN;
const r20 = ret20DIdx >= 0 ? parseFloat(mData[i][ret20DIdx]) : NaN;
const r60 = ret60DIdx >= 0 ? parseFloat(mData[i][ret60DIdx]) : NaN;
const close = closeIdx >= 0 ? parseFloat(mData[i][closeIdx]) : NaN;
const ma60 = ma60Idx >= 0 ? parseFloat(mData[i][ma60Idx]) : NaN;
if (Number.isFinite(r5)) globalKospiRet5D_ = r5;
if (Number.isFinite(r10)) globalKospiRet10D_ = r10;
if (Number.isFinite(r20)) globalKospiRet20D_ = r20;
if (Number.isFinite(r60)) globalKospiRet60D_ = r60;
if (Number.isFinite(close) && Number.isFinite(ma60) && ma60 > 0) {
globalKospiDrawdown_ = Math.max(0, parseFloat(((1 - close / Math.max(close, ma60)) * 100).toFixed(2)));
} else if (Number.isFinite(r60) && r60 < 0) {
globalKospiDrawdown_ = Math.abs(r60);
}
}
if (sym === "REGIME_PRELIM" && closeIdx >= 0) {
const rv = String(mData[i][closeIdx]).trim();
if (rv) globalRegimePrelim_ = rv;
}
if (globalKospiRet10D_ !== null && globalKospiRet20D_ !== null && globalRegimePrelim_ !== null) break;
}
}
} catch(e) { handleFetchError_("runDataFeed:macro pre-read", e, "CRITICAL"); }
// sector_flow 전회 실행 결과 통합 pre-read: rank, ETF수익률, 수급, RW1/RW3, 섹터 밸류에이션
const sectorFlowData_ = {}; // sector_name → { rank, etfRet10D, smart5, smart20, rw1, rw3, medianPE, medianPBR }
try {
const sfSheet = getSpreadsheet_().getSheetByName("sector_flow");
if (sfSheet) {
const sfData = sfSheet.getDataRange().getValues();
const sfHdr = sfData[1] ?? [];
const sNameIdx = sfHdr.indexOf("Sector");
const rankIdx = sfHdr.indexOf("Sector_Rank") >= 0 ? sfHdr.indexOf("Sector_Rank") : sfHdr.indexOf("Rotation_Rank");
const scoreIdx = sfHdr.indexOf("Sector_Score") >= 0 ? sfHdr.indexOf("Sector_Score") : sfHdr.indexOf("Rotation_Score");
const etfR10Idx = sfHdr.indexOf("Sector_Ret10D") >= 0 ? sfHdr.indexOf("Sector_Ret10D") :
(sfHdr.indexOf("ETF_Ret10D") >= 0 ? sfHdr.indexOf("ETF_Ret10D") : sfHdr.indexOf("Sector_Ret20D"));
const smart5Idx = sfHdr.indexOf("SmartMoney_5D_KRW") >= 0 ? sfHdr.indexOf("SmartMoney_5D_KRW") : sfHdr.indexOf("Frg_5D_SUM");
const smart20Idx= sfHdr.indexOf("SmartMoney_20D_KRW") >= 0 ? sfHdr.indexOf("SmartMoney_20D_KRW") : sfHdr.indexOf("Frg_20D_SUM");
const rw1Idx = sfHdr.indexOf("RW1");
const rw3Idx = sfHdr.indexOf("RW3");
const medPeIdx = sfHdr.indexOf("Sector_Median_PE");
const medPbrIdx = sfHdr.indexOf("Sector_Median_PBR");
if (sNameIdx >= 0) {
for (let i = 2; i < sfData.length; i++) {
const sName = String(sfData[i][sNameIdx]).trim();
if (!sName || sName === "Sector") continue;
const rank = rankIdx >= 0 ? parseInt(sfData[i][rankIdx]) : null;
sectorFlowData_[sName] = {
rank: Number.isFinite(rank) ? rank : null,
score: scoreIdx >= 0 ? parseFloat(sfData[i][scoreIdx]) : null,
etfRet10D: etfR10Idx >= 0 ? parseFloat(sfData[i][etfR10Idx]) : null,
smart5: smart5Idx >= 0 ? parseFloat(sfData[i][smart5Idx]) : null,
smart20: smart20Idx >= 0 ? parseFloat(sfData[i][smart20Idx]) : null,
rw1: rw1Idx >= 0 ? parseInt(sfData[i][rw1Idx]) : 0,
rw3: rw3Idx >= 0 ? parseInt(sfData[i][rw3Idx]) : 0,
medianPE: medPeIdx >= 0 ? parseFloat(sfData[i][medPeIdx]) : null,
medianPBR: medPbrIdx >= 0 ? parseFloat(sfData[i][medPbrIdx]) : null,
};
}
}
}
} catch(e) { handleFetchError_("runDataFeed:sector_flow pre-read", e, "CRITICAL"); }
// core_satellite 탭 pre-read — RS_Pct_20D → SS001_P 상대강도 점수용
const csRsPctMap_ = {}; // ticker → RS_Pct_20D (0~100)
try {
const csSheet = getSpreadsheet_().getSheetByName("core_satellite");
if (csSheet) {
const csData = csSheet.getDataRange().getValues();
const csHdr = csData[1] ?? [];
const csTkIdx = csHdr.indexOf("Ticker");
const csRsPctIdx = csHdr.indexOf("RS_Pct_20D");
if (csTkIdx >= 0 && csRsPctIdx >= 0) {
for (let i = 2; i < csData.length; i++) {
const tk = String(csData[i][csTkIdx]).trim();
if (!tk) continue;
const rsPct = parseFloat(csData[i][csRsPctIdx]);
if (Number.isFinite(rsPct)) csRsPctMap_[tk] = rsPct;
}
}
}
} catch(e) { handleFetchError_("runDataFeed:core_satellite pre-read", e, "CRITICAL"); }
const preReads = {
positionStopMap_, globalHeatPct_,
globalKospiRet5D_, globalKospiRet10D_, globalKospiRet20D_, globalKospiRet60D_, globalKospiDrawdown_,
globalRegimePrelim_,
sectorFlowData_, csRsPctMap_, riskBudget_, totalAssetKrw_,
weeklyTargetCashPct_, bayesian, savedEpsRevision, today,
positionCountStatus_,
};
const activeTickers_ = (typeof getActiveTickers_ === "function") ? getActiveTickers_() : TICKERS;
for (const t of activeTickers_) {
const result = buildTickerRowV2_(t, preReads, _trailingStopUpdates_);
rows.push(result.row);
_coreTotalPct += result.corePctDelta;
_satTotalPct += result.satPctDelta;
Utilities.sleep(400);
}
// ── 섹터별 총노출 집계 (duplicate_exposure_rule 판별 + Sell_Priority_Score 입력) ──
// spec: spec/risk/portfolio_exposure.yaml:duplicate_exposure_rule
const _sectorExpMap_ = {};
{
const wIdx_ = headers.indexOf("Weight_Pct");
const tIdx_ = headers.indexOf("Ticker");
rows.forEach(row => {
const tk_ = String(row[tIdx_] ?? "");
const w_ = parseFloat(row[wIdx_]);
if (!tk_ || !Number.isFinite(w_) || w_ <= 0) return;
const sec_ = TICKER_SECTOR_MAP[tk_] ?? "";
if (sec_) _sectorExpMap_[sec_] = (_sectorExpMap_[sec_] || 0) + w_;
});
}
// ── Sell_Priority_Score 일괄 계산 (post-loop, 섹터집계 완료 후) ───────────
// spec: spec/risk/portfolio_exposure.yaml:sell_priority_engine.candidate_scoring
{
const spIdx_ = headers.indexOf("Sell_Priority_Score");
if (spIdx_ >= 0) {
rows.forEach(row => {
const res_ = calcSellPriorityScore_(row, headers, _sectorExpMap_);
row[spIdx_] = res_.score;
});
}
}
const targetCashPctIdx = headers.indexOf("Rebalance_Target_Cash_Pct");
const needKrwIdx = headers.indexOf("Rebalance_Need_KRW");
const overrideQtyIdx = headers.indexOf("Override_Sell_Qty");
const overrideReasonIdx = headers.indexOf("Override_Reason");
const overrideValidationIdx = headers.indexOf("Override_Validation");
const sellActionIdx = headers.indexOf("Sell_Action");
const sellValidationIdx = headers.indexOf("Sell_Validation");
const sellLimitIdx = headers.indexOf("Sell_Limit_Price");
const ruleSellQtyIdx = headers.indexOf("Rule_Sell_Qty");
const accountQtyIdx = headers.indexOf("Account_Holding_Qty");
const finalActionIdx = headers.indexOf("Final_Action");
const priorityScoreForRebalIdx = headers.indexOf("Priority_Score");
if (
Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0 &&
Number.isFinite(settlementCashD2_) &&
Number.isFinite(weeklyTargetCashPct_) && weeklyTargetCashPct_ > 0 &&
targetCashPctIdx >= 0 && needKrwIdx >= 0 && overrideQtyIdx >= 0 &&
overrideReasonIdx >= 0 && overrideValidationIdx >= 0
) {
const targetCashKrw = totalAssetKrw_ * weeklyTargetCashPct_ / 100;
const needKrw = Math.max(0, targetCashKrw - settlementCashD2_);
rows.forEach(row => {
row[targetCashPctIdx] = weeklyTargetCashPct_;
row[needKrwIdx] = Math.round(needKrw);
row[overrideValidationIdx] = needKrw > 0 ? "NOT_SELECTED" : "NO_REBALANCE_NEEDED";
});
if (needKrw > 0) {
// ── 리밸런스 후보 확장 (sell_priority_engine 3단계 풀) ──────────────────
// spec: portfolio_exposure.yaml:sell_priority_engine.hard_precedence
// Tier 1: 기존 SELL_READY(매도신호 확정) → Tier 2: ETF(중복노출) → Tier 3: 손실위성(-10%↓)
// 코어주도주(삼성전자·SK하이닉스)는 hard_stop 없으면 풀 제외 (spec:prohibition)
const nameIdx_ = headers.indexOf("Name");
const profitIdx_ = headers.indexOf("Profit_Pct");
const tickerIdx_ = headers.indexOf("Ticker");
const spScoreIdx_ = headers.indexOf("Sell_Priority_Score");
let remaining = needKrw;
rows
.map((row, idx) => ({ row, idx }))
.filter(item => {
const row = item.row;
const finalAction = String(row[finalActionIdx] ?? "");
const sellAction = String(row[sellActionIdx] ?? "");
const sellVal = String(row[sellValidationIdx] ?? "");
const name__ = String(row[nameIdx_] ?? "");
const ticker__ = String(row[tickerIdx_] ?? "");
const profitPct__ = parseFloat(row[profitIdx_]);
const isEtf__ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(name__);
const isCL__ = (ticker__ === "005930" || ticker__ === "000660");
// hard_stop — core leader도 포함
if (finalAction === "EXIT_SIGNAL" || sellAction === "EXIT_100") return true;
// Tier 1: SELL_READY (기존 로직)
if (sellAction && sellAction !== "HOLD" &&
sellVal === "SIGNAL_CONFIRMED" && finalAction === "SELL_READY") return true;
// Tier 2: ETF 중복노출 (코어리더 ETF 없으므로 isCL__ 체크 불필요)
if (isEtf__) return true;
// Tier 3: 손실 위성 -10% 이하, 코어리더 제외
if (!isEtf__ && !isCL__ &&
Number.isFinite(profitPct__) && profitPct__ <= -10) return true;
return false;
})
// Sell_Priority_Score 내림차순 → 점수 높을수록 먼저 현금 확보 대상
.sort((a, b) => {
const as_ = parseFloat(a.row[spScoreIdx_]) || 0;
const bs_ = parseFloat(b.row[spScoreIdx_]) || 0;
return bs_ - as_;
})
.forEach(item => {
if (remaining <= 0) return;
const row = item.row;
const price = parseFloat(row[sellLimitIdx]);
const name__= String(row[nameIdx_] ?? "");
const isEtf__ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(name__);
const finalAction__ = String(row[finalActionIdx] ?? "");
const tier__ =
finalAction__ === "EXIT_SIGNAL" ? "①하드스탑" :
finalAction__ === "SELL_READY" ? "②매도신호" :
isEtf__ ? "③중복ETF" : "④손실위성";
// 방향 A: 수량 없음 — Override_Sell_Qty는 캡처 후 수동 계산
if (!Number.isFinite(price) || price <= 0) {
row[overrideValidationIdx] = "NO_PRICE";
return;
}
row[overrideReasonIdx] = `[${tier__}] D+2 현금 ${weeklyTargetCashPct_}% 회복 — 수량 캡처 후 확인`;
row[overrideValidationIdx] = "SIGNAL_ONLY_USER_CONFIRM";
});
}
}
const priorityIdx = headers.indexOf("Action_Priority");
const scoreIdx = headers.indexOf("Priority_Score");
const rankIdx = headers.indexOf("Final_Rank");
if (priorityIdx >= 0 && scoreIdx >= 0 && rankIdx >= 0) {
rows
.map((row, idx) => ({ row, idx }))
.sort((a, b) => {
const ap = parseFloat(a.row[priorityIdx]);
const bp = parseFloat(b.row[priorityIdx]);
if (ap !== bp) return ap - bp;
const as = parseFloat(a.row[scoreIdx]);
const bs = parseFloat(b.row[scoreIdx]);
if (as !== bs) return bs - as;
return a.idx - b.idx;
})
.forEach((item, rank) => { item.row[rankIdx] = rank + 1; });
}
// ── Fetch 품질 진단 집계 (runDataFeed 완료 직전) ──────────────────────────
// Price_Status / DART_Status 기반으로 STALE·MISSING 비율 집계 후 Logger 출력.
// STALE 비율 > 50%이면 다음 실행 시 캐시 전체 강제 갱신을 위해 경고를 settings에 기록.
{
const psIdx_ = headers.indexOf("Price_Status");
const dsIdx_ = headers.indexOf("DART_Status");
let priceOk_ = 0, priceStale_ = 0, priceMissing_ = 0;
rows.forEach(row => {
const ps = String(row[psIdx_] ?? "");
if (ps === "PRICE_OK") priceOk_++;
else if (ps === "PRICE_STALE") priceStale_++;
else priceMissing_++;
});
const stalePct_ = rows.length > 0 ? Math.round(priceStale_ / rows.length * 100) : 0;
Logger.log(
`[FETCH_DIAG] 총 ${rows.length}종목 | PRICE_OK=${priceOk_} PRICE_STALE=${priceStale_}(${stalePct_}%) MISSING=${priceMissing_}`
);
// STALE 과반수(>50%) — 다음 세션에서 캐시 전체 재수집 경고
if (stalePct_ > 50) {
upsertOperationalWarningSetting_(
"data_freshness_warning",
`[STALE_MAJORITY] price_stale=${stalePct_}% — 다음 runDataFeed 전 clearFetchCache() 실행 권장`
);
Logger.log("[FETCH_DIAG][WARN] STALE 과반수(" + stalePct_ + "%) — clearFetchCache() 자동 호출");
try { clearFetchCache(); } catch (_) {}
} else {
upsertOperationalWarningSetting_("data_freshness_warning", "");
}
}
writeToSheet("data_feed", headers, rows);
Logger.log(`data_feed 완료: ${rows.length}종목`);
// 버킷 스냅샷 저장 (runMacro → BUCKET_STATUS 행에 사용)
_bucketSnapshot_ = {
core_pct: parseFloat(_coreTotalPct.toFixed(2)),
satellite_pct: parseFloat(_satTotalPct.toFixed(2)),
ts: today,
};
// F4: account_snapshot trailing stop 일괄 갱신
applyTrailingStopUpdates_();
// [WBS-3.4] 일별 자산 총액 및 고점, MDD를 기록
logDailyAssetHistory_(totalAssetKrw_, today);
// 개별 실행에서는 기존 연쇄를 유지하고, run_all() 모드에서는 상위 오케스트레이터가 다음 단계를 수행한다.
if (!isRunAllOrchestrated_()) {
runSectorFlow();
}
}
/**
* [WBS-3.4] 일별 자산 총액 및 고점, MDD를 기록한다.
*/
function logDailyAssetHistory_(totalAsset, todayStr) {
try {
if (!Number.isFinite(totalAsset) || totalAsset <= 0) {
Logger.log("[MDD_GUARD] totalAsset이 유효하지 않아 일별 기록을 건너뜁니다.");
return;
}
// daily_history 시트 획득 또는 생성
var ss = getSpreadsheet_();
var sheet = ss.getSheetByName("daily_history");
if (!sheet) {
sheet = ss.insertSheet("daily_history");
// 헤더 작성
sheet.appendRow(["Date", "Total_Asset_KRW", "Peak_Asset_KRW", "MDD_Pct"]);
Logger.log("[MDD_GUARD] daily_history 시트를 신규 생성하고 헤더를 작성했습니다.");
}
// 기존 데이터 읽기
var data = sheet.getDataRange().getValues();
// 오늘 날짜가 이미 존재하는지 체크 (중복 기록 방지)
var todayIndex = -1;
for (var i = 1; i < data.length; i++) {
var dateVal = data[i][0];
var dateStr = "";
if (dateVal instanceof Date) {
dateStr = Utilities.formatDate(dateVal, "Asia/Seoul", "yyyy-MM-dd");
} else {
dateStr = String(dateVal).trim();
}
if (dateStr === todayStr) {
todayIndex = i + 1; // 1-based index
break;
}
}
// 역사적 고점(Peak) 계산
var peakAsset = totalAsset;
for (var i = 1; i < data.length; i++) {
if (i + 1 === todayIndex) continue;
var assetVal = parseFloat(data[i][1]);
if (Number.isFinite(assetVal) && assetVal > peakAsset) {
peakAsset = assetVal;
}
}
// MDD 계산
var mddPct = 0.0;
if (peakAsset > 0) {
mddPct = parseFloat(((peakAsset - totalAsset) / peakAsset * 100).toFixed(2));
}
if (todayIndex > 0) {
// 이미 오늘 날짜가 있으면 해당 행 업데이트
sheet.getRange(todayIndex, 1, 1, 4).setValues([[todayStr, totalAsset, peakAsset, mddPct]]);
Logger.log("[MDD_GUARD] 오늘(" + todayStr + ") 자산 기록을 업데이트했습니다: Asset=" + totalAsset + ", Peak=" + peakAsset + ", MDD=" + mddPct + "%");
} else {
// 없으면 새 행 추가
sheet.appendRow([todayStr, totalAsset, peakAsset, mddPct]);
Logger.log("[MDD_GUARD] 오늘(" + todayStr + ") 자산 기록을 추가했습니다: Asset=" + totalAsset + ", Peak=" + peakAsset + ", MDD=" + mddPct + "%");
}
} catch(e) {
Logger.log("[MDD_GUARD] daily_history 기록 실패: " + e.message);
}
}
// --- Source: src/gas_adapter_parts/gdc_02_account_satellite.gs ---
function ensureAccountSnapshotConfirmModeSetting_(settingsObj) {
try {
const settings = settingsObj || {};
const raw = String(settings["account_snapshot_confirm_mode"] || "").trim();
if (raw) return;
const ss = getSpreadsheet_();
const sh = ss.getSheetByName("settings");
if (!sh) return;
sh.appendRow(["account_snapshot_confirm_mode", "STRICT_Y", "STRICT_Y|AUTO_IF_PARSE_OK"]);
settings["account_snapshot_confirm_mode"] = "STRICT_Y";
Logger.log("[SETTINGS_DEFAULT] account_snapshot_confirm_mode=STRICT_Y");
} catch (e) {
Logger.log("[SETTINGS_DEFAULT][WARN] account_snapshot_confirm_mode 주입 실패: " + e.message);
}
}
function upsertOperationalWarningSetting_(key, value) {
try {
const ss = getSpreadsheet_();
const sh = ss.getSheetByName("settings");
if (!sh) return;
const data = sh.getDataRange().getValues();
for (let i = 0; i < data.length; i++) {
if (String(data[i][0] || "").trim() === key) {
sh.getRange(i + 1, 2).setValue(value);
return;
}
}
sh.appendRow([key, value, "auto-generated operational warning"]);
} catch (e) {
Logger.log("[SETTINGS_WARNING][WARN] " + key + " 갱신 실패: " + e.message);
}
}
// ── buildTickerRow_ sub-functions ──────────────────────────────────────────
function _tickerSetup_(t, preReads) {
const {
positionStopMap_, globalHeatPct_, globalKospiRet10D_, globalRegimePrelim_,
sectorFlowData_, csRsPctMap_, riskBudget_, totalAssetKrw_,
weeklyTargetCashPct_, bayesian, savedEpsRevision, today,
positionCountStatus_,
} = preReads;
const isRiskOffRegime = globalRegimePrelim_ === "RISK_OFF" || globalRegimePrelim_ === "RISK_OFF_CANDIDATE";
const heatBlock = Number.isFinite(globalHeatPct_) && globalHeatPct_ >= 10;
const heatCaution = Number.isFinite(globalHeatPct_) && globalHeatPct_ >= 7 && globalHeatPct_ < 10;
const flow = fetchNaverFlow(t.code);
const price = resolveDataFeedPriceMetrics(t.code);
const valuation = fetchNaverMarketMetrics(t.code);
const consensus = fetchNaverConsensusData(t.code);
const notices = fetchNaverDisclosureNotices(t.code);
const dartSummary = summarizeDisclosureNotices(notices);
const frg5 = flow.rows.slice(0,5).reduce((s,r) => s+r.frgn, 0);
const inst5 = flow.rows.slice(0,5).reduce((s,r) => s+r.inst, 0);
const frg20 = flow.rows.reduce((s,r) => s+r.frgn, 0);
const inst20 = flow.rows.reduce((s,r) => s+r.inst, 0);
const indiv5 = -(frg5+inst5);
// priceStatus 4단계
const priceStatus = !price.ok ? "PRICE_MISSING" :
price.isFallbackQuote ? "PRICE_QUOTE_ONLY" :
price.isPriceStale ? "PRICE_STALE" : "PRICE_OK";
const flow5Status = flow.ok ? `OK: ${frg5 > 0 ? "외국인 매수" : "외국인 매도"} / ${inst5 > 0 ? "기관 매수" : "기관 매도"}` : "DATA_MISSING";
const flow20Status = flow.ok ? "OK" : "DATA_MISSING";
const ind5Status = flow.ok ? "OK" : "DATA_MISSING";
const valSurgeStatus = calcValSurgeStatus(price.valSurge);
const liquidityStatus = calcLiquidityStatus(Number(price.avgTradingValue5D));
const spreadStatus = calcSpreadStatus(Number(price.spreadPct));
const missing = [];
if (!flow.ok) missing.push("Flow5D/Flow20D");
if (flow.ok && flow.isFlowStale) missing.push(`FLOW_STALE(${flow.rows[0]?.date ?? "?"})`);
if (priceStatus === "PRICE_MISSING") missing.push("ATR20/Val_Surge");
if (priceStatus === "PRICE_QUOTE_ONLY") missing.push("PRICE_QUOTE_ONLY:MA/ATR결측");
if (priceStatus === "PRICE_STALE") missing.push(`PRICE_STALE(${price.priceDate})`);
if (dartSummary.status === "NAVER_NOTICE_EMPTY" || String(dartSummary.status).startsWith("NAVER_NOTICE_ERROR")) missing.push("DART");
if (heatBlock) missing.push(`HF005:HEAT_BLOCK(${globalHeatPct_}%)`);
if (heatCaution) missing.push(`HEAT_CAUTION(${globalHeatPct_}%→수량50%감액)`);
if (isRiskOffRegime) missing.push(`REGIME_BLOCK(${globalRegimePrelim_})`);
if (globalHeatPct_ === null) missing.push("TOTAL_HEAT_UNKNOWN");
const next = [];
if (priceStatus === "PRICE_MISSING" || priceStatus === "PRICE_QUOTE_ONLY") next.push("Yahoo Finance chart");
if (missing.includes("DART")) next.push("Naver 공시공지");
if (missing.includes("Flow5D/Flow20D")) next.push("Naver frgn.naver");
const perfBias = calcPerformanceBuyBias_(bayesian);
const posRec = positionStopMap_[t.code];
return {
t, preReads,
flow, price, valuation, consensus, dartSummary,
frg5, inst5, frg20, inst20, indiv5,
priceStatus, flow5Status, flow20Status, ind5Status, valSurgeStatus, liquidityStatus, spreadStatus,
missing, next, isRiskOffRegime, heatBlock, heatCaution, perfBias, posRec,
today, positionCountStatus_, weeklyTargetCashPct_,
};
}
// ── Fundamentals: EPS, 52W, target price, dividends, financial health ────────
function _addTickerFundamentals_(ctx) {
const { t, price, valuation, consensus, preReads } = ctx;
const { savedEpsRevision, today } = preReads;
// ── EPS_Revision_Status: Naver 우선, Yahoo 폴백, 기존값 최후 보존 ──────
let epsRevisionStatus = "";
if (consensus.ok && consensus.epsRevisionStatus !== "DATA_MISSING") {
epsRevisionStatus = consensus.epsRevisionStatus;
} else {
const yahooConsensus = fetchYahooConsensusEps(t.code);
if (yahooConsensus.ok && yahooConsensus.epsRevisionStatus !== "DATA_MISSING") {
epsRevisionStatus = yahooConsensus.epsRevisionStatus;
} else {
epsRevisionStatus = savedEpsRevision[t.code] ?? "";
}
}
// ── 배당수익률: Naver main(_dvr) 우선, Yahoo quote 폴백 ──────────────
const naverDvr = Number.isFinite(valuation.dvr) ? valuation.dvr : null;
const yahooQuote = fetchYahooMarketMetrics(t.code);
const divYield = naverDvr ?? (Number.isFinite(yahooQuote.divYield) ? yahooQuote.divYield : "");
// ── Beta: Yahoo quote 우선, quoteSummary 폴백 ─────────────────────────
let beta = Number.isFinite(yahooQuote.beta) ? yahooQuote.beta : null;
// ── 52주 고저가: Naver main 우선, Yahoo quote 폴백 ────────────────────
const high52W = Number.isFinite(valuation.high52W) ? valuation.high52W
: Number.isFinite(yahooQuote.high52W) ? yahooQuote.high52W : null;
const low52W = Number.isFinite(valuation.low52W) ? valuation.low52W
: Number.isFinite(yahooQuote.low52W) ? yahooQuote.low52W : null;
const closeVal = price.ok && Number.isFinite(price.close) ? price.close : null;
const pct52WHigh = Number.isFinite(high52W) && Number.isFinite(closeVal) && high52W > 0
? ((closeVal / high52W - 1) * 100).toFixed(1) : "";
const pctFrom52WLow = Number.isFinite(low52W) && Number.isFinite(closeVal) && low52W > 0
? ((closeVal / low52W - 1) * 100).toFixed(1) : "";
// ── 목표주가: Naver consensus 우선, Yahoo quoteSummary 폴백 ──────────
let targetPrice = Number.isFinite(consensus.targetPrice) && consensus.targetPrice > 0
? consensus.targetPrice : null;
const yahooFin = fetchYahooTargetPrice(t.code);
if (!targetPrice && yahooFin.ok && Number.isFinite(yahooFin.targetPrice) && yahooFin.targetPrice > 0) {
targetPrice = yahooFin.targetPrice;
}
if (!beta && Number.isFinite(yahooFin.beta)) beta = yahooFin.beta;
const upsidePct = Number.isFinite(targetPrice) && Number.isFinite(closeVal) && closeVal > 0
? ((targetPrice / closeVal - 1) * 100).toFixed(1) : "";
// ── EPS 1년 성장률 ────────────────────────────────────────────────────
const epsGrowth1y = Number.isFinite(consensus.epsGrowth1y) ? consensus.epsGrowth1y : null;
// ── DPS ───────────────────────────────────────────────────────────────
const dps = Number.isFinite(yahooFin.dividendPerShare) ? yahooFin.dividendPerShare : null;
// ── 재무 건전성 7개 필드 (FINANCIAL_HEALTH_V1 + OCF_B + 7일 캐시 통합) ─────
// ETF는 개별 재무제표가 없으므로 수집 스킵
const isEtfTicker_ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(t.name ?? "");
let fundResult;
if (isEtfTicker_) {
Logger.log('[INFO][FUND_SKIP_ETF] ' + t.code + ' (' + (t.name ?? '') + ') — ETF, 펀더멘털 수집 불필요');
fundResult = { ok: false, source: 'etf_no_fundamentals' };
} else {
fundResult = (typeof fetchFundamentalsWithCache_ === 'function')
? fetchFundamentalsWithCache_(t.code, t.code, yahooFin)
: yahooFin;
}
const roePct = fundResult.ok && Number.isFinite(fundResult.roePct) ? fundResult.roePct : null;
const opMarginPct = fundResult.ok && Number.isFinite(fundResult.operatingMarginPct) ? fundResult.operatingMarginPct : null;
const debtToEquity = fundResult.ok && Number.isFinite(fundResult.debtToEquity) ? fundResult.debtToEquity : null;
const currentRatio = fundResult.ok && Number.isFinite(fundResult.currentRatio) ? fundResult.currentRatio : null;
const fcfB = fundResult.ok && Number.isFinite(fundResult.fcfB) ? fundResult.fcfB : null;
const ocfB = fundResult.ok && Number.isFinite(fundResult.ocfB) ? fundResult.ocfB : null;
const revenueGrowthPct = fundResult.ok && Number.isFinite(fundResult.revenueGrowthPct) ? fundResult.revenueGrowthPct : null;
// ── 실적 발표일 → 잔여 일수 ───────────────────────────────────────────
const earningsDateStr = yahooFin?.earningsDate ?? null;
const tp = today.split("-").map(Number);
const todayMs = Date.UTC(tp[0], tp[1] - 1, tp[2]);
let daysToEarnings = "";
if (earningsDateStr) {
const ep = earningsDateStr.split("-").map(Number);
daysToEarnings = Math.round((Date.UTC(ep[0], ep[1]-1, ep[2]) - todayMs) / (1000*60*60*24));
}
// ── 배당락일 → 잔여 일수 (A4) ──────────────────────────────────────────
const exDividendDateStr = yahooFin?.exDividendDate ?? null;
let daysToExDiv = "";
if (exDividendDateStr) {
const xp = exDividendDateStr.split("-").map(Number);
daysToExDiv = Math.round((Date.UTC(xp[0], xp[1]-1, xp[2]) - todayMs) / (1000*60*60*24));
}
Object.assign(ctx, {
epsRevisionStatus, epsGrowth1y, divYield, dps, beta,
high52W, low52W, pct52WHigh, pctFrom52WLow,
targetPrice, upsidePct, earningsDateStr, daysToEarnings,
exDividendDateStr, daysToExDiv,
roePct, opMarginPct, debtToEquity, currentRatio, fcfB, ocfB, revenueGrowthPct,
});
}
// ── [2026-05-21_BRT_HARNESS_V1] BRT/SAQG helpers ─────────────────────────
function calcBenchmarkRelativeTimeseries_(price, high52W, preReads) {
const k5 = preReads.globalKospiRet5D_;
const k20 = preReads.globalKospiRet20D_;
const k60 = preReads.globalKospiRet60D_;
const kDrawdown = preReads.globalKospiDrawdown_;
const close = price && price.ok && Number.isFinite(price.close) ? price.close : null;
const stockDrawdown = Number.isFinite(high52W) && Number.isFinite(close) && high52W > 0
? parseFloat((Math.max(0, (1 - close / high52W) * 100)).toFixed(2)) : null;
const excessDrawdown = stockDrawdown !== null && Number.isFinite(kDrawdown)
? parseFloat((stockDrawdown - kDrawdown).toFixed(2)) : null;
const ret5 = price && Number.isFinite(price.ret5D) ? price.ret5D : null;
const ret20 = price && Number.isFinite(price.ret20D) ? price.ret20D : null;
const ret60 = price && Number.isFinite(price.ret60D) ? price.ret60D : null;
const rec5 = ret5 !== null && Number.isFinite(k5) && k5 > 0 ? parseFloat((ret5 / k5).toFixed(3)) : null;
const rec20 = ret20 !== null && Number.isFinite(k20) && k20 > 0 ? parseFloat((ret20 / k20).toFixed(3)) : null;
const downsideBeta = ret20 !== null && Number.isFinite(k20) && k20 < 0 ? parseFloat((ret20 / k20).toFixed(3)) : null;
// [C-2] RS ratio slopes: change in RS ratio per day across windows
// slope = (rsRatio_longWindow - rsRatio_shortWindow) / daysBetween
// Positive = relative strength improving; negative = deteriorating
const rsRatio5d = (ret5 !== null && Number.isFinite(k5) && k5 !== 0) ? ret5 / k5 : null;
const rsRatio20d = (ret20 !== null && Number.isFinite(k20) && k20 !== 0) ? ret20 / k20 : null;
const rsRatio60d = (ret60 !== null && Number.isFinite(k60) && k60 !== 0) ? ret60 / k60 : null;
const slope20 = (rsRatio5d !== null && rsRatio20d !== null)
? parseFloat(((rsRatio20d - rsRatio5d) / 15).toFixed(4))
: (ret20 !== null && Number.isFinite(k20) ? parseFloat(((ret20 - k20) / 20).toFixed(4)) : null);
const slope60 = (rsRatio20d !== null && rsRatio60d !== null)
? parseFloat(((rsRatio60d - rsRatio20d) / 40).toFixed(4))
: (ret60 !== null && Number.isFinite(k60) ? parseFloat(((ret60 - k60) / 60).toFixed(4)) : null);
const brtMethod = (rsRatio5d !== null && rsRatio20d !== null)
? "RS_RATIO_MULTI_WINDOW_PROXY" : "PROXY_FROM_RET20_RET60";
let verdict = "UNKNOWN";
if (excessDrawdown !== null && rec20 !== null && slope20 !== null) {
if (excessDrawdown >= 10 && (rec20 < 0.50 || (slope60 !== null && slope60 < 0))) verdict = "BROKEN";
else if (excessDrawdown <= 0 && rec20 >= 1.20 && slope20 > 0) verdict = "LEADER";
else if (excessDrawdown >= 5 || rec20 < 0.80 || slope20 < 0) verdict = "LAGGARD";
else verdict = "MARKET";
}
return {
stock_drawdown_from_high_pct: stockDrawdown,
excess_drawdown_pctp: excessDrawdown,
recovery_ratio_5d: rec5,
recovery_ratio_20d: rec20,
downside_beta: downsideBeta,
rs_ratio_5d: rsRatio5d,
rs_ratio_20d: rsRatio20d,
rs_ratio_60d: rsRatio60d,
rs_line_20d_slope: slope20,
rs_line_60d_slope: slope60,
brt_verdict: verdict,
brt_method: brtMethod,
};
}
function fuseRsVerdictV2_(rsV1, brtVerdict) {
const v1 = rsV1 || "UNKNOWN";
const brt = brtVerdict || "UNKNOWN";
if (brt === "BROKEN" && v1 === "LEADER") return "LAGGARD";
if (v1 === "BROKEN" || brt === "BROKEN") return "BROKEN";
if (brt === "LEADER" && v1 === "LAGGARD") return "MARKET";
if (v1 === "LAGGARD" || brt === "LAGGARD") return "LAGGARD";
if (v1 === "LEADER" && brt === "LEADER") return "LEADER";
if (v1 === "UNKNOWN" && brt === "UNKNOWN") return "UNKNOWN";
return "MARKET";
}
function calcSatelliteAlphaQualityGate_(args) {
if (args.position_type === "core") {
return { saqg_v1: "EXEMPT", saqg_penalty: 0, saqg_failed_filters: "" };
}
if (args.ss001_grade === "D" || args.rs_verdict === "BROKEN") {
return { saqg_v1: "EXCLUDED", saqg_penalty: 99, saqg_failed_filters: args.ss001_grade === "D" ? "D_GRADE" : "RS_BROKEN" };
}
const failed = [];
let penalty = 0;
const coreFailures = [];
if (!(Number.isFinite(args.ret20D) && Number.isFinite(args.kospiRet20D) && args.ret20D > args.kospiRet20D)) {
failed.push("F1_relative_return"); coreFailures.push("F1"); penalty += 2;
}
if (!((Number.isFinite(args.recovery_ratio_20d) && args.recovery_ratio_20d >= 1.20)
|| (Number.isFinite(args.recovery_ratio_5d) && args.recovery_ratio_5d >= 1.30))) {
failed.push("F2_recovery_power"); coreFailures.push("F2"); penalty += 2;
}
if (!(Number.isFinite(args.excess_drawdown_pctp) && args.excess_drawdown_pctp <= 5)) {
failed.push("F3_downside_protection"); coreFailures.push("F3"); penalty += 2;
}
if (!(Number.isFinite(args.frg5) && args.frg5 > 0 || Number.isFinite(args.inst5) && args.inst5 > 0)) {
failed.push("F4_institutional_flow"); penalty += 1;
}
if (!["LEADER", "MARKET"].includes(args.rs_verdict)) {
failed.push("F5_sector_leadership"); penalty += 1;
}
let status = "ELIGIBLE";
if (penalty >= 3 || coreFailures.length >= 2) status = "EXCLUDED";
else if (penalty > 0) status = "WATCHLIST_ONLY";
return { saqg_v1: status, saqg_penalty: penalty, saqg_failed_filters: failed.join("|") };
}
// ── Gates & scores: entry sizing, breakout/anti-climax/leader/RW gates,
// FLOW_CREDIT, SS001, TP ladder, position monitoring, F4 trailing stop ────
function _addTickerGates_(ctx, trailingStopUpdates) {
const { t, price, flow, posRec, preReads,
targetPrice, epsRevisionStatus, epsGrowth1y, valuation,
frg5, inst5, frg20, inst20, indiv5, heatCaution, high52W } = ctx;
const { positionStopMap_, riskBudget_, totalAssetKrw_, bayesian,
globalRegimePrelim_, globalKospiRet10D_, globalKospiRet20D_, csRsPctMap_, sectorFlowData_,
globalHeatPct_ } = preReads;
// ── 진입가·손절가·기대우위 추정 (Bayesian multiplier) ─────────────────
const limitPriceEst = price.ok && Number.isFinite(price.close) && Number.isFinite(price.atr20)
? Math.round(price.close + price.atr20 * 0.05) : "";
const stopPriceActual = posRec ? posRec.stop_price : null;
const stopPriceEst = stopPriceActual != null
? stopPriceActual
: (price.ok && Number.isFinite(price.close) && Number.isFinite(price.atr20)
? Math.round(Math.max(price.close * 0.92, price.close - price.atr20 * THRESHOLDS.ATR_STOP_MULT)) : "");
const stopPriceSource = stopPriceActual != null ? "account_snapshot" : "ATR추정";
let eeEst = "";
if (bayesian.bayesian_multiplier > 0
&& limitPriceEst !== "" && stopPriceEst !== "" && limitPriceEst > stopPriceEst
&& Number.isFinite(targetPrice) && targetPrice > limitPriceEst) {
eeEst = ((targetPrice - limitPriceEst) / (limitPriceEst - stopPriceEst) * bayesian.bayesian_multiplier - 0.003).toFixed(2);
} else if (bayesian.bayesian_multiplier === 0) {
eeEst = "0 (no_bet)";
}
// ── Pos_Size_Qty 추정: POSITION_SIZE_V1 간략 버전 ─────────────────────
let posSizeQty = "";
let posConstraint = "";
if (Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0
&& price.ok && Number.isFinite(price.atr20) && price.atr20 > 0
&& Number.isFinite(price.close) && price.close > 0
&& bayesian.bayesian_multiplier > 0) {
const atrQty = Math.floor(totalAssetKrw_ * riskBudget_ * bayesian.bayesian_multiplier / (price.atr20 * THRESHOLDS.ATR_STOP_MULT));
const weightQty = Math.floor(totalAssetKrw_ * 0.05 / price.close);
let rawQty = Math.max(0, Math.min(atrQty, weightQty));
const bindingLabel = atrQty <= weightQty ? `ATR(${atrQty}주)` : `Weight(${weightQty}주)`;
if (heatCaution && rawQty > 0) {
rawQty = Math.max(1, Math.floor(rawQty * 0.5));
posConstraint = `${bindingLabel}→Heat감액(${globalHeatPct_}%)`;
} else {
posConstraint = bindingLabel;
}
posSizeQty = rawQty;
}
// ── Breakout Pilot Score ───────────────────────────────────────────────
const priceDev = price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) && price.ma20 > 0
? (price.close / price.ma20 - 1) * 100 : null;
const vsTerm = price.ok && Number.isFinite(price.valSurge) ? price.valSurge / 10 : 0;
const netFlowRatio = flow.ok && Number.isFinite(price.avgVolume5D) && price.avgVolume5D > 0
? Math.min(5, Math.max(-5, (frg5 + inst5) / price.avgVolume5D)) : 0;
const breakoutScore = Number.isFinite(priceDev) ? parseFloat((priceDev + vsTerm + netFlowRatio).toFixed(1)) : "";
const breakoutGate = breakoutScore !== "" ? (breakoutScore > 15 ? "ALLOW" : "WAIT") : "";
// ── anti_climax_buy_gate S1~S5 ────────────────────────────────────────
const ret5Dval = price.ok && Number.isFinite(parseFloat(price.ret5D)) ? parseFloat(price.ret5D) : null;
const ac_s1 = Number.isFinite(ret5Dval) ? (ret5Dval >= 25 ? 1 : 0) : 0;
const ac_s2 = Number.isFinite(price.avgTradingValue5D) && Number.isFinite(price.avgTradingValue20D) && price.avgTradingValue20D > 0
? (price.avgTradingValue5D >= price.avgTradingValue20D * 3.0 ? 1 : 0) : 0;
const hlRange = price.ok && Number.isFinite(price.high) && Number.isFinite(price.low) ? price.high - price.low : 0;
const ac_s3 = price.ok && Number.isFinite(price.high) && Number.isFinite(price.close) && hlRange > 0
? ((price.high - price.close) / hlRange >= 0.35 ? 1 : 0) : 0;
const ac_s4 = flow.ok && frg5 < 0 && inst5 < 0 ? 1 : 0;
const ac_s5 = flow.ok && indiv5 > 0 && (frg5 < 0 || inst5 < 0) ? 1 : 0;
const ac_total = ac_s1 + ac_s2 + ac_s3 + ac_s4 + ac_s5;
const ac_gate = ac_total >= 3 ? "BLOCK" : ac_total === 2 ? "CAUTION" : "CLEAR";
// ── daily_leader_scan C1~C5 자동 계산 ─────────────────────────────────
const c1 = price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) && Number.isFinite(price.high) && Number.isFinite(price.low)
? (price.close >= price.ma20 && price.close >= (price.high - (price.high - price.low) * 0.3) ? 1 : 0) : 0;
const ret10DKospi = globalKospiRet10D_ ?? null;
const c2 = price.ok && Number.isFinite(price.ret10D) && Number.isFinite(ret10DKospi)
? ((price.ret10D - ret10DKospi) >= 3 ? 1 : 0) : 0;
const c3 = Number.isFinite(price.avgTradingValue5D) && Number.isFinite(price.avgTradingValue20D) && price.avgTradingValue20D > 0
? (price.avgTradingValue5D >= price.avgTradingValue20D * 1.5 ? 1 : 0) : 0;
const c4 = flow.ok && (frg5 > 0 || inst5 > 0) ? 1 : 0;
// C5: Tier_1 + Rotation_Rank<=3 -> 1.0 / Tier_1+rank>3 or Tier_2 -> 0.5 / Tier_3 -> 0
const tickerSector = TICKER_SECTOR_MAP[t.code] ?? null;
const sfSector = tickerSector ? (sectorFlowData_[tickerSector] ?? null) : null;
const sectorRank = sfSector?.rank ?? null;
const sectorTier = tickerSector ? (SECTOR_TIER_MAP[tickerSector] ?? "Tier_3") : "Tier_3";
let c5;
if (sectorTier === "Tier_1") {
c5 = sectorRank !== null ? (sectorRank <= 3 ? 1.0 : 0.5) : 0.5;
} else if (sectorTier === "Tier_2") {
c5 = 0.5;
} else {
c5 = 0;
}
const leaderTotal = c1 + c2 + c3 + c4 + c5;
const leaderGate = leaderTotal >= 4 ? "EXPLORE_CANDIDATE" : leaderTotal >= 3 ? "WATCH_ONLY" : "BELOW_THRESHOLD";
// ── 상대약세 청산 신호 RW1~RW5 자동 계산 ───────────────────────────────
const etfRet10D = sfSector?.etfRet10D ?? null;
const rw1 = sfSector?.rw1 ?? 0;
const rw2 = price.ok && Number.isFinite(price.ret10D) && Number.isFinite(etfRet10D)
? ((price.ret10D - etfRet10D) <= -5 ? 1 : 0) : 0;
const rw3 = sfSector?.rw3 ?? 0;
const rw4 = Number.isFinite(price.avgTradingValue5D) && Number.isFinite(price.avgTradingValue20D) && price.avgTradingValue20D > 0
? (price.avgTradingValue5D / price.avgTradingValue20D <= 0.60 ? 1 : 0) : 0;
const rw5 = price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) && Number.isFinite(price.ma60)
? (price.close < price.ma20 && price.close < price.ma60 ? 1 : 0) : 0;
const rw_partial = rw1 + rw2 + rw3 + rw4 + rw5;
// ── FLOW_CREDIT_V1 ────────────────────────────────────────────────────
const fc_c1 = price.ok && Number.isFinite(price.close) &&
((Number.isFinite(price.open) && price.close >= price.open) ||
(Number.isFinite(price.prevClose) && price.close > price.prevClose)) ? 1 : 0;
const fc_c2 = price.ok && Number.isFinite(price.volume) && Number.isFinite(price.avgVolume5D) && price.avgVolume5D > 0
? (price.volume >= price.avgVolume5D * 1.20 ? 1 : 0) : 0;
const fc_c3 = flow.ok && (frg5 + inst5) > 0 ? 1 : 0;
const flowCredit = (fc_c1 === 0 && fc_c2 === 0) ? 0
: parseFloat((fc_c1 * 0.30 + fc_c2 * 0.30 + fc_c3 * 0.40).toFixed(2));
// ── TRAILING_STOP_PRICE_V1 (포지션 탭 highest_price_since_entry 기반) ──
const posHighest = positionStopMap_[t.code]?.highest_price ?? null;
let trailingStopPrice = "";
if (Number.isFinite(posHighest) && posHighest > 0 && price.ok && Number.isFinite(price.atr20)) {
trailingStopPrice = Math.round(posHighest - price.atr20 * THRESHOLDS.ATR_STOP_MULT);
}
// ── SS001 종목 점수 자동 계산 ─────────────────────────────────────────
const ss001 = calcSS001Score_({
rsPct20D: csRsPctMap_[t.code] ?? null,
avgTV5D: price.avgTradingValue5D,
avgTV20D: price.avgTradingValue20D,
flowCredit,
epsRevisionStatus,
regimePrelim: globalRegimePrelim_,
isKosdaq: KOSDAQ_TICKERS.has(t.code),
sfMedPE: sfSector?.medianPE ?? null,
sfMedPBR: sfSector?.medianPBR ?? null,
forwardPE: Number.isFinite(valuation.per) ? valuation.per : null,
pbrVal: Number.isFinite(valuation.pbr) ? valuation.pbr : null,
epsGrowth1y,
});
const { ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val,
ss001_total, ss001_norm, ss001_grade, pegVal, pegGate } = ss001;
// ── BENCHMARK_RELATIVE_TIMESERIES_V1 — KOSPI 대비 시계열 상대평가 ──────────
const brt = calcBenchmarkRelativeTimeseries_(price, high52W, preReads);
// ── RS_VERDICT_V1 → RS_VERDICT_V2 — 상대강도 판정 (spec/13_formula_registry.yaml) ──
const kospiRet10DForRS = globalKospiRet10D_ ?? null;
const stockRet10DForRS = price.ok && Number.isFinite(price.ret10D) ? price.ret10D : null;
const excess_ret_10d = (stockRet10DForRS !== null && kospiRet10DForRS !== null)
? parseFloat((stockRet10DForRS - kospiRet10DForRS).toFixed(2)) : null;
let rs_verdict_v1_raw;
if (excess_ret_10d === null) {
rs_verdict_v1_raw = "UNKNOWN";
} else if (excess_ret_10d < -10 && rw_partial >= 3) {
rs_verdict_v1_raw = "BROKEN";
} else if (excess_ret_10d < -3 || (excess_ret_10d < 0 && rw_partial >= 3)) {
rs_verdict_v1_raw = "LAGGARD";
} else if (excess_ret_10d > 3 && flowCredit >= 0.6) {
rs_verdict_v1_raw = "LEADER";
} else {
rs_verdict_v1_raw = "MARKET";
}
const rs_verdict = fuseRsVerdictV2_(rs_verdict_v1_raw, brt.brt_verdict);
// ── COMPOSITE_VERDICT_V1 — SS001 × RS_VERDICT 매트릭스 ───────────────────
const _cvMatrix = {
A: { LEADER: "PRIME_CANDIDATE", MARKET: "PRIME_CANDIDATE",
LAGGARD: "WATCH_CANDIDATE", BROKEN: "EXIT_REVIEW", UNKNOWN: "WATCH_CANDIDATE" },
B: { LEADER: "PRIME_CANDIDATE", MARKET: "WATCH_CANDIDATE",
LAGGARD: "REDUCE_CANDIDATE", BROKEN: "EXIT_REVIEW", UNKNOWN: "WATCH_CANDIDATE" },
C: { LEADER: "WATCH_CANDIDATE", MARKET: "REDUCE_CANDIDATE",
LAGGARD: "REDUCE_CANDIDATE", BROKEN: "CLOSE_POSITION", UNKNOWN: "REDUCE_CANDIDATE" },
D: { LEADER: "REDUCE_CANDIDATE", MARKET: "REDUCE_CANDIDATE",
LAGGARD: "CLOSE_POSITION", BROKEN: "CLOSE_POSITION", UNKNOWN: "REDUCE_CANDIDATE" },
};
const composite_verdict = _cvMatrix[ss001_grade]?.[rs_verdict] ?? "WATCH_CANDIDATE";
const saqg = calcSatelliteAlphaQualityGate_({
position_type: posRec?.position_type ?? "satellite",
ss001_grade,
ret20D: price.ok && Number.isFinite(price.ret20D) ? price.ret20D : null,
kospiRet20D: globalKospiRet20D_,
recovery_ratio_5d: brt.recovery_ratio_5d,
recovery_ratio_20d: brt.recovery_ratio_20d,
excess_drawdown_pctp: brt.excess_drawdown_pctp,
frg5, inst5,
rs_verdict,
});
// ── TAKE_PROFIT_LADDER_V1 ─────────────────────────────────────────────
let tp1Price = "", tp1Qty = "", tp2Price = "", tp2Qty = "";
let timeStopDate = "", daysToTimeStop = "";
if (posRec && Number.isFinite(posRec.entry_price) && Number.isFinite(posRec.quantity) && posRec.quantity > 0) {
const avgCost = posRec.entry_price;
const heldQty = posRec.quantity;
if (posRec.position_type === "core") {
const q1 = Math.floor(heldQty * 0.25);
const q2 = Math.floor((heldQty - q1) * 0.40);
tp1Price = Math.round(avgCost * THRESHOLDS.TP_CORE_1); tp1Qty = q1;
tp2Price = Math.round(avgCost * THRESHOLDS.TP_CORE_2); tp2Qty = q2;
} else {
const q1 = Math.floor(heldQty * 0.50);
const q2 = Math.floor((heldQty - q1) * 0.50);
tp1Price = Math.round(avgCost * THRESHOLDS.TP_SAT_1); tp1Qty = q1;
tp2Price = Math.round(avgCost * THRESHOLDS.TP_SAT_2); tp2Qty = q2;
}
// Time_Stop: stage_1=60D, stage_2=30D
const stageLimit = posRec.entry_stage === "stage_2" ? THRESHOLDS.TIME_STOP_STAGE2 : THRESHOLDS.TIME_STOP_STAGE1; // 기본 60일
if (posRec.entry_date) {
try {
const entryMs = new Date(posRec.entry_date).getTime();
if (!isNaN(entryMs)) {
const tsMs = entryMs + stageLimit * 86400000;
timeStopDate = Utilities.formatDate(new Date(tsMs), "Asia/Seoul", "yyyy-MM-dd");
daysToTimeStop = Math.round((tsMs - Date.now()) / 86400000);
}
} catch(_) {}
}
}
// ── 포지션 모니터링 (Weight_Pct / Profit_Pct / PnL / Stage2_Gate / Band_Status) ──
let weightPct = "", profitPct = "", unrealizedPnl = "", stage2Gate = "", bandStatus = "";
let corePctDelta = 0, satPctDelta = 0;
if (posRec && Number.isFinite(posRec.entry_price) && Number.isFinite(posRec.quantity) && posRec.quantity > 0) {
const ep = posRec.entry_price;
const qty = posRec.quantity;
const cl = price.ok && Number.isFinite(price.close) ? price.close : null;
if (cl !== null && Number.isFinite(totalAssetKrw_) && totalAssetKrw_ > 0) {
weightPct = parseFloat(((cl * qty) / totalAssetKrw_ * 100).toFixed(2));
}
if (cl !== null) {
profitPct = parseFloat(((cl - ep) / ep * 100).toFixed(2));
unrealizedPnl = Math.round((cl - ep) * qty);
}
if (posRec.entry_stage === "stage_1" && cl !== null) {
stage2Gate = ((cl - ep) / ep * 100 >= THRESHOLDS.STAGE2_GATE_MIN_PCT) ? "PASS" : "PENDING";
} else if (posRec.entry_stage) {
stage2Gate = "N/A";
}
if (weightPct !== "" && posRec.position_type !== "core") {
bandStatus = weightPct > THRESHOLDS.SAT_BAND_MAX ? "OVERWEIGHT" : weightPct >= 3 ? "IN_BAND" : "UNDERWEIGHT";
} else if (weightPct !== "") {
bandStatus = "CORE_" + (weightPct > 10 ? "HIGH" : weightPct >= 3 ? "MID" : "LOW");
}
if (weightPct !== "") {
if (posRec.position_type === "core") corePctDelta = weightPct;
else satPctDelta = weightPct;
}
}
// ── F4 Trailing Stop 갱신 대기열 ─────────────────────────────────────
if (posRec && posRec.quantity > 0 && price.ok && Number.isFinite(price.close) && Number.isFinite(price.atr20)) {
const curHigh = Number.isFinite(posRec.highest_price) ? posRec.highest_price : 0;
const curStop = Number.isFinite(posRec.stop_price) ? posRec.stop_price : 0;
const entryPrice = Number.isFinite(posRec.entry_price) ? posRec.entry_price : 0;
if (price.close > curHigh) {
const newStop = parseFloat((price.close - price.atr20 * THRESHOLDS.ATR_STOP_MULT).toFixed(0));
// PS002: 손절선 단조 상승 보장
if (newStop > curStop && (entryPrice <= 0 || newStop < entryPrice)) {
trailingStopUpdates.push({ ticker: t.code, new_highest: price.close, new_stop: newStop });
}
}
}
Object.assign(ctx, {
limitPriceEst, stopPriceEst, stopPriceSource, eeEst, posSizeQty, posConstraint,
breakoutScore, breakoutGate,
ac_s1, ac_s2, ac_s3, ac_s4, ac_s5, ac_total, ac_gate,
c1, c2, c3, c4, c5, leaderTotal, leaderGate,
rw1, rw2, rw3, rw4, rw5, rw_partial, flowCredit,
trailingStopPrice,
ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val,
ss001_total, ss001_norm, ss001_grade, pegVal, pegGate,
excess_ret_10d, rs_verdict, composite_verdict,
tp1Price, tp1Qty, tp2Price, tp2Qty, timeStopDate, daysToTimeStop,
weightPct, profitPct, unrealizedPnl, stage2Gate, bandStatus,
corePctDelta, satPctDelta,
});
}
// ── Decision: F1-F3 timing, sell decision, allowed/final action, reason/params
function _addTickerRoute_(ctx) {
// THIN_ADAPTER: [unknown] delegated to Python — src/quant_engine/inject_computed_harness.py:calc_semiconductor_cluster
const { t, price, flow, dartSummary, posRec, preReads,
priceStatus, isRiskOffRegime, heatBlock, heatCaution, perfBias,
liquidityStatus, spreadStatus,
stopPriceEst, trailingStopPrice, tp1Price, tp1Qty, tp2Price,
profitPct, daysToTimeStop, ac_gate, rw_partial, rw1, rw2, rw3, rw4, rw5,
flowCredit, leaderTotal, leaderGate, ss001_grade, ss001_norm, ss001_total,
rs_verdict, excess_ret_10d,
weightPct, posSizeQty } = ctx;
const brt = ctx.brt || { brt_verdict: "UNKNOWN", brt_method: "DATA_MISSING" };
const saqg = ctx.saqg || { saqg_v1: "EXEMPT" };
const { globalRegimePrelim_, globalHeatPct_ } = preReads;
// ── F1 기술적 타이밍 지표 ────────────────────────────────────────────
const timing = (price.ok && Array.isArray(price.rows) && price.rows.length >= 21)
? calcTimingMetrics_(price.rows) : {};
const ma20Slope = timing.ma20Slope ?? "";
const disparity = timing.disparity ?? "";
const rsi14 = timing.rsi14 ?? "";
const bbWidth = timing.bbWidth ?? "";
const bbPosition = timing.bbPosition ?? "";
const bbUpper = timing.bbUpper ?? "";
const bbLower = timing.bbLower ?? "";
const cashFloorStatus_ = Number.isFinite(globalHeatPct_)
? (globalHeatPct_ >= 10 ? "HARD_BLOCK" : globalHeatPct_ >= 7 ? "TRIM_REQUIRED" : "PASS") : "PASS";
// ── F2 진입 모드 게이트 ──────────────────────────────────────────────
const entryModeResult = (price.ok && Object.keys(timing).length)
? calcEntryMode_(timing, price) : { mode: "NEUTRAL", gate: "PENDING", reason: "데이터부족" };
const entryMode = entryModeResult.mode;
const entryModeGate = entryModeResult.gate;
const entryModeReason = entryModeResult.reason;
// ── F3 매도 타이밍 신호 ──────────────────────────────────────────────
const exitSignalDetail = (posRec && posRec.quantity > 0 && price.ok && Object.keys(timing).length)
? calcExitSignalDetail_(timing, price) : "";
const timingRoute = calcTimingRoute_({
priceStatus, atr20: price.atr20, flowCredit, leaderTotal, leaderGate,
acGate: ac_gate, rwPartial: rw_partial, entryMode, entryModeGate, exitSignalDetail,
rsi14, disparity, ma20Slope, spreadPct: price.spreadPct,
avgTradeValue5D: price.avgTradingValue5D, profitPct, daysToTimeStop,
});
const timingScoreEntry = timingRoute.entry_score;
const timingScoreExit = timingRoute.exit_score;
const timingAction = timingRoute.action;
const timingBlockReason = timingRoute.reason;
const isEtf_ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(t.name ?? "");
const sellRoute = calcSellRoute_({
close: price.close, stopPrice: stopPriceEst, trailingStop: trailingStopPrice,
tp1Price, tp2Price, profitPct, rwPartial: rw_partial,
timingExitScore: timingScoreExit, daysToTimeStop, timingAction, exitSignalDetail,
acGate: ac_gate, regime: globalRegimePrelim_ ?? "", atr20: price.atr20,
cashFloorStatus: cashFloorStatus_,
liquidityStatus: String(price.liquidityStatus ?? price.Liquidity_Status ?? ""),
spreadStatus: String(price.spreadStatus ?? price.Spread_Status ?? ""),
accountType: posRec?.account_type ?? "",
isCoreLeader: indexOfArr_(CORE_TICKERS, t.code) >= 0,
isEtf: isEtf_,
});
const cashPreservePlan = calcCashPreservationPlan_({
sellAction: sellRoute.action, cashFloorStatus: cashFloorStatus_,
regime: globalRegimePrelim_ ?? "",
isCoreLeader: indexOfArr_(CORE_TICKERS, t.code) >= 0,
isEtf: isEtf_,
liquidityStatus: String(price.liquidityStatus ?? price.Liquidity_Status ?? ""),
spreadStatus: String(price.spreadStatus ?? price.Spread_Status ?? ""),
accountType: posRec?.account_type ?? "",
profitPct, rwPartial: rw_partial, reboundHoldbackScore: 0,
});
let sellAction = sellRoute.action;
let sellRatioPct = sellRoute.ratio_pct;
if (sellAction !== "EXIT_100" && sellAction !== "TRAILING_STOP_BREACH"
&& Number.isFinite(cashPreservePlan.recommended_ratio)
&& cashPreservePlan.recommended_ratio > 0
&& cashPreservePlan.recommended_ratio < sellRatioPct) {
sellRatioPct = cashPreservePlan.recommended_ratio;
if (sellRatioPct <= 25) sellAction = "TRIM_25";
else if (sellRatioPct <= 33) sellAction = "TRIM_33";
else sellAction = "TRIM_50";
sellRoute.action = sellAction;
sellRoute.ratio_pct = sellRatioPct;
sellRoute.reason = `${sellRoute.reason}|CASH_PRESERVE:${cashPreservePlan.style}`;
}
const sellLimitPrice = sellRoute.limit_price;
const sellPriceSource = sellRoute.price_source;
const sellPriceBasis = sellRoute.price_basis;
const sellExecutionWindow = sellRoute.execution_window;
const sellOrderType = sellRoute.order_type;
const sellReason = sellRoute.reason;
const sellValidation = sellRoute.validation;
const accountHoldingQty = Number.isFinite(posRec?.account_quantity) ? posRec.account_quantity : "";
const accountAvgCost = Number.isFinite(posRec?.account_average_cost) ? posRec.account_average_cost : "";
const accountMarketValue = Number.isFinite(posRec?.account_market_value) ? posRec.account_market_value : "";
const accountParseStatus = posRec?.account_parse_status ?? "";
// account_snapshot CAPTURE_READ_OK 시 Sell_Qty 직접 산출
const sellQtyCalc = (() => {
if (accountParseStatus !== "CAPTURE_READ_OK") return "";
const heldQty = Number.isFinite(posRec?.account_quantity) ? posRec.account_quantity : 0;
if (heldQty <= 0 || !sellRatioPct || sellAction === "HOLD") return "";
const availQty = Number.isFinite(posRec?.available_quantity) && posRec.available_quantity > 0
? posRec.available_quantity : heldQty;
return Math.max(1, Math.min(Math.round(heldQty * sellRatioPct / 100), availQty));
})();
const ruleSellQty = (() => {
const heldQty = Number.isFinite(accountHoldingQty) ? accountHoldingQty : 0;
if (heldQty <= 0 || !sellRatioPct) return "";
const calc = Math.floor(heldQty * sellRatioPct / 100);
return calc > 0 ? calc : "";
})();
// ── Allowed_Action ───────────────────────────────────────────────────
// 우선순위: 데이터 품질 → HF005 → DART → 유동성 → REGIME매수차단 → RW청산 → SS001
let action;
if (priceStatus !== "PRICE_OK" || !Number.isFinite(price.atr20)) {
action = "OBSERVE_ONLY";
} else if (heatBlock) {
action = posRec?.quantity > 0 ? "HOLD" : "NO_ADD";
} else if (dartSummary?.risk) {
action = "HOLD_NO_ADD";
} else if (!flow.ok
|| (Number.isFinite(price.avgTradingValue5D) && price.avgTradingValue5D < 50)
|| (Number.isFinite(price.spreadPct) && price.spreadPct > 0.8)) {
action = "NO_ADD";
} else if (timingAction === "STOP_OR_TIME_EXIT_READY" || rw_partial >= 3) {
action = "EXIT_SIGNAL";
} else if (timingAction === "EXIT_REVIEW" || rw_partial >= 2) {
action = "REVIEW_EXIT";
} else if (timingAction === "NO_BUY_OVERHEATED") {
action = "HOLD_NO_ADD";
} else if (isRiskOffRegime) {
// Issue 3: RISK_OFF/RISK_OFF_CANDIDATE 레짐에서 신규 매수 차단
action = posRec?.quantity > 0 ? "HOLD" : "NO_ADD";
} else if (saqg.saqg_v1 === "EXCLUDED") {
action = "HOLD_NO_ADD";
} else if (saqg.saqg_v1 === "WATCHLIST_ONLY") {
action = "WATCH_CANDIDATE";
} else if (ss001_grade === "A" && (timingAction === "BUY_STAGE1_READY" || timingAction === "BUY_BREAKOUT_PILOT_ONLY")) {
action = (heatCaution || perfBias.entry_block || perfBias.quantity_multiplier <= 0)
? "WATCH_CANDIDATE" : timingAction;
} else if (ss001_grade === "A") {
action = "WATCH_CANDIDATE";
} else if (ss001_grade === "B" && timingAction !== "HOLD_NO_TIMING_EDGE") {
action = (heatCaution || perfBias.entry_block || perfBias.quantity_multiplier <= 0)
? "WATCH_CANDIDATE"
: (timingAction === "WATCH_TIMING_SETUP" ? "WATCH_CANDIDATE" : timingAction);
} else if (ss001_grade === "B") {
action = "WATCH_CANDIDATE";
} else if (ss001_grade === "C") {
action = "HOLD";
} else {
action = "HOLD_NO_ADD";
}
// ── RAG_V1: CLA 레짐 위성 신규 BUY 알파 게이트 ─────────────────────────
const _BUY_ACTIONS_ = new Set(["BUY_STAGE1_READY", "BUY_BREAKOUT_PILOT_ONLY", "BUY_PULLBACK_WAIT"]);
let rag_v1 = "EXEMPT", rag_reason = "no_buy_action";
if (_BUY_ACTIONS_.has(action)) {
const _rag = validateReplacementAlpha_({
posRec, globalRegimePrelim_,
rs_verdict, ss001_norm, excess_ret_10d,
});
rag_v1 = _rag.rag_v1;
rag_reason = _rag.rag_reason;
if (rag_v1 === 'FAIL') action = "HOLD";
}
if (saqg.saqg_v1 === "EXCLUDED" || saqg.saqg_v1 === "WATCHLIST_ONLY") {
rag_reason = rag_reason === "no_buy_action" ? ("saqg_" + saqg.saqg_v1) : rag_reason + "|saqg_" + saqg.saqg_v1;
}
const finalRoute = calcFinalRoute_({
sellAction, sellValidation, allowedAction: action, timingAction,
timingScoreEntry, timingScoreExit, ss001Total: ss001_total,
flowCredit, leaderTotal, rwPartial: rw_partial, profitPct, daysToTimeStop,
weightPct, acGate: ac_gate, liquidityStatus, spreadStatus,
dartRisk: !!(dartSummary?.risk),
missingFields: ctx.missing.length ? ctx.missing.join(" | ") : "",
});
const finalAction = finalRoute.final_action;
const actionPriority = finalRoute.action_priority;
const priorityScore = finalRoute.priority_score;
const routeSource = finalRoute.route_source;
// ── Action_Reason: "왜 이 액션인가" — B-2 ────────────────────────────
const sellDetailLabel_ = {
"EXIT_100": "손절전량(100%)",
"TRIM_70": "RW강도매도(70%)",
"TRAILING_STOP_BREACH": "트레일링이탈(70%)",
"TRIM_50": "RW부분매도(50%)",
"TRIM_33": "RW초기경보(33%)",
"TRIM_25": "RW약세감지(25%)",
"PROFIT_TRIM_50": "익절(50%)",
"PROFIT_TRIM_35": "익절(35%)",
"PROFIT_TRIM_25": "익절(25%)",
"TAKE_PROFIT_TIER1":"TP1익절(25%)",
"TIME_EXIT_100": "타임스탑만료(100%)",
"TIME_TRIM_50": "타임스탑근접(50%)",
"TIME_TRIM_25": "타임스탑예고(25%)",
"REGIME_TRIM_50": "레짐포트축소(50%)",
};
let actionReason = "";
if (finalAction === "SELL_READY") {
const lbl_ = sellDetailLabel_[sellAction] ?? sellAction;
actionReason = `${lbl_} ${sellRatioPct}% @${sellLimitPrice}원 [${sellReason}]`;
} else if (finalAction === "EXIT_SIGNAL" || finalAction === "EXIT_REVIEW") {
const rwItems_ = [rw1&&"RW1",rw2&&"RW2",rw3&&"RW3",rw4&&"RW4",rw5&&"RW5"].filter(Boolean);
actionReason = `RW${rw_partial}(${rwItems_.join("+")})${exitSignalDetail ? " "+exitSignalDetail : ""}`;
} else if (["BUY_STAGE1_READY","BUY_BREAKOUT_PILOT_ONLY","BUY_PULLBACK_WAIT"].includes(finalAction)) {
const r_ = Number.isFinite(rsi14) ? rsi14.toFixed(0) : "?";
const d_ = Number.isFinite(disparity) ? disparity.toFixed(1) : "?";
const f_ = Number.isFinite(flowCredit) ? flowCredit.toFixed(2) : "?";
actionReason = `SS001:${ss001_grade}${ss001_norm.toFixed(0)}점 RSI${r_} 이격${d_}% FC${f_}`;
} else if (finalAction === "WATCH_TIMING_SETUP" || action === "WATCH_CANDIDATE") {
const why_ = timingBlockReason || entryModeReason || "-";
const perf_ = perfBias.entry_block
? `PERF_BLOCK(${perfBias.reason})`
: (perfBias.quantity_multiplier < 1 ? `PERF_CAUTION(${perfBias.reason})` : "");
actionReason = `SS001:${ss001_grade}${ss001_norm.toFixed(0)}점 타이밍미충족(${why_})${perf_ ? " | " + perf_ : ""}`;
} else if (action === "HOLD") {
actionReason = heatBlock ? `HeatBlock(${globalHeatPct_}%)`
: isRiskOffRegime ? globalRegimePrelim_
: `SS001:${ss001_grade}`;
} else if (action === "NO_ADD") {
const why_ = [];
if (!flow.ok) why_.push("수급이탈");
if (Number.isFinite(price.avgTradingValue5D) && price.avgTradingValue5D < 50)
why_.push(`거래대금${price.avgTradingValue5D.toFixed(0)}억`);
if (Number.isFinite(price.spreadPct) && price.spreadPct > 0.8)
why_.push(`스프레드${price.spreadPct.toFixed(2)}%`);
if (isRiskOffRegime) why_.push(globalRegimePrelim_);
actionReason = why_.join("|") || "유동성차단";
} else if (action === "HOLD_NO_ADD") {
if (dartSummary?.risk) {
actionReason = `DART:${dartSummary.risk}`;
} else if (timingAction === "NO_BUY_OVERHEATED" || ac_gate === "BLOCK") {
actionReason = `과열(${timingBlockReason||ac_gate||""})`;
} else {
actionReason = `SS001:${ss001_grade}${ss001_norm.toFixed(0)}점`;
}
} else if (action === "OBSERVE_ONLY") {
actionReason = `DATA_UNAVAIL(${priceStatus})`;
}
// ── C-3: Action_Params — 실행 파라미터 압축 ──────────────────────────
let actionParams = "";
if (finalAction === "SELL_READY") {
const ratio_ = Number.isFinite(sellRatioPct) ? `${sellRatioPct}%` : "?%";
const price_ = Number.isFinite(sellLimitPrice) ? `@${sellLimitPrice.toLocaleString()}원` : "@?원";
const win_ = sellExecutionWindow || "";
const ord_ = sellOrderType || "";
actionParams = [ratio_, price_, win_, ord_].filter(Boolean).join(" | ");
} else if (finalAction === "EXIT_SIGNAL" || finalAction === "EXIT_REVIEW") {
actionParams = "RW신호 — 캡처 후 수량 확인";
} else if (["BUY_STAGE1_READY","BUY_BREAKOUT_PILOT_ONLY","BUY_PULLBACK_WAIT"].includes(finalAction)) {
const qty_ = Number.isFinite(posSizeQty) ? `목표 ${posSizeQty}주` : "";
const stop_ = Number.isFinite(stopPriceEst) ? `손절 ${stopPriceEst.toLocaleString()}원` : "";
const tp1_ = Number.isFinite(tp1Price) ? `TP1 ${tp1Price.toLocaleString()}원(${tp1Qty ?? "?"}주)` : "";
const perf_ = perfBias.quantity_multiplier < 1 ? `PF_${perfBias.reason}:${perfBias.quantity_multiplier}x` : "";
actionParams = [qty_, stop_, tp1_, perf_].filter(Boolean).join(" | ");
} else if (finalAction === "WATCH_TIMING_SETUP") {
const perf_ = perfBias.entry_block
? `PERF_BLOCK(${perfBias.reason})`
: perfBias.quantity_multiplier < 1 ? `PERF_CAUTION(${perfBias.reason})` : "";
actionParams = [`대기 — ${timingBlockReason || entryModeReason || "-"}`, perf_].filter(Boolean).join(" | ");
}
Object.assign(ctx, {
ma20Slope, disparity, rsi14, bbWidth, bbPosition, bbUpper, bbLower,
entryMode, entryModeGate, entryModeReason, exitSignalDetail,
timingScoreEntry, timingScoreExit, timingAction, timingBlockReason,
sellAction, sellRatioPct, sellQtyCalc, sellLimitPrice, sellPriceSource, sellPriceBasis,
sellExecutionWindow, sellOrderType, sellReason, sellValidation,
cashPreservePlan, accountHoldingQty, accountAvgCost, accountMarketValue, accountParseStatus,
ruleSellQty,
finalAction, actionPriority, priorityScore, routeSource,
actionReason, actionParams, action,
brt, saqg,
rag_v1, rag_reason,
});
}
function buildTickerRowV2_(t, preReads, trailingStopUpdates) {
const ctx = _tickerSetup_(t, preReads);
_addTickerFundamentals_(ctx);
_addTickerGates_(ctx, trailingStopUpdates);
_addTickerRoute_(ctx);
const {
flow, price, valuation, dartSummary,
frg5, inst5, indiv5, frg20, inst20,
priceStatus, flow5Status, flow20Status, ind5Status, valSurgeStatus,
liquidityStatus, spreadStatus, today, positionCountStatus_, weeklyTargetCashPct_,
epsRevisionStatus, epsGrowth1y, divYield, dps, beta, high52W, low52W,
pct52WHigh, pctFrom52WLow, targetPrice, upsidePct, earningsDateStr, daysToEarnings,
exDividendDateStr, daysToExDiv, roePct, opMarginPct, debtToEquity, currentRatio, fcfB, ocfB, revenueGrowthPct,
limitPriceEst, stopPriceEst, stopPriceSource, eeEst, posSizeQty, posConstraint,
breakoutScore, breakoutGate, ac_s1, ac_s2, ac_s3, ac_s4, ac_s5, ac_total, ac_gate,
c1, c2, c3, c4, c5, leaderTotal, leaderGate,
rw1, rw2, rw3, rw4, rw5, rw_partial, flowCredit,
trailingStopPrice, ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val,
ss001_total, ss001_norm, ss001_grade, pegVal, pegGate,
excess_ret_10d, rs_verdict_v1_raw, rs_verdict, composite_verdict,
brt, saqg,
tp1Price, tp1Qty, tp2Price, tp2Qty, timeStopDate, daysToTimeStop,
weightPct, profitPct, unrealizedPnl, stage2Gate, bandStatus, corePctDelta, satPctDelta,
ma20Slope, disparity, rsi14, bbWidth, bbPosition, bbUpper, bbLower,
entryMode, entryModeGate, entryModeReason, exitSignalDetail,
timingScoreEntry, timingScoreExit, timingAction, timingBlockReason,
sellAction, sellRatioPct, sellQtyCalc, sellLimitPrice, sellPriceSource, sellPriceBasis,
sellExecutionWindow, sellOrderType, sellReason, sellValidation,
cashPreservePlan, accountHoldingQty, accountAvgCost, accountMarketValue, accountParseStatus,
ruleSellQty, finalAction, actionPriority, priorityScore, routeSource,
actionReason, actionParams, action, missing, next,
rag_v1, rag_reason,
} = ctx;
const row = [
// ── 기본 수급·가격 (11 + 29 = 40) ──────────────────────────────────
t.code, t.name, flow.rows[0]?.date ?? today, frg5, inst5, indiv5, frg20, inst20, flow.ok ? "Y" : "N", String(flow.rows.length), today,
priceStatus,
price.ok ? price.close : "",
price.ok && Number.isFinite(price.open) ? price.open : "",
price.ok && Number.isFinite(price.prevClose) ? price.prevClose : "",
price.ok && Number.isFinite(price.high) ? price.high : "",
price.ok && Number.isFinite(price.low) ? price.low : "",
price.ok && Number.isFinite(price.volume) ? price.volume : "",
price.ok && Number.isFinite(price.avgVolume5D) ? Math.round(price.avgVolume5D) : "",
price.ok && Number.isFinite(price.ma20) ? price.ma20.toFixed(2) : "",
price.ok && Number.isFinite(price.ma60) ? price.ma60.toFixed(2) : "",
price.ok && Number.isFinite(price.ret5D) ? parseFloat(price.ret5D).toFixed(2) : "", // Ret5D
price.ok && Number.isFinite(price.ret10D) ? price.ret10D.toFixed(2) : "",
price.ok && Number.isFinite(price.ret20D) ? price.ret20D.toFixed(2) : "",
price.ok && Number.isFinite(price.ret60D) ? price.ret60D.toFixed(2) : "",
price.ok ? Math.round(price.atr20) : "",
price.ok && Number.isFinite(price.atr20Pct) ? price.atr20Pct.toFixed(2) : "",
price.ok && Number.isFinite(price.valSurge) ? price.valSurge.toFixed(1) : "",
Number.isFinite(price.avgTradingValue5D) ? price.avgTradingValue5D.toFixed(2) : "",
Number.isFinite(price.avgTradingValue20D) ? price.avgTradingValue20D.toFixed(2) : "",
Number.isFinite(price.avgTradingValue5D) ? Math.round(price.avgTradingValue5D * 1000000) : "",
Number.isFinite(price.avgTradingValue20D) ? Math.round(price.avgTradingValue20D * 1000000) : "",
"KRW",
Number.isFinite(price.bid) ? price.bid : "N/A",
Number.isFinite(price.ask) ? price.ask : "N/A",
Number.isFinite(price.spreadPct) ? price.spreadPct.toFixed(2) : "N/A",
spreadStatus, price.source ?? "N/A", price.quoteSource ?? "QUOTE_NO_MATCH",
price.quoteStatus ?? "QUOTE_NO_MATCH", liquidityStatus,
flow5Status, flow20Status, ind5Status, valSurgeStatus,
dartSummary.status, dartSummary.source, dartSummary.catalyst, dartSummary.risk,
// ── 밸류에이션 (5+9+4) ─────────────────────────────────────────────
Number.isFinite(valuation.per) ? valuation.per : "",
Number.isFinite(valuation.pbr) ? valuation.pbr : "",
Number.isFinite(valuation.eps) ? valuation.eps : "",
epsRevisionStatus,
epsGrowth1y != null ? epsGrowth1y : "", // EPS_Growth_1Y_Pct
Number.isFinite(divYield) ? Number(divYield).toFixed(2) : (divYield !== "" ? divYield : ""),
dps != null ? dps : "", // DPS
Number.isFinite(beta) ? Number(beta).toFixed(2) : "",
Number.isFinite(high52W) ? high52W : "",
Number.isFinite(low52W) ? low52W : "",
pct52WHigh, pctFrom52WLow,
Number.isFinite(targetPrice) ? Math.round(targetPrice) : "",
upsidePct,
earningsDateStr ?? "",
daysToEarnings !== "" ? daysToEarnings : "",
exDividendDateStr ?? "", // Ex_Dividend_Date
daysToExDiv !== "" ? daysToExDiv : "", // Days_To_Ex_Div
// ── 재무 건전성 (7) (FINANCIAL_HEALTH_V1 + OCF_B 추가, 7일 캐시) ─────────
roePct != null ? Number(roePct).toFixed(1) : "",
opMarginPct != null ? Number(opMarginPct).toFixed(1) : "",
debtToEquity != null ? Number(debtToEquity).toFixed(1) : "",
currentRatio != null ? Number(currentRatio).toFixed(2) : "",
fcfB != null ? Number(fcfB).toFixed(1) : "",
ocfB != null ? Number(ocfB).toFixed(1) : "", // OCF_B (억원)
revenueGrowthPct != null ? Number(revenueGrowthPct).toFixed(1) : "",
// ── 진입가·손절가·기대우위·수량 추정 (5) ──────────────────────────
limitPriceEst, stopPriceEst, stopPriceSource, eeEst, posSizeQty, posConstraint,
// ── 익절 사다리·타임스탑 (6) ─────────────────────────────────────
tp1Price, tp1Qty, tp2Price, tp2Qty, timeStopDate, daysToTimeStop,
// ── 포지션 모니터링 (6) ───────────────────────────────────────────
weightPct, profitPct, unrealizedPnl, stage2Gate, bandStatus,
positionCountStatus_ ?? "", // Position_Count_Status
// ── F1 기술적 타이밍 지표 (7) ────────────────────────────────────
ma20Slope, disparity, rsi14, bbWidth, bbPosition, bbUpper, bbLower,
// ── F2 진입 모드 게이트 (3) ──────────────────────────────────────
entryMode, entryModeGate, entryModeReason,
// ── F3 매도 타이밍 신호 (1) ──────────────────────────────────────
exitSignalDetail,
// ── F5 타이밍 종합 액션 (4) ──────────────────────────────────────
timingScoreEntry, timingScoreExit, timingAction, timingBlockReason,
// ── F6 매도 액션·수량·가격 (10) ─────────────────────────────────
sellAction, sellRatioPct, sellQtyCalc, sellLimitPrice, sellPriceSource, sellPriceBasis,
sellExecutionWindow, sellOrderType, sellReason, sellValidation,
cashPreservePlan.style, cashPreservePlan.recommended_ratio, cashPreservePlan.reasons,
// ── F6A 계좌 캡처·주간 리밸런싱 검증 (10) ───────────────────────
accountHoldingQty, accountAvgCost, accountMarketValue, accountParseStatus,
ruleSellQty, weeklyTargetCashPct_ ?? "", "", "", "", "",
// ── F7 최종 룰엔진 액션·우선순위 (5) ────────────────────────────
finalAction, actionPriority, priorityScore, "", routeSource,
// ── 수급·점수 자동 계산 (13: SS001_Norm_Score 추가) ───────────────────
flowCredit, trailingStopPrice,
ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val, ss001_total, parseFloat(ss001_norm.toFixed(1)), ss001_grade,
pegVal, pegGate,
// ── Breakout 게이트 (2) ────────────────────────────────────────────
breakoutScore, breakoutGate,
// ── anti_climax_buy_gate (7) ──────────────────────────────────────
ac_s1, ac_s2, ac_s3, ac_s4, ac_s5, ac_total, ac_gate,
// ── daily_leader_scan C1~C5 (7) ───────────────────────────────────
c1, c2, c3, c4, c5, leaderTotal, leaderGate,
// ── RW 청산 신호 (6) ──────────────────────────────────────────────
rw1, rw2, rw3, rw4, rw5, rw_partial,
// ── BRT_V1 + RS_VERDICT_V2 + COMPOSITE_VERDICT_V1 + SAQG/RAG ──────
brt.stock_drawdown_from_high_pct !== null ? brt.stock_drawdown_from_high_pct : "",
brt.excess_drawdown_pctp !== null ? brt.excess_drawdown_pctp : "",
brt.recovery_ratio_5d !== null ? brt.recovery_ratio_5d : "",
brt.recovery_ratio_20d !== null ? brt.recovery_ratio_20d : "",
brt.downside_beta !== null ? brt.downside_beta : "",
brt.rs_line_20d_slope !== null ? brt.rs_line_20d_slope : "",
brt.rs_line_60d_slope !== null ? brt.rs_line_60d_slope : "",
brt.brt_verdict,
brt.brt_method,
excess_ret_10d !== null ? excess_ret_10d : "",
rs_verdict_v1_raw,
rs_verdict,
composite_verdict,
saqg.saqg_v1,
saqg.saqg_penalty,
saqg.saqg_failed_filters,
rag_v1,
rag_reason,
// ── 데이터 품질 (5) ───────────────────────────────────────────────
missing.length ? missing.join(" | ") : "",
next.length ? next.join(" | ") : "",
actionReason,
actionParams,
action
];
return { row, corePctDelta, satPctDelta };
}
// ── 1일 수익률 보조 함수 ──────────────────────────────────────────────────────
function fetchYahooPrice1D(sym) {
const cacheKey = `yahoo_price1d_${sym}`;
const cached = getCachedFetchResult_(cacheKey);
if (cached) return cached;
if (isFetchCircuitOpen_("yahoo_chart")) return "N/A";
if (!consumeFetchBudget_("yahoo_chart", sym)) return "N/A";
sym = sym.replace(/\^/g, "%5E");
const url = `https://query2.finance.yahoo.com/v8/finance/chart/${sym}?interval=1d&range=5d`;
try {
const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
if (resp.getResponseCode() !== 200) {
recordFetchFailure_("yahoo_chart");
cacheJsonSet_(cacheKey, "N/A", FETCH_GOVERNANCE.ttl.failure);
return "N/A";
}
const data = JSON.parse(resp.getContentText());
const closes = data?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.filter(c => c != null) ?? [];
if (closes.length < 2) {
recordFetchFailure_("yahoo_chart");
cacheJsonSet_(cacheKey, "N/A", FETCH_GOVERNANCE.ttl.failure);
return "N/A";
}
const last = closes[closes.length-1];
const prev = closes[closes.length-2];
const result = ((last/prev-1)*100).toFixed(2);
cacheJsonSet_(cacheKey, result, FETCH_GOVERNANCE.ttl.yahoo_chart_ok);
recordFetchSuccess_("yahoo_chart");
return result;
} catch(e) {
recordFetchFailure_("yahoo_chart");
cacheJsonSet_(cacheKey, "N/A", FETCH_GOVERNANCE.ttl.failure);
return "N/A";
}
}
// ── Core Satellite 청크 실행 ────────────────────────────────────────────
// 위성 후보군 스크리닝용 출력. 보유 종목 완성도 매트릭스는 data_feed가 본체.
// 100종목 이상 한 번에 실행하면 6분 제한 초과 위험 → 50종목씩 청크로 분할 (현재 유니버스 약 40여 개는 1번 실행에 완료됨)
// 트리거: runCoreSatelliteChunk → 매일 17:00~18:00, 별도 스크립트 프로젝트 권장
const CHUNK_SIZE = 50;
function runCoreSatelliteBatch() {
if (typeof isRunAllOrchestrated_ === "function" && isRunAllOrchestrated_()) {
if (typeof setFetchSessionLabel_ === "function") {
setFetchSessionLabel_("runCoreSatelliteBatch");
}
} else {
beginFetchSession_("runCoreSatelliteBatch");
}
const props = PropertiesService.getScriptProperties();
const universe = getCoreSatelliteUniverse(); // 아래 함수 참조
const totalChunks = Math.ceil(universe.length / CHUNK_SIZE);
const TIMEOUT_BUDGET_SEC = 210;
const startTime = new Date().getTime();
let chunkIdx = parseInt(props.getProperty("cs_chunk_idx") ?? "0", 10);
let rowIdx = parseInt(props.getProperty("cs_row_idx") ?? "0", 10);
const schemaVersion = props.getProperty("cs_schema_version") ?? "";
if (chunkIdx === 0 || schemaVersion !== SCHEMA_VERSION) {
resetCoreSatelliteChunks();
props.setProperty("cs_schema_version", SCHEMA_VERSION);
chunkIdx = 0;
rowIdx = 0;
}
if (chunkIdx >= totalChunks) {
// 모든 청크 완료 → unified 탭 업데이트 후 리셋
runCoreSatelliteFinalize();
props.setProperty("cs_chunk_idx", "0");
writeCoreSatelliteStatus_("FINALIZED", universe.length, universe.length, totalChunks, 0, "all chunks already complete");
Logger.log("core_satellite: 모든 청크 완료 → finalize");
return;
}
const slice = universe.slice(chunkIdx * CHUNK_SIZE, (chunkIdx+1) * CHUNK_SIZE);
const dataFeedMap = buildDataFeedPriceMap();
const dataFeedSellMap_ = buildDataFeedSellMap_();
const sheetName = `cs_chunk_${chunkIdx}`;
const headers = [
"Ticker","Name","Sector",
"Price_Date","Close","Open","PrevClose","High","Low","Volume","AvgVolume_5D","MA20","MA60","Ret10D","Ret20D","Ret60D","Price_Source","ATR20","ATR20_Pct","Val_Surge_Pct","AvgTradeValue_5D_M","AvgTradeValue_20D_M","AvgTradeValue_5D_KRW","AvgTradeValue_20D_KRW","TradeValue_Unit","Bid","Ask","Spread_Pct","Spread_Status","Spread_Source","Quote_Source","Quote_Status","Liquidity_Status",
"Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_OK","Flow_Rows",
"ETF_Ret5D","Rotation_Score","Alert_Level","Smart_Money",
"DART_Status","DART_Source","DART_Catalyst","DART_Risk",
"Missing_Fields","Next_Source_To_Check","Allowed_Action",
"Final_Action","Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Validation",
"Action_Reason","Action_Params","Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason",
"Timing_Action","Timing_Score_Entry","Timing_Score_Exit","Entry_Mode","Entry_Mode_Gate","Entry_Mode_Reason","Exit_Signal_Detail",
"Candidate_Quality_Grade","T1_Forced_Sell_Risk_Score","T1_Forced_Sell_Risk_State","T1_Forced_Sell_Risk_Reason",
"Sell_Conflict_Score","Sell_Conflict_State","Sell_Conflict_Reason","Execution_Recommendation_State","Execution_Recommendation_Reason",
"ChunkIdx","AsOfDate",
"RS_Rank_20D","RS_Pct_20D"
];
const rows = [];
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
if (rowIdx > 0) {
const existingSheet = getSpreadsheet_().getSheetByName(sheetName);
if (existingSheet) {
const existingData = existingSheet.getDataRange().getValues();
if (existingData.length > 2) {
for (const row of existingData.slice(2)) {
const normalized = Array.isArray(row) ? row.slice(0, headers.length) : [];
while (normalized.length < headers.length) normalized.push("");
rows.push(normalized);
}
}
}
}
if (rowIdx >= slice.length) {
props.setProperty("cs_row_idx", "0");
props.setProperty("cs_chunk_idx", String(chunkIdx + 1));
return runCoreSatelliteBatch();
}
for (; rowIdx < slice.length; rowIdx++) {
const t = slice[rowIdx];
const flow = fetchNaverFlow(t.code);
const price = resolveSatellitePriceMetrics(t.code, dataFeedMap);
const notices = fetchNaverDisclosureNotices(t.code);
const dartSummary = summarizeDisclosureNotices(notices);
const frg5 = flow.rows.slice(0,5).reduce((s,r)=>s+r.frgn, 0);
const inst5 = flow.rows.slice(0,5).reduce((s,r)=>s+r.inst, 0);
const frg20 = flow.rows.reduce((s,r)=>s+r.frgn, 0);
const inst20= flow.rows.reduce((s,r)=>s+r.inst, 0);
const indiv5= -(frg5+inst5);
Utilities.sleep(400);
const score = calcRotationScore(frg5, inst5, frg20, inst20, indiv5, null);
const alert = calcAlert(score, frg5, inst5);
const smart = calcSmartMoney(frg5, inst5, indiv5);
const priceStatus = price.ok ? "PRICE_OK" : "PRICE_MISSING";
const liquidityStatus = calcLiquidityStatus(Number(price.avgTradingValue5D));
const spreadStatus = calcSpreadStatus(Number(price.spreadPct));
const valSurgeStatus = calcValSurgeStatus(price.valSurge);
const missing = [];
if (!flow.ok) missing.push("FLOW");
if (!price.ok) missing.push("ATR20");
if (dartSummary.status === "NAVER_NOTICE_EMPTY" || String(dartSummary.status).startsWith("NAVER_NOTICE_ERROR")) missing.push("DART");
const next = [];
if (missing.includes("ATR20")) next.push("Yahoo Finance chart");
if (missing.includes("DART")) next.push("Naver 공시공지");
if (missing.includes("FLOW")) next.push("Naver frgn.naver");
const action = buildAllowedAction(score, priceStatus, price.atr20, dartSummary, flow.ok, price.avgTradingValue5D, price.spreadPct);
const sellRow_ = dataFeedSellMap_[normalizeTickerCode(t.code)] ?? null;
const sellFinal_ = String(sellRow_?.Final_Action ?? "").trim();
const sellAction_ = String(sellRow_?.Sell_Action ?? "").trim();
const sellHasSignal_ = sellFinal_ === "SELL_READY" || sellFinal_ === "EXIT_SIGNAL" || sellFinal_ === "EXIT_REVIEW";
const sellValidation_ = String(sellRow_?.Sell_Validation ?? "").trim() || (sellHasSignal_ ? "SIGNAL_CONFIRMED" : "SIGNAL_ONLY");
const sellRatio_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Sell_Ratio_Pct)) ? Number(sellRow_?.Sell_Ratio_Pct) : 0) : 0;
const sellQty_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Sell_Qty)) ? Number(sellRow_?.Sell_Qty) : 0) : 0;
const sellLimit_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Sell_Limit_Price)) ? Number(sellRow_?.Sell_Limit_Price) : "") : "";
const actionReason_ = sellHasSignal_ ? String(sellRow_?.Action_Reason ?? "") : "";
const actionParams_ = sellHasSignal_ ? String(sellRow_?.Action_Params ?? "") : "";
const cashStyle_ = sellHasSignal_ ? String(sellRow_?.Cash_Preserve_Style ?? "NONE") : "NONE";
const cashRatio_ = sellHasSignal_ ? (Number.isFinite(Number(sellRow_?.Cash_Preserve_Ratio)) ? Number(sellRow_?.Cash_Preserve_Ratio) : 0) : 0;
const cashReason_ = sellHasSignal_ ? String(sellRow_?.Cash_Preserve_Reason ?? "") : "";
const timingLocal_ = (price.ok && Array.isArray(price.rows) && price.rows.length >= 21)
? calcTimingMetrics_(price.rows) : {};
const entryLocal_ = (price.ok && Object.keys(timingLocal_).length)
? calcEntryMode_(timingLocal_, price) : { mode: "NEUTRAL", gate: "PENDING", reason: "데이터부족" };
const localTimingRoute_ = calcTimingRoute_({
priceStatus,
atr20: price.atr20,
flowCredit: "",
leaderTotal: "",
leaderGate: "",
acGate: "",
rwPartial: sellRow_?.RW_Partial,
entryMode: entryLocal_.mode,
entryModeGate: entryLocal_.gate,
exitSignalDetail: "",
rsi14: timingLocal_.rsi14,
disparity: timingLocal_.disparity,
ma20Slope: timingLocal_.ma20Slope,
spreadPct: price.spreadPct,
avgTradeValue5D: price.avgTradingValue5D,
profitPct: sellRow_?.Profit_Pct,
daysToTimeStop: sellRow_?.Days_To_Time_Stop,
});
const timingAction_ = String(sellRow_?.Timing_Action ?? localTimingRoute_.action ?? "");
const timingEntry_ = Number.isFinite(Number(sellRow_?.Timing_Score_Entry))
? Number(sellRow_?.Timing_Score_Entry) : localTimingRoute_.entry_score;
const timingExit_ = Number.isFinite(Number(sellRow_?.Timing_Score_Exit))
? Number(sellRow_?.Timing_Score_Exit) : localTimingRoute_.exit_score;
const entryMode_ = String(sellRow_?.Entry_Mode ?? entryLocal_.mode ?? "");
const entryGate_ = String(sellRow_?.Entry_Mode_Gate ?? entryLocal_.gate ?? "");
const entryReason_ = String(sellRow_?.Entry_Mode_Reason ?? entryLocal_.reason ?? "");
const exitSignalDetail_ = String(sellRow_?.Exit_Signal_Detail ?? "");
const candidateQuality_ = calcCoreCandidateQualityGrade_({
rotationScore: score,
flowOk: flow.ok ? "Y" : "N",
priceStatus,
liquidityStatus,
dartRisk: dartSummary.risk,
missingFields: missing.join(" | "),
});
const t1Risk_ = calcT1ForcedSellRisk_({
sellAction: sellAction_,
sellValidation: sellValidation_,
timingScoreExit: timingExit_,
rwPartial: sellRow_?.RW_Partial,
rsi14: timingLocal_.rsi14,
disparity: timingLocal_.disparity,
valSurgePct: price.valSurge,
ret5D: price.ret5D,
dartRisk: dartSummary.risk,
lateChaseRiskScore: sellRow_ ? sellRow_["Late_Chase_Risk_Score"] : "",
distributionRiskScore: sellRow_ ? sellRow_["Distribution_Risk_Score"] : "",
});
const sellConflict_ = calcSellConflictScore_({
sellFinal: sellFinal_,
sellAction: sellAction_,
cashPreserveStyle: cashStyle_,
allowedAction: action,
});
const executionState_ = calcCoreSatelliteExecutionState_({
candidateQualityGrade: candidateQuality_,
timingAction: timingAction_,
entryModeGate: entryGate_,
t1State: t1Risk_.state,
sellConflictState: sellConflict_.state,
allowedAction: action,
});
const executionReason_ = [
`quality=${candidateQuality_}`,
`timing=${timingAction_}`,
`t1=${t1Risk_.state}`,
`sell_conflict=${sellConflict_.state}`,
].join("|");
rows.push([
t.code, t.name, t.sector ?? "",
price.ok ? price.priceDate : today,
price.ok ? price.close : "N/A",
price.ok && Number.isFinite(price.open) ? price.open : "N/A",
price.ok && Number.isFinite(price.prevClose) ? price.prevClose : "N/A",
price.ok && Number.isFinite(price.high) ? price.high : "N/A",
price.ok && Number.isFinite(price.low) ? price.low : "N/A",
price.ok && Number.isFinite(price.volume) ? price.volume : "N/A",
price.ok && Number.isFinite(price.avgVolume5D) ? Math.round(price.avgVolume5D) : "N/A",
price.ok && Number.isFinite(price.ma20) ? Number(price.ma20).toFixed(2) : "N/A",
price.ok && Number.isFinite(price.ma60) ? Number(price.ma60).toFixed(2) : "N/A",
price.ok && Number.isFinite(price.ret10D) ? Number(price.ret10D).toFixed(2) : "N/A",
price.ok && Number.isFinite(price.ret20D) ? Number(price.ret20D).toFixed(2) : "N/A",
price.ok && Number.isFinite(price.ret60D) ? Number(price.ret60D).toFixed(2) : "N/A",
price.ok ? String(price.source ?? "") : "N/A",
price.ok && Number.isFinite(price.atr20) ? Math.round(price.atr20) : "N/A",
price.ok && Number.isFinite(price.atr20Pct) ? Number(price.atr20Pct).toFixed(2) : "N/A",
price.ok && Number.isFinite(price.valSurge) ? Number(price.valSurge).toFixed(1) : "N/A",
Number.isFinite(price.avgTradingValue5D) ? price.avgTradingValue5D.toFixed(2) : "N/A",
Number.isFinite(price.avgTradingValue20D) ? price.avgTradingValue20D.toFixed(2) : "N/A",
Number.isFinite(price.avgTradingValue5D) ? Math.round(price.avgTradingValue5D * 1000000) : "N/A",
Number.isFinite(price.avgTradingValue20D) ? Math.round(price.avgTradingValue20D * 1000000) : "N/A",
"KRW",
Number.isFinite(price.bid) ? price.bid : "N/A",
Number.isFinite(price.ask) ? price.ask : "N/A",
Number.isFinite(price.spreadPct) ? price.spreadPct.toFixed(2) : "N/A",
spreadStatus,
price.source ?? "N/A",
price.quoteSource ?? "QUOTE_NO_MATCH",
price.quoteStatus ?? "QUOTE_NO_MATCH",
liquidityStatus,
frg5, inst5, indiv5, frg20, inst20, flow.ok ? "Y" : "N", String(flow.rows.length),
"N/A", score, alert, smart,
dartSummary.status,
dartSummary.source,
dartSummary.catalyst,
dartSummary.risk,
missing.length ? missing.join(" | ") : "",
next.length ? next.join(" | ") : "",
action,
sellFinal_ || "HOLD",
sellAction_ || "HOLD",
sellRatio_,
sellQty_,
sellLimit_,
sellValidation_,
actionReason_,
actionParams_,
cashStyle_,
cashRatio_,
cashReason_,
timingAction_,
timingEntry_,
timingExit_,
entryMode_,
entryGate_,
entryReason_,
exitSignalDetail_,
candidateQuality_,
t1Risk_.score,
t1Risk_.state,
t1Risk_.reason,
sellConflict_.score,
sellConflict_.state,
sellConflict_.reason,
executionState_,
executionReason_,
String(chunkIdx), today,
"", "" // RS_Rank_20D, RS_Pct_20D — finalize 단계에서 채워짐
]);
const elapsedSec = (new Date().getTime() - startTime) / 1000;
if (elapsedSec > TIMEOUT_BUDGET_SEC) {
writeToSheet(sheetName, headers, rows);
props.setProperty("cs_chunk_idx", String(chunkIdx));
props.setProperty("cs_row_idx", String(rowIdx + 1));
writeCoreSatelliteStatus_(
"PARTIAL_SAVED",
universe.length,
Math.min(chunkIdx * CHUNK_SIZE + rowIdx + 1, universe.length),
totalChunks,
chunkIdx,
`chunk ${chunkIdx + 1}/${totalChunks} partial saved at row ${rowIdx + 1}/${slice.length}`
);
Logger.log(`core_satellite chunk ${chunkIdx} partial saved at row ${rowIdx + 1}/${slice.length}`);
throw new Error("PARTIAL_SAVE_REQUESTED");
}
}
// 청크 데이터를 임시 시트에 누적 저장
writeToSheet(sheetName, headers, rows);
props.setProperty("cs_chunk_idx", String(chunkIdx + 1));
props.setProperty("cs_row_idx", "0");
writeCoreSatelliteStatus_(
chunkIdx + 1 >= totalChunks ? "FINALIZING" : "IN_PROGRESS",
universe.length,
Math.min((chunkIdx + 1) * CHUNK_SIZE, universe.length),
totalChunks,
chunkIdx + 1,
`chunk ${chunkIdx + 1}/${totalChunks} written`
);
Logger.log(`core_satellite chunk ${chunkIdx}/${totalChunks-1} 완료: ${rows.length}종목`);
// 마지막 청크는 같은 실행에서 즉시 finalize한다.
if (chunkIdx + 1 >= totalChunks) {
runCoreSatelliteFinalize();
props.setProperty("cs_chunk_idx", "0");
props.setProperty("cs_row_idx", "0");
writeCoreSatelliteStatus_("COMPLETE", universe.length, universe.length, totalChunks, 0, "finalize complete");
Logger.log("core_satellite: 마지막 청크 완료 → finalize");
}
}
function writeCoreSatelliteStatus_(status, universeCount, processedCount, totalChunks, nextChunkIdx, detail) {
const updatedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
const coverage = universeCount > 0 ? roundNum((processedCount / universeCount) * 100, 2) : 0;
PropertiesService.getScriptProperties().setProperty("cs_status", JSON.stringify({
status, universeCount, processedCount,
coveragePct: coverage, chunkSize: CHUNK_SIZE,
totalChunks, nextChunkIdx, updatedAt, detail: detail || ""
}));
}
function runCoreSatelliteFinalize() {
// 모든 cs_chunk_N 탭을 합쳐 core_satellite 탭에 기록
const ss = getSpreadsheet_();
const allRows = [];
const headers = [
"Ticker","Name","Sector",
"Price_Date","Close","Open","PrevClose","High","Low","Volume","AvgVolume_5D","MA20","MA60","Ret10D","Ret20D","Ret60D","Price_Source","ATR20","ATR20_Pct","Val_Surge_Pct","AvgTradeValue_5D_M","AvgTradeValue_20D_M","AvgTradeValue_5D_KRW","AvgTradeValue_20D_KRW","TradeValue_Unit","Bid","Ask","Spread_Pct","Spread_Status","Spread_Source","Quote_Source","Quote_Status","Liquidity_Status",
"Frg_5D","Inst_5D","Indiv_5D","Frg_20D","Inst_20D","Flow_OK","Flow_Rows",
"ETF_Ret5D","Rotation_Score","Alert_Level","Smart_Money",
"DART_Status","DART_Source","DART_Catalyst","DART_Risk",
"Missing_Fields","Next_Source_To_Check","Allowed_Action",
"Final_Action","Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Validation",
"Action_Reason","Action_Params","Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason",
"Timing_Action","Timing_Score_Entry","Timing_Score_Exit","Entry_Mode","Entry_Mode_Gate","Entry_Mode_Reason","Exit_Signal_Detail",
"Candidate_Quality_Grade","T1_Forced_Sell_Risk_Score","T1_Forced_Sell_Risk_State","T1_Forced_Sell_Risk_Reason",
"Sell_Conflict_Score","Sell_Conflict_State","Sell_Conflict_Reason","Execution_Recommendation_State","Execution_Recommendation_Reason",
"ChunkIdx","AsOfDate",
"RS_Rank_20D","RS_Pct_20D"
];
let chunkIdx = 0;
while (true) {
const s = ss.getSheetByName(`cs_chunk_${chunkIdx}`);
if (!s) break;
const data = s.getDataRange().getValues();
// row[0] = updated 메타, row[1] = 헤더, row[2..] = 데이터
if (data.length > 2) {
for (const row of data.slice(2)) {
const normalized = Array.isArray(row) ? row.slice(0, headers.length) : [];
while (normalized.length < headers.length) normalized.push("");
allRows.push(normalized);
}
}
s.hideSheet(); // 임시 탭 숨김
chunkIdx++;
}
if (allRows.length === 0) return;
// ── 섹터별 Ret20D 상대강도 순위 계산 ──────────────────────────────────
// Sector=index 2, Ret20D=index 14, RS_Rank_20D=index 53, RS_Pct_20D=index 54
const SECTOR_IDX = 2;
const RET20D_IDX = 14;
const RS_RANK_IDX = headers.indexOf("RS_Rank_20D");
const RS_PCT_IDX = headers.indexOf("RS_Pct_20D");
const TICKER_IDX = headers.indexOf("Ticker");
if (TICKER_IDX >= 0) {
allRows.forEach(r => { r[TICKER_IDX] = normalizeTickerCode(r[TICKER_IDX]); });
}
const sectorGroups = {};
allRows.forEach((r, i) => {
const sector = String(r[SECTOR_IDX] ?? "").trim();
const ret20D = parseFloat(r[RET20D_IDX]);
if (!sector || !Number.isFinite(ret20D)) return;
if (!sectorGroups[sector]) sectorGroups[sector] = [];
sectorGroups[sector].push({ rowIdx: i, ret20D });
});
for (const group of Object.values(sectorGroups)) {
group.sort((a, b) => b.ret20D - a.ret20D); // 수익률 높을수록 rank=1
group.forEach((item, rankIdx) => {
allRows[item.rowIdx][RS_RANK_IDX] = rankIdx + 1;
// 백분위: 1위=100, 꼴찌=0 (섹터 내 상대 위치)
allRows[item.rowIdx][RS_PCT_IDX] = Math.round((1 - rankIdx / group.length) * 100);
});
}
writeToSheet("core_satellite", headers, allRows);
writeCoreSatelliteStatus_("COMPLETE", allRows.length, allRows.length, chunkIdx, 0, "core_satellite finalized from chunk sheets");
deleteCoreSatelliteChunkSheets_("finalize complete");
Logger.log(`core_satellite finalize 완료: ${allRows.length}종목`);
}
function deleteCoreSatelliteChunkSheets_(reason) {
const ss = getSpreadsheet_();
const sheets = ss.getSheets();
let deleted = 0;
for (const sheet of sheets) {
const name = sheet.getName();
if (/^cs_chunk_\d+$/.test(name)) {
ss.deleteSheet(sheet);
deleted++;
}
}
if (deleted > 0) {
Logger.log(`core_satellite 임시 청크 시트 삭제: ${deleted}개 (${reason || "cleanup"})`);
}
return deleted;
}
// ── Rotation Score / Alert / SmartMoney 공통 계산 ────────────────────────────
function calcRotationScore(frg5, inst5, frg20, inst20, indiv5, etf) {
let score = 0;
if (frg5>0 && inst5>0) score+=40; else if(frg5>0||inst5>0) score+=20;
if (frg20>0 && inst20>0) score+=20; else if(frg20>0||inst20>0) score+=10;
if (etf?.ok) { if(+etf.ret5D>0) score+=10; if(+etf.ret20D>0) score+=10; }
if ((frg5>0||inst5>0) && indiv5<0) score+=10;
return Math.max(0, Math.min(100, score));
}
function calcAlert(score, frg5, inst5) {
return score>=70&&frg5>0&&inst5>0 ? "INFLOW_STRONG" :
score>=50 ? "INFLOW_MODERATE" :
score>=30 ? "NEUTRAL" :
frg5<0&&inst5<0 ? "OUTFLOW_ALERT" : "OUTFLOW_CAUTION";
}
function calcSmartMoney(frg5, inst5, indiv5) {
return frg5>0&&inst5>0&&indiv5<0 ? "STRONG" :
(frg5>0||inst5>0)&&indiv5<0 ? "MODERATE" :
frg5>0||inst5>0 ? "WEAK" : "ABSENT";
}
function buildDataFeedPriceMap() {
const holdings = sheetToJson("data_feed");
const map = {};
for (const row of holdings) {
const ticker = normalizeTickerCode(row.Ticker);
if (!ticker) continue;
map[ticker] = row;
}
return map;
}
function buildDataFeedSellMap_() {
const holdings = sheetToJson("data_feed");
const map = {};
for (const row of holdings) {
const ticker = normalizeTickerCode(row.Ticker);
if (!ticker) continue;
map[ticker] = row;
}
return map;
}
function resolveDataFeedPriceMetrics(code) {
const ticker = normalizeTickerCode(code);
const price = fetchNaverOhlcMetrics(ticker);
if (price.ok) return price;
const yahooOhlc = fetchYahooOhlcMetrics(ticker);
if (yahooOhlc.ok) return yahooOhlc;
const naverFallback = fetchNaverMarketMetrics(ticker);
if (naverFallback.ok) {
return {
ok: true,
source: "Naver Finance main",
isFallbackQuote: true, // OHLC 없음 — MA/ATR/ValSurge 결측
isPriceStale: false, // 실시간 호가 — 날짜 스테일 아님
priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"),
close: Number(naverFallback.marketPrice),
open: null,
high: null,
low: null,
volume: null,
prevClose: null,
avgVolume5D: null,
ma20: null,
ma60: null,
ret10D: null,
ret20D: null,
ret60D: null,
atr20: null,
atr20Pct: null,
valSurge: null,
avgTradingValue5D: null,
avgTradingValue20D: null,
ret5D: null,
bid: Number.isFinite(naverFallback.bid) ? naverFallback.bid : null,
ask: Number.isFinite(naverFallback.ask) ? naverFallback.ask : null,
spreadPct: Number.isFinite(naverFallback.spreadPct) ? naverFallback.spreadPct : null,
quoteSource: naverFallback.source ?? "naver_main",
quoteStatus: naverFallback.quoteStatus ?? "NAVER_QUOTE_NO_MATCH",
quoteHttpStatus: naverFallback.httpStatus ?? null,
error: price.error || naverFallback.error || ""
};
}
const fallback = fetchYahooPrice(ticker);
if (fallback.ok) {
return {
ok: true,
source: "Yahoo Finance close",
isFallbackQuote: true, // OHLC 없음 — MA/ATR/ValSurge 결측
isPriceStale: false, // 실시간 가격 — 날짜 스테일 아님
priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"),
close: Number(fallback.close),
open: null,
high: null,
low: null,
volume: null,
prevClose: null,
avgVolume5D: null,
ma20: null,
ma60: null,
ret10D: null,
ret20D: fallback.ret20D,
ret60D: null,
atr20: null,
atr20Pct: null,
valSurge: null,
avgTradingValue5D: null,
avgTradingValue20D: null,
bid: null,
ask: null,
spreadPct: null,
quoteSource: "QUOTE_NO_MATCH",
quoteStatus: "QUOTE_NO_MATCH",
ret5D: fallback.ret5D,
ret20D: fallback.ret20D,
error: price.error || fallback.error || ""
};
}
return { ok: false, error: price.error || fallback.error || "PRICE_MISSING" };
}
function resolveSatellitePriceMetrics(code, dataFeedMap) {
const ticker = normalizeTickerCode(code);
const local = dataFeedMap?.[ticker] || null;
if (local && String(local.Price_Status ?? "").toUpperCase() === "PRICE_OK") {
const parseNum = (v) => (v === "" || v == null || isNaN(Number(v))) ? null : Number(v);
const close = parseNum(local.Close);
const atr20 = parseNum(local.ATR20);
const avgTradingValue5D = parseNum(local.AvgTradingValue_5D_M ?? local.Avg_TradingValue_5D_M ?? local.AvgTradeValue_5D_M);
const avgTradingValue20D = parseNum(local.AvgTradingValue_20D_M ?? local.Avg_TradingValue_20D_M ?? local.AvgTradeValue_20D_M);
const bid = parseNum(local.Bid);
const ask = parseNum(local.Ask);
const spreadPct = parseNum(local.Spread_Pct ?? local.SpreadPct);
return {
ok: Number.isFinite(close),
source: "data_feed",
priceDate: String(local.Price_Date ?? ""),
close: close !== null ? close : "",
open: parseNum(local.Open),
high: parseNum(local.High),
low: parseNum(local.Low),
volume: parseNum(local.Volume),
prevClose: parseNum(local.PrevClose),
avgVolume5D: parseNum(local.AvgVolume_5D),
ma20: parseNum(local.MA20),
ma60: parseNum(local.MA60),
ret10D: parseNum(local.Ret10D),
ret20D: parseNum(local.Ret20D),
ret60D: parseNum(local.Ret60D),
atr20: atr20,
atr20Pct: parseNum(local.ATR20_Pct),
valSurge: parseNum(local.Val_Surge_Pct),
avgTradingValue5D: avgTradingValue5D,
avgTradingValue20D: avgTradingValue20D,
bid: bid,
ask: ask,
spreadPct: spreadPct,
quoteSource: String(local.Quote_Source ?? local.quoteSource ?? local.Spread_Source ?? "data_feed"),
quoteStatus: String(local.Quote_Status ?? local.quoteStatus ?? "QUOTE_NO_MATCH")
};
}
const price = fetchNaverOhlcMetrics(ticker);
if (price.ok) return price;
const yahooOhlc = fetchYahooOhlcMetrics(ticker);
if (yahooOhlc.ok) return yahooOhlc;
const naverFallback = fetchNaverMarketMetrics(ticker);
if (naverFallback.ok) {
return {
ok: true,
source: "Naver Finance main",
isFallbackQuote: true,
isPriceStale: false,
priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"),
close: Number(naverFallback.marketPrice),
open: null,
high: null,
low: null,
volume: null,
prevClose: null,
avgVolume5D: null,
ma20: null,
ma60: null,
ret10D: null,
ret20D: null,
ret60D: null,
atr20: null,
atr20Pct: null,
valSurge: null,
avgTradingValue5D: null,
avgTradingValue20D: null,
bid: Number.isFinite(naverFallback.bid) ? naverFallback.bid : null,
ask: Number.isFinite(naverFallback.ask) ? naverFallback.ask : null,
spreadPct: Number.isFinite(naverFallback.spreadPct) ? naverFallback.spreadPct : null,
quoteSource: naverFallback.source ?? "naver_main",
quoteStatus: naverFallback.quoteStatus ?? "NAVER_QUOTE_NO_MATCH",
quoteHttpStatus: naverFallback.httpStatus ?? null,
ret5D: null,
ret20D: null
};
}
const fallback = fetchYahooPrice(ticker);
if (fallback.ok) {
return {
ok: true,
source: "Yahoo Finance close",
isFallbackQuote: true,
isPriceStale: false,
priceDate: Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd"),
close: Number(fallback.close),
atr20: null,
atr20Pct: null,
valSurge: null,
avgTradingValue5D: null,
avgTradingValue20D: null,
bid: null,
ask: null,
spreadPct: null,
quoteSource: "QUOTE_NO_MATCH",
quoteStatus: "QUOTE_NO_MATCH",
ret5D: fallback.ret5D,
ret20D: fallback.ret20D
};
}
return { ok: false, error: price.error || fallback.error || "PRICE_MISSING" };
}
function fetchTrendingTickers() {
const cacheKey = `trending_tickers_${Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd")}`;
const cached = getCachedFetchResult_(cacheKey);
if (cached && Array.isArray(cached)) return cached;
const url = "https://finance.naver.com/sise/sise_quant.naver"; // 거래상위 (Top Volume)
const tickers = [];
try {
const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
if (resp.getResponseCode() === 200) {
const html = resp.getContentText("EUC-KR");
const pattern = /<a href="\/item\/main\.naver\?code=([0-9]{6})" class="tltle">([^<]+)<\/a>/g;
let match;
while ((match = pattern.exec(html)) !== null) {
const name = match[2].trim();
// ETF/ETN 등 제외
if (!name.includes("KODEX") && !name.includes("TIGER") && !name.includes("인버스") && !name.includes("레버리지") && !name.includes("KOSEF") && !name.includes("HANARO") && !name.includes("KBSTAR") && !name.includes("ACE")) {
tickers.push({ code: match[1], name: name, sector: "Dynamic(거래상위)" });
}
if (tickers.length >= 10) break; // Top 10 Dynamic stocks
}
}
} catch(e) {
handleFetchError_("fetchTrendingTickers", e, "WARN");
}
if (tickers.length > 0) {
cacheJsonSet_(CACHE_VERSION + cacheKey, tickers, 12 * 60 * 60);
}
return tickers;
}
// ── Core Satellite 종목 유니버스 ──────────────────────────────────────────────
// 실제 운용 시 ScriptProperties 또는 별도 시트에서 로드 권장
function getCoreSatelliteUniverse() {
const ss = getSpreadsheet_();
let sheetUniverse = ss.getSheetByName("universe");
let list = [];
let purgedCount = 0;
const nowMs = Date.now();
const MAX_DAYS = 14; // 동적 발굴 종목의 기본 모니터링 수명 (14일간 눌림목 추적)
const todayStr = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
// 1. 기존 유니버스 시트가 있으면 읽어온다.
if (sheetUniverse) {
const data = sheetUniverse.getDataRange().getValues();
if (data.length > 1) {
for (let i = 1; i < data.length; i++) {
const r = data[i];
if (!r[0]) continue;
const code = normalizeTickerCode(r[0]);
const name = String(r[1]||"").trim();
const sector = String(r[2]||"").trim();
const addedDateStr = String(r[3]||"").trim() || todayStr; // 없으면 오늘로 간주
// 14일 경과된 Dynamic 종목 자동 삭제 로직 (사용자가 섹터명을 바꾸면 영구보존)
if (sector.toUpperCase().startsWith("DYNAMIC")) {
const addedMs = Date.parse(addedDateStr);
if (!isNaN(addedMs) && (nowMs - addedMs) / (1000 * 60 * 60 * 24) > MAX_DAYS) {
purgedCount++;
continue; // 리스트에 추가하지 않음 (삭제)
}
}
list.push({ code, name, sector, addedDate: addedDateStr });
}
}
} else {
// 시트가 없으면 새로 생성하고 헤더를 쓴다.
sheetUniverse = ss.insertSheet("universe");
sheetUniverse.getRange(1, 1, 1, 4).setValues([["Ticker", "Name", "Sector", "AddedDate"]]);
sheetUniverse.getRange("A:A").setNumberFormat("@"); // 코드 열 텍스트 지정
}
// 2. 읽어온 데이터가 아예 없으면 (최초 실행) 기본 AI 리스트로 초기화한다.
if (list.length === 0) {
const defaults = [
// AI 반도체 & 메모리
{ code:"005930", name:"삼성전자", sector:"반도체" },
{ code:"000660", name:"SK하이닉스", sector:"반도체" },
{ code:"042700", name:"한미반도체", sector:"반도체" },
{ code:"007660", name:"이수페타시스",sector:"반도체/PCB" },
{ code:"403870", name:"HPSP", sector:"반도체/장비" },
{ code:"058470", name:"리노공업", sector:"반도체/부품" },
// AI 전력/인프라/발전
{ code:"010120", name:"LS ELECTRIC",sector:"AI전력/기기" },
{ code:"267260", name:"HD현대일렉트릭",sector:"AI전력/기기" },
{ code:"298040", name:"효성중공업", sector:"AI전력/기기" },
{ code:"006260", name:"LS", sector:"AI전력/전선" },
{ code:"001440", name:"대한전선", sector:"AI전력/전선" },
{ code:"034020", name:"두산에너빌리티",sector:"AI인프라/발전" },
{ code:"028050", name:"삼성E&A", sector:"AI인프라/EPC" },
// 방산
{ code:"012450", name:"한화에어로스페이스",sector:"방산" },
{ code:"064350", name:"현대로템", sector:"방산" },
{ code:"079550", name:"LIG넥스원", sector:"방산" },
// 조선
{ code:"329180", name:"HD현대중공업",sector:"조선" },
{ code:"042660", name:"한화오션", sector:"조선" },
{ code:"009540", name:"HD한국조선해양",sector:"조선" },
// 자동차
{ code:"005380", name:"현대차", sector:"자동차" },
{ code:"000270", name:"기아", sector:"자동차" },
// 은행 / 증권 / 지주회사
{ code:"105560", name:"KB금융", sector:"은행" },
{ code:"055550", name:"신한지주", sector:"은행" },
{ code:"024110", name:"기업은행", sector:"은행" },
{ code:"071050", name:"한국금융지주", sector:"증권" },
{ code:"006800", name:"미래에셋증권", sector:"증권" },
{ code:"005940", name:"NH투자증권", sector:"증권" },
{ code:"180640", name:"한진칼", sector:"지주회사" },
{ code:"267250", name:"HD현대", sector:"지주회사" },
{ code:"034730", name:"SK", sector:"지주회사" },
// 바이오
{ code:"207940", name:"삼성바이오로직스",sector:"바이오" },
{ code:"068270", name:"셀트리온", sector:"바이오" },
{ code:"128940", name:"한미약품", sector:"바이오" },
{ code:"000100", name:"유한양행", sector:"바이오" },
// 2차전지
{ code:"373220", name:"LG에너지솔루션",sector:"2차전지" },
{ code:"006400", name:"삼성SDI", sector:"2차전지" },
{ code:"003670", name:"포스코퓨처엠",sector:"2차전지" },
// 지주/기타
{ code:"028260", name:"삼성물산", sector:"지주회사" }
];
list = defaults.map(t => ({ ...t, addedDate: todayStr }));
const initialRows = list.map(t => [t.code, t.name, t.sector, t.addedDate]);
// 헤더가 없을 수 있으므로 전체 초기화
sheetUniverse.clearContents();
sheetUniverse.getRange(1, 1, 1, 4).setValues([["Ticker", "Name", "Sector", "AddedDate"]]);
sheetUniverse.getRange(2, 1, initialRows.length, 4).setValues(initialRows);
}
// 3. 만료된 종목이 있어서 정리(Purge)를 했다면 시트를 새로고침
if (purgedCount > 0) {
sheetUniverse.clearContents();
sheetUniverse.getRange(1, 1, 1, 4).setValues([["Ticker", "Name", "Sector", "AddedDate"]]);
if (list.length > 0) {
const validRows = list.map(t => [t.code, t.name, t.sector, t.addedDate]);
sheetUniverse.getRange(2, 1, validRows.length, 4).setValues(validRows);
}
Logger.log(`[Auto-Clean] 14일 경과된 노이즈 종목 ${purgedCount}개 자동 삭제 완료.`);
}
// 4. 동적 주도주(거래급증) 자동 발굴
const dynamicTickers = fetchTrendingTickers();
const existingCodes = new Set(list.map(x => x.code));
const newDiscovered = [];
for (const t of dynamicTickers) {
if (!existingCodes.has(t.code)) {
t.sector = "Dynamic";
t.addedDate = todayStr;
list.push(t);
newDiscovered.push([t.code, t.name, t.sector, t.addedDate]);
existingCodes.add(t.code);
}
}
// 5. 새롭게 발견된 주도주 시트 바닥에 추가
if (newDiscovered.length > 0) {
const lastRow = sheetUniverse.getLastRow() || 1;
sheetUniverse.getRange(lastRow + 1, 1, newDiscovered.length, 4).setValues(newDiscovered);
Logger.log(`[Discovery] 새로운 주도주 ${newDiscovered.length}개 발견 및 universe 시트에 영구 저장 완료.`);
}
return list;
}
function resetCoreSatelliteChunks() {
const ss = getSpreadsheet_();
const props = PropertiesService.getScriptProperties();
deleteCoreSatelliteChunkSheets_("reset before chunk run");
props.setProperty("cs_chunk_idx", "0");
props.setProperty("cs_row_idx", "0");
writeCoreSatelliteStatus_("RESET", 0, 0, 0, 0, "chunk sheets cleared");
}
/**
* data_feed 시트에서 ticker → 시장데이터 맵 구성
* H2~H5에 필요한 컬럼을 모두 읽는다.
*/
function buildDataFeedMap_(ss) {
var map = {};
var sheet = ss.getSheetByName(DATA_FEED_SHEET_NAME);
if (!sheet) return map;
var data = sheet.getDataRange().getValues();
if (data.length <= DF_HEADER_ROW_IDX) return map;
var headers = data[DF_HEADER_ROW_IDX];
var c = buildColIdx_(headers);
for (var i = DF_HEADER_ROW_IDX + 1; i < data.length; i++) {
var row = data[i];
var ticker = normTicker_(c['Ticker'] !== undefined ? row[c['Ticker']] : '');
if (!ticker) continue;
map[ticker] = {
ticker: ticker,
name: strCol_(row, c, 'Name'),
atr20: numCol_(row, c, 'ATR20'),
close: numCol_(row, c, 'Close') || numCol_(row, c, 'Close_Price'),
ma20: numCol_(row, c, 'MA20'),
ma60: numCol_(row, c, 'MA60'),
ma20Slope: numCol_(row, c, 'MA20_Slope'),
rsi14: numCol_(row, c, 'RSI14'),
bbPosition: numCol_(row, c, 'BB_Position'),
leaderTotal: numCol_(row, c, 'Leader_Scan_Total'),
leaderGate: strCol_(row, c, 'Leader_Gate'),
bandStatus: strCol_(row, c, 'Band_Status'),
grade: strCol_(row, c, 'SS001_Grade') || strCol_(row, c, 'Grade'),
flowCredit: numCol_(row, c, 'Flow_Credit'),
flowOk: strCol_(row, c, 'Flow_OK'),
rwPartial: numCol_(row, c, 'RW_Partial'),
finalAction: strCol_(row, c, 'Final_Action'),
sellSignal: strCol_(row, c, 'Sell_Signal'),
sellRatioPct: numCol_(row, c, 'Sell_Ratio_Pct'),
sellLimitPrice: numCol_(row, c, 'Sell_Limit_Price'),
weightTargetPct: numCol_(row, c, 'Weight_Target_Pct')
|| numCol_(row, c, 'Target_Weight_Pct'),
avgTradeVal5d: numCol_(row, c, 'AvgTradeValue_5D_M'),
avgTradeVal20d: numColN_(row, c, 'AvgTradeValue_20D_M'), // H6: secular_leader 과열신호 3번째 조건
valSurgePct: numColN_(row, c, 'Val_Surge_Pct'),
targetPrice: numColN_(row, c, 'Target_Price'),
upsidePct: numColN_(row, c, 'Upside_Pct'),
positionClass: strCol_(row, c, 'Position_Class')
|| strCol_(row, c, 'position_class'),
isDuplicateEtf: strCol_(row, c, 'Duplicate_ETF') === 'Y'
|| strCol_(row, c, 'Is_Duplicate_ETF') === 'Y',
frg5d: numColN_(row, c, 'Frg_5D'),
inst5d: numColN_(row, c, 'Inst_5D'),
frg20d: numColN_(row, c, 'Frg_20D'),
inst20d: numColN_(row, c, 'Inst_20D'),
ret5d: numColN_(row, c, 'Ret5D'),
ret20d: numColN_(row, c, 'Ret20D'),
prevClose: numColN_(row, c, 'PrevClose'),
high: numColN_(row, c, 'High'),
low: numColN_(row, c, 'Low'),
volume: numColN_(row, c, 'Volume'),
avgVolume5d: numColN_(row, c, 'AvgVolume_5D'),
sellQty: numColN_(row, c, 'Sell_Qty'), // M3: 선행 계산값 직접 사용
acTotal: numColN_(row, c, 'AC_Total'), // H3: secular_leader_gate
acGate: strCol_(row, c, 'AC_Gate'), // H3: anti_climax 판정
liquidityStatus: strCol_(row, c, 'Liquidity_Status'),
spreadStatus: strCol_(row, c, 'Spread_Status'),
dartRiskStatus: strCol_(row, c, 'DART_Risk') || strCol_(row, c, 'DART_Status'),
dartRisk: strCol_(row, c, 'DART_Risk'),
eventHoldDays: numColN_(row, c, 'Event_Hold_Days'), // M4: 이벤트 홀드 잔여일
high52w: numColN_(row, c, 'High_52W') || numColN_(row, c, 'High52W'), // L4
// ── [2026-05-21_BRT_HARNESS_V1] BRT/RS/Composite 판정 ──────────────
stock_drawdown_from_high_pct: numColN_(row, c, 'Stock_Drawdown_From_High_Pct'),
excess_drawdown_pctp: numColN_(row, c, 'Excess_Drawdown_PctP'),
recovery_ratio_5d: numColN_(row, c, 'Recovery_Ratio_5D'),
recovery_ratio_20d: numColN_(row, c, 'Recovery_Ratio_20D'),
downside_beta: numColN_(row, c, 'Downside_Beta'),
rs_line_20d_slope: numColN_(row, c, 'RS_Line_20D_Slope'),
rs_line_60d_slope: numColN_(row, c, 'RS_Line_60D_Slope'),
brt_verdict: strCol_(row, c, 'BRT_Verdict'),
brt_method: strCol_(row, c, 'BRT_Method'),
excess_ret_10d: numColN_(row, c, 'Excess_Ret_10D'),
rs_verdict_v1_raw: strCol_(row, c, 'RS_Verdict_V1_Raw'),
rs_verdict: strCol_(row, c, 'RS_Verdict'),
composite_verdict: strCol_(row, c, 'Composite_Verdict'),
saqg_v1: strCol_(row, c, 'SAQG_V1'),
saqg_penalty: numColN_(row, c, 'SAQG_Penalty'),
saqg_failed_filters: strCol_(row, c, 'SAQG_Failed_Filters'),
rag_v1: strCol_(row, c, 'RAG_Verdict'),
rag_reason: strCol_(row, c, 'RAG_Reason'),
// ── 재무 건전성 — FINANCIAL_HEALTH_V1 + OCF_B (7일 캐시 수집) ───────────
roe_pct: numColN_(row, c, 'ROE_Pct'),
opm_pct: numColN_(row, c, 'Operating_Margin_Pct'),
debt_ratio_pct: numColN_(row, c, 'Debt_To_Equity'),
current_ratio: numColN_(row, c, 'Current_Ratio'),
free_cf_krw: numColN_(row, c, 'FCF_B') != null
? numColN_(row, c, 'FCF_B') * 1e8 : null, // 억원 → 원
operating_cf_krw: numColN_(row, c, 'OCF_B') != null
? numColN_(row, c, 'OCF_B') * 1e8 : null, // 억원 → 원 (신규)
revenue_growth_pct: numColN_(row, c, 'Revenue_Growth_Pct'),
};
}
return map;
}
/**
* account_snapshot 파싱
* 반환값: H1 집계값(aggregate) + H2~H5용 holdings 배열(per-holding)
*/
function parseAccountSnapshot_(ss, totalAssetKrw, dfMap) {
var result = {
capturedAt: null,
immediateCashKrw: 0,
settlementCashD2Krw: 0,
openOrderAmountKrw: 0,
totalHeatKrw: 0,
heatRowsCount: 0,
heatAtrEstimated: false,
derivedTotalAsset: 0,
holdings: []
};
var sheet = ss.getSheetByName(AS_SHEET_NAME);
if (!sheet) { Logger.log('[HARNESS] account_snapshot 시트 없음'); return result; }
// ── 환율(USD_KRW) 로드 ──────────────────────────────────────────────────
var usdKrw = 1400; // default fallback
try {
var macroSheet = ss.getSheetByName("macro");
if (macroSheet) {
var mData = macroSheet.getDataRange().getValues();
var headerRowIdx = 0;
for (var r = 0; r < Math.min(5, mData.length); r++) {
var row = mData[r] ?? [];
if (row.indexOf("Symbol") >= 0 && row.indexOf("Name") >= 0) {
headerRowIdx = r;
break;
}
}
var nameIdx = mData[headerRowIdx].indexOf("Name");
var closeIdx = mData[headerRowIdx].indexOf("Close");
if (nameIdx >= 0 && closeIdx >= 0) {
for (var i = headerRowIdx + 1; i < mData.length; i++) {
if (String(mData[i][nameIdx]).trim() === "USD_KRW") {
var val = parseFloat(mData[i][closeIdx]);
if (Number.isFinite(val) && val > 0) {
usdKrw = val;
break;
}
}
}
}
}
} catch (e) {
Logger.log('[WARN] parseAccountSnapshot_ 환율 로드 실패: ' + e.message);
}
var data = sheet.getDataRange().getValues();
if (data.length <= AS_HEADER_ROW_IDX) return result;
var headers = data[AS_HEADER_ROW_IDX];
var c = buildColIdx_(headers);
var marketValueSum = 0;
for (var i = AS_HEADER_ROW_IDX + 1; i < data.length; i++) {
var row = data[i];
var parseStatus = strCol_(row, c, 'parse_status');
if (parseStatus !== 'CAPTURE_READ_OK') continue;
// captured_at (첫 유효 행 기준)
if (!result.capturedAt && c['captured_at'] !== undefined) {
var rawTs = row[c['captured_at']];
if (rawTs) result.capturedAt = rawTs instanceof Date ? rawTs : new Date(rawTs);
}
var accountType = strCol_(row, c, 'account_type') || strCol_(row, c, 'Account_Type') || '일반계좌';
var isRestrictedAcct = accountType === 'ISA' || accountType === '연금저축';
var holdingQty = numCol_(row, c, 'holding_quantity');
var immCash = numCol_(row, c, 'immediate_cash');
var d2Cash = numCol_(row, c, 'settlement_cash_d2');
var openOrder = numCol_(row, c, 'open_order_amount');
var mktValue = numCol_(row, c, 'market_value');
var ticker = normTicker_(c['ticker'] !== undefined ? row[c['ticker']] : '');
var isUsTicker = /^[A-Z]+$/.test(ticker);
if (isUsTicker) {
var currPrice = numCol_(row, c, 'current_price');
if (currPrice > 0 && holdingQty > 0) {
mktValue = Math.round(currPrice * holdingQty * usdKrw);
}
}
if (!isRestrictedAcct) {
if (immCash > 0) result.immediateCashKrw += immCash;
if (d2Cash > 0) result.settlementCashD2Krw += d2Cash;
if (openOrder > 0) result.openOrderAmountKrw += openOrder;
}
if (mktValue > 0) marketValueSum += mktValue;
var userConfirmed = strCol_(row, c, 'user_confirmed');
if (holdingQty <= 0 || userConfirmed !== 'Y') continue;
// 보유 포지션 처리
var avgCost = numCol_(row, c, 'average_cost');
if (isUsTicker && avgCost > 0) {
avgCost = round2_(avgCost * usdKrw);
}
var stopPrice = numCol_(row, c, 'stop_price');
if (isUsTicker && stopPrice > 0) {
stopPrice = round2_(stopPrice * usdKrw);
}
var dfRow = dfMap[ticker] || {};
var atr20 = dfRow.atr20 || 0;
var stopSrc = 'MANUAL';
// stop_price 미입력 또는 비정상 → STOP_PRICE_CORE_V1 계산
if (stopPrice <= 0 || stopPrice >= avgCost) {
if (atr20 > 0 && avgCost > 0) {
var atrPct = atr20 / avgCost * 100;
var atrMul = atrPct >= 8 ? 2.0 : 1.5;
stopPrice = Math.max(avgCost * 0.92, avgCost - atr20 * atrMul);
stopSrc = 'COMPUTED_ATR';
} else {
stopPrice = avgCost * 0.92;
stopSrc = 'COMPUTED_PCT';
}
result.heatAtrEstimated = true;
}
// Total Heat (H1)
var heatI = (avgCost - stopPrice) * holdingQty;
if (heatI > 0) {
result.totalHeatKrw += heatI;
result.heatRowsCount++;
}
// stop_price 이탈 감지
var close = dfRow.close || 0;
if (isUsTicker) {
var currPrice = numCol_(row, c, 'current_price');
close = currPrice > 0 ? round2_(currPrice * usdKrw) : round2_(close * usdKrw);
}
var stopBreach = close > 0 && stopPrice > 0 && close <= stopPrice;
// weight_pct: settings의 total_asset 기준으로 계산, 없으면 account_snapshot 컬럼
var weightPct = totalAssetKrw > 0 && mktValue > 0
? round2_(mktValue / totalAssetKrw * 100)
: numCol_(row, c, 'weight_pct') || numCol_(row, c, 'Weight_Pct');
var highestPriceSinceEntry = numCol_(row, c, 'highest_price_since_entry');
var returnPct = numColN_(row, c, 'return_pct');
var entryDateStr = strCol_(row, c, 'entry_date') || '';
var holdingDays = 0;
if (entryDateStr) {
var entryMs = new Date(entryDateStr).getTime();
if (!isNaN(entryMs)) holdingDays = Math.floor((Date.now() - entryMs) / 86400000);
}
result.holdings.push({
ticker: ticker,
name: strCol_(row, c, 'name') || strCol_(row, c, 'Name') || dfRow.name || '',
account: accountType,
holdingQty: holdingQty,
avgCost: avgCost,
stopPrice: stopPrice,
stopPriceSrc: stopSrc,
stopBreach: stopBreach,
marketValue: mktValue,
weightPct: weightPct,
close: close,
profitPct: Number.isFinite(returnPct) ? returnPct : null,
holdingDays: holdingDays,
highestPriceSinceEntry: highestPriceSinceEntry > 0 ? highestPriceSinceEntry : null,
entryDate: entryDateStr,
parseStatus: parseStatus
});
}
result.derivedTotalAsset = result.settlementCashD2Krw + marketValueSum;
return result;
}
// FORMULA_STUB: CASH_RATIOS_V1 — 현금비중 (calcCashRatios_) GAS 미구현, settlement_cash/total_asset 계산 (GAS_REFERENCE_ONLY)