Files
QuantEngineByItz/src/gas_adapter_parts/gdc_02_account_satellite.gs
T
kjh2064 7786e60daf feat(gas-thin-adapter): Phase 3 thin_adapter — 23개 forbidden 함수에 THIN_ADAPTER 위임 주석 삽입
GAS_THIN_ADAPTER_POLICY_V1 Phase 3 (thin_adapter) 진행:
- tools/gas_thin_adapter_phase3_annotate.py: 23개 GAS forbidden 함수에 THIN_ADAPTER 주석 자동 삽입 스크립트
- src/gas_adapter_parts 7개 파일: 각 forbidden 함수 본문 첫 줄에
  // THIN_ADAPTER: [<responsibility>] delegated to Python — <module>:<function>
  주석 추가 (기능 코드 무변경, additive-only)
- spec/39: thin_adapter phase IN_PROGRESS + thin_adapter_result 블록 추가

⚠ GAS 파일 변경됨 — GAS deploy + 사용자 검증 필요 (runDataFeed 실행)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:32:00 +09:00

2162 lines
107 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.
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:"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;
}