Files
QuantEngineByItz/src/gas_adapter_parts/gdf_01_price_metrics.gs
T
kjh2064 ee3e799de1 feat: 리밸런싱 엔진 V1 + GAS 버그 수정 (2026-06-13)
주요 변경:
- tools/build_rebalance_engine_v1.py: REBALANCE_ENGINE_V1 신규
  * account_snapshot 직접 합산(_build_snap_position_map) → 소수주 분리 행 병합
  * 레짐 소스 macro.REGIME_PRELIM 최우선 (GAS 와 동일)
- src/gas_adapter_parts/gdf_06_rebalance.gs: runRebalanceSheet_() 신규
  * Logger.log / getSpreadsheet_() 로 run_all 연동 수정
- src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs
  * _mergePositionRecord_(): 소수주 중복 행 합산 신규
  * parseInt → parseFloat (qty, availQty)
- src/gas_adapter_parts/gdf_01_price_metrics.gs
  * 미보유 종목 SELL_READY → WATCH_EXIT_SIGNAL
- spec/41_release_dag.yaml: build_rebalance_sheet 노드 추가 (step_count 63)
- spec/51_formula_lifecycle_registry.yaml: REBALANCE_ENGINE_V1 등록

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

2449 lines
105 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* gas_data_feed.gs — Google Apps Script 버전
*
* Phase 2: GAS에서 Naver Finance를 직접 호출해 데이터 수집.
* EUC-KR 인코딩을 GAS 네이티브로 처리 (iconv 불필요).
*
* 배포 방법:
* 1. script.google.com → 새 프로젝트
* 2. 이 파일 붙여넣기
* 3. 트리거 설정: runDataFeed → 시간 기반 → 매일 → 16:30~17:30
*
* 실행 시간 전략 (GAS 6분 제한):
* - data_feed: 보유 10종목만 → ~30초
* - sector_flow: 11섹터×3종목 → ~3분
* - macro/unified: 단순 집계 → ~30초
* - core_satellite(100종목): 별도 트리거, 청크 분할 실행
*
* 하네스 통합:
* - buildHarnessContext_()와 관련 헬퍼는 이 파일에 직접 포함된다.
* - 별도 하네스 파일 없이 이 파일 하나만 배포해도 된다.
*/
const SPREADSHEET_ID = "1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM";
const SCHEMA_VERSION = "2026-05-15-qg2";
const TICKERS_BASE = [
{ code: "005930", name: "삼성전자" },
{ code: "000660", name: "SK하이닉스" },
{ code: "000270", name: "기아" },
{ code: "091160", name: "KODEX 반도체" },
{ code: "064350", name: "현대로템" },
{ code: "012450", name: "한화에어로스페이스" },
{ code: "028050", name: "삼성E&A" },
{ code: "010120", name: "LS ELECTRIC" },
{ code: "0117V0", name: "TIGER AI전력기기" },
{ code: "494670", name: "TIGER 조선TOP10" },
{ code: "471990", name: "KODEX AI반도체핵심장비" },
];
// TICKERS 우선순위: TICKERS_BASE → account_snapshot 보유종목 → watch_tickers_override 수동 추가.
// account_snapshot에 보유수량(qty > 0)이 있는 종목은 TICKERS_BASE에 없어도 자동 포함된다.
function getActiveTickers_() {
let tickers = TICKERS_BASE.slice();
const existingCodes = new Set(tickers.map(t => t.code));
// ── 1. account_snapshot 자동 동기 ─────────────────────────────────────────
// parse_status=CAPTURE_READ_OK + holding_quantity > 0 인 KR 종목을 자동 포함.
// 미국 종목(GOOGL/MSFT 등, 순 알파벳 코드)은 Naver Finance 조회 불가 → skip.
// 소수주(qty < 1)도 동일 종목코드가 이미 추가됐으면 중복 추가 없음.
try {
const ss = getSpreadsheet_();
const snapSh = ss.getSheetByName("account_snapshot");
if (snapSh) {
const snapData = snapSh.getDataRange().getValues();
// account_snapshot은 row 1(index 0) = 안내, row 2(index 1) = 헤더
const headerRowIdx = snapData.length >= 2 ? 1 : 0;
const hdr = snapData[headerRowIdx].map(h => String(h).trim());
const codeIdx = hdr.indexOf("ticker");
const nameIdx = hdr.indexOf("name");
const qtyIdx = hdr.indexOf("holding_quantity");
const parseIdx = hdr.indexOf("parse_status");
const ptIdx = hdr.indexOf("position_type");
if (codeIdx >= 0) {
for (let i = headerRowIdx + 1; i < snapData.length; i++) {
const row = snapData[i];
const rawCode = String(row[codeIdx] || "").trim();
if (!rawCode) continue;
// 미국 종목 skip: GOOGL, MSFT, NVDA 등 순수 알파벳은 Naver 조회 불가
if (/^[A-Za-z]+$/.test(rawCode)) {
Logger.log("[TICKERS_SNAPSHOT] US종목 skip: " + rawCode);
continue;
}
const normCode = normalizeTickerCode(rawCode);
if (!normCode) continue;
if (parseIdx >= 0) {
const ps = String(row[parseIdx] || "").trim().toUpperCase();
if (ps && ps !== "CAPTURE_READ_OK") continue;
}
const qty = parseFloat(row[qtyIdx] ?? 0) || 0;
if (qty <= 0) continue;
if (!existingCodes.has(normCode)) {
const name = nameIdx >= 0 ? String(row[nameIdx] || normCode).trim() : normCode;
tickers.push({ code: normCode, name: name });
existingCodes.add(normCode);
Logger.log("[TICKERS_SNAPSHOT] 자동 추가: " + normCode + " (" + name + ") qty=" + qty);
}
}
}
}
} catch (e) {
Logger.log("[TICKERS_SNAPSHOT][WARN] account_snapshot 읽기 실패: " + e.message);
}
// ── 2. watch_tickers_override 수동 추가 (settings 탭) ──────────────────────
// 형식: "코드1:이름1,코드2:이름2" — 위 두 소스에 없는 종목을 수동 추가할 때 사용.
try {
const ss = getSpreadsheet_();
const sh = ss.getSheetByName("settings");
if (sh) {
const data = sh.getDataRange().getValues();
for (let i = 0; i < data.length; i++) {
if (String(data[i][0] || "").trim() !== "watch_tickers_override") continue;
const raw = String(data[i][1] || "").trim();
if (!raw) break;
raw.split(",").forEach(entry => {
const [code, name] = entry.trim().split(":").map(s => s.trim());
const normCode = normalizeTickerCode(code || "");
if (normCode && !existingCodes.has(normCode)) {
tickers.push({ code: normCode, name: name || normCode });
existingCodes.add(normCode);
Logger.log("[TICKERS_OVERRIDE] 수동 추가: " + normCode + " (" + (name || normCode) + ")");
}
});
break;
}
}
} catch (e) {
Logger.log("[TICKERS_OVERRIDE][WARN] settings 읽기 실패: " + e.message);
}
Logger.log("[getActiveTickers_] 최종 종목 수: " + tickers.length
+ " (base=" + TICKERS_BASE.length + " total=" + tickers.length + ")");
return tickers;
}
// 하위 호환: 기존 코드가 TICKERS를 직접 참조하는 경우를 위해 별칭 유지.
// runDataFeed()는 getActiveTickers_()를 호출해 동적 목록을 사용.
const TICKERS = TICKERS_BASE;
// 종목 → 섹터 매핑 (sector_flow의 Sector_Rank → C5 daily_leader_scan에 사용)
const TICKER_SECTOR_MAP = {
"005930": "반도체", "000660": "반도체", "042700": "반도체",
"010120": "AI전력", "267260": "AI전력", "006260": "AI전력",
"012450": "방산", "079550": "방산", "047810": "방산", "064350": "방산",
"329180": "조선", "042660": "조선", "009540": "조선",
"028050": "건설/EPC","000720": "건설/EPC","006360": "건설/EPC",
"005380": "자동차", "000270": "자동차", "012330": "자동차",
"105560": "금융/은행","055550": "금융/은행","086790": "금융/은행",
"373220": "2차전지","006400": "2차전지","051910": "2차전지",
"207940": "바이오", "068270": "바이오", "128940": "바이오",
"099440": "원전", "023450": "원전", "015760": "원전",
"028260": "소비재", "097950": "소비재", "004370": "소비재",
// ETF — 해당 섹터로 매핑
"091160": "반도체", "0117V0": "AI전력", "494670": "조선",
"471990": "반도체", // KODEX AI반도체핵심장비 (누락 추가)
"266410": "바이오", "091180": "자동차", "091170": "금융/은행",
"305720": "2차전지","139220": "소비재",
};
// 섹터 → Tier 매핑 (C5 daily_leader_scan 점수 정밀화)
// Tier_1=1.0(+rank≤3), Tier_2=0.5 고정, Tier_3=0
const SECTOR_TIER_MAP = {
"반도체": "Tier_1",
"AI전력": "Tier_1",
"방산": "Tier_1",
"조선": "Tier_1",
"자동차": "Tier_2",
"2차전지": "Tier_2",
"바이오": "Tier_2",
"원전": "Tier_2",
"건설/EPC": "Tier_3",
"금융/은행":"Tier_3",
"소비재": "Tier_3",
};
// KOSDAQ 상장 종목 Set — SS001_VAL_KOSDAQ_PEG(max 12pt) 적용 대상
// 현재 보유 10종목은 모두 KOSPI 상장. KOSDAQ 종목 편입 시 코드 추가.
const KOSDAQ_TICKERS = new Set([
// e.g., "035900", "003230"
]);
const DART_CATALYST_KEYWORDS = [
"수주",
"계약",
"실적",
"공급",
"납품",
"증설",
"합병",
"인수",
"배당",
"자사주",
];
const DART_RISK_KEYWORDS = [
"감자",
// "정정" 제거: DART 제목 앞 접두어로 잠정실적·계약체결 등 모든 공시에 붙어 오탐 유발
"상장폐지",
"관리종목",
"횡령",
"배임",
"불성실",
"소송",
"회생",
"유상증자",
"감사의견", // 감사의견 거절·한정
"공시번복", // 공시 내용 번복 (실질적 정정)
"조사", // 금감원 조사
];
// GAS_CACHE_MAX_TTL: GAS CacheService 최대 허용 TTL = 21600초(6시간).
// 초과 시 put()이 silently fail(try/catch 흡수) → 캐시 저장 안됨 → 매번 re-fetch 유발.
const GAS_CACHE_MAX_TTL = 21600;
const FETCH_GOVERNANCE = {
budget: {
naver_flow: 1,
naver_quote: 1,
naver_ohlc: 1,
naver_notice: 1,
naver_consensus: 1,
naver_fund: 1, // 펀더멘털 fallback (분기별, 7일 캐시 우선)
yahoo_price: 1,
yahoo_quote: 1,
yahoo_chart: 1,
yahoo_financials: 1,
},
ttl: {
naver_flow_ok: GAS_CACHE_MAX_TTL, // 6h (GAS 최대값)
naver_quote_ok: 30 * 60, // 30분 (장중 실시간 호가)
naver_ohlc_ok: GAS_CACHE_MAX_TTL, // 6h — 수정: 43200 → 21600 (GAS 초과 버그 fix)
naver_notice_ok: 4 * 60 * 60, // 4h
naver_consensus_ok: 4 * 60 * 60, // 4h
// 펀더멘털은 분기별 데이터 — CacheService 6h 저장 후 PropertiesService 7일 캐시로 이중 방어
naver_fund_ok: GAS_CACHE_MAX_TTL, // 6h
yahoo_price_ok: GAS_CACHE_MAX_TTL, // 6h
yahoo_quote_ok: 30 * 60, // 30분
yahoo_chart_ok: GAS_CACHE_MAX_TTL, // 6h
yahoo_financials_ok: GAS_CACHE_MAX_TTL, // 6h
failure: 10 * 60, // 10분 (재시도 대기)
},
failureLimit: 3,
coolDownMs: 3 * 60 * 60 * 1000,
};
// ── 운영 임계값 상수 (magic number 50개+ → 단일 위치로 통합) ────────────────
// 수치 변경 시 반드시 이 블록만 수정. 코드 본문 하드코딩 금지.
const THRESHOLDS = {
// Val_Surge_Pct 상태 구간 (%)
VAL_SURGE_WATCH: 15,
VAL_SURGE_HOT: 35,
VAL_SURGE_EXHAUSTED: 50,
// 유동성 — 5D 평균 거래대금 (백만원)
LIQUIDITY_PREFERRED_M: 100,
LIQUIDITY_OK_M: 50,
// 호가 스프레드 (%)
SPREAD_OK_PCT: 0.25,
SPREAD_WARN_PCT: 0.50,
// Take Profit 승수 (진입가 대비)
TP_CORE_1: 1.15, // core 1차 +15%
TP_CORE_2: 1.25, // core 2차 +25%
TP_SAT_1: 1.10, // satellite 1차 +10%
TP_SAT_2: 1.20, // satellite 2차 +20%
// Time Stop (calendar days)
TIME_STOP_STAGE1: 60,
TIME_STOP_STAGE2: 30,
// Bucket 할당 목표 범위 (%)
BUCKET_CORE_MIN: 60,
BUCKET_CORE_MAX: 72,
BUCKET_SAT_MIN: 10,
BUCKET_SAT_MAX: 25,
BUCKET_CASH_MIN: 10,
BUCKET_CASH_MAX: 22,
// Satellite 단일종목 비중 상한 (%)
SAT_BAND_MAX: 7,
// Orbit Gap 경보 (%p)
ORBIT_MILD_BEHIND: 1,
ORBIT_SIGNIFICANT_BEHIND: 3,
ORBIT_AHEAD_TARGET: -2,
// 포지션 수량 위험 예산 (기본 — settings 탭 override 가능)
DEFAULT_RISK_BUDGET: 0.007,
// ATR 기반 손절 승수
ATR_STOP_MULT: 1.5,
ATR_TRAILING_MULT: 1.5,
ATR_STOP_MULT_HIGH: 2.0, // ATR20_Pct >= 8% 고변동성 종목 전용
// Stage2 진입 최소 수익 (%)
STAGE2_GATE_MIN_PCT: 1.5,
// ── Sell_Priority_Score 산출 상수 (spec: portfolio_exposure.yaml:sell_priority_engine) ──
SP_HARD_STOP: 50, // EXIT_SIGNAL / EXIT_100
SP_SELL_SIGNAL: 40, // SELL_READY / TRIM 신호 확정
SP_HOLDINGS_ROTATE: 20, // EXIT_REVIEW / 보유주 교체 후보
SP_TAKE_PROFIT: 10, // Profit_Pct >= 10% (익절 후보)
SP_ETF_DUPLICATE: 20, // ETF + 섹터노출 >= 20% (중복노출 상한 초과)
SP_ETF_MODERATE: 15, // ETF + 섹터노출 >= 10%
SP_CASH_LARGE: 15, // Weight_Pct >= 3% (현금 회복 효과 대)
SP_CASH_MID: 10, // Weight_Pct >= 1%
SP_CASH_SMALL: 3,
SP_RW4: 20, // RW_Partial >= 4
SP_RW3: 15, // RW_Partial == 3
SP_RW2: 8, // RW_Partial == 2
SP_BELOW_MA20: 8, // close < MA20
SP_LOSS_SATELLITE: 12, // 손실 >= -10%, 위성, 비코어리더
SP_OVERWEIGHT_LARGE: 12, // 목표비중 초과 >= 5%p
SP_OVERWEIGHT_MID: 6, // 목표비중 초과 >= 2%p
SP_CORE_LEADER: -20, // 직접 코어 주도주 + 상승추세 (패널티)
SP_SS001_A: -12, // SS001 A등급 (패널티)
SP_DUPLICATE_THRESH: 20, // 섹터노출 중복 판정 기준 (%)
};
function getKrxMarketSessionStatus_(dt) {
const d = dt instanceof Date ? dt : new Date(dt || new Date());
if (isNaN(d.getTime())) {
return { open: false, reason: "invalid_datetime" };
}
const kst = new Date(d.getTime() + 9 * 60 * 60 * 1000);
const day = kst.getUTCDay();
const minutes = kst.getUTCHours() * 60 + kst.getUTCMinutes();
const open = day >= 1 && day <= 5 && minutes >= 9 * 60 && minutes < 15 * 60 + 30;
return {
open: open,
reason: open ? "MARKET_OPEN" : "MARKET_CLOSED",
kst_date: Utilities.formatDate(kst, "Asia/Seoul", "yyyy-MM-dd"),
kst_time: Utilities.formatDate(kst, "Asia/Seoul", "HH:mm:ss"),
};
}
// account_snapshot freshness 확인 — last_updated/captured_at 최신 행 기준 경과일 반환
function checkAccountSnapshotFreshness_() {
try {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName("account_snapshot");
if (!sheet) return { fresh: false, reason: "account_snapshot 탭 없음" };
const session = getKrxMarketSessionStatus_(new Date());
const data = sheet.getDataRange().getValues();
if (data.length < 3) return { fresh: true, reason: "보유 원장 없음" };
const hdr = data[1].map(h => String(h).trim());
const luIdx = hdr.indexOf("last_updated") >= 0 ? hdr.indexOf("last_updated") : hdr.indexOf("captured_at");
const qtyIdx = hdr.indexOf("holding_quantity");
const statusIdx = hdr.indexOf("parse_status");
const confirmedIdx = hdr.indexOf("user_confirmed");
if (luIdx < 0) return { fresh: null, reason: "last_updated/captured_at 컬럼 없음" };
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
let latestDate = null;
for (let i = 2; i < data.length; i++) {
const parseStatus = statusIdx >= 0 ? String(data[i][statusIdx] ?? "").trim() : "";
const confirmed = confirmedIdx >= 0 ? String(data[i][confirmedIdx] ?? "").trim().toUpperCase() : "";
if (parseStatus !== "CAPTURE_READ_OK" || !["Y", "YES", "TRUE", "1"].includes(confirmed)) continue;
const qty = parseInt(data[i][qtyIdx]);
if (!Number.isFinite(qty) || qty <= 0) continue;
const raw = data[i][luIdx];
const d = raw instanceof Date
? Utilities.formatDate(raw, "Asia/Seoul", "yyyy-MM-dd")
: String(raw).trim().substring(0, 10);
if (/^\d{4}-\d{2}-\d{2}$/.test(d) && d > (latestDate ?? "")) latestDate = d;
}
if (!latestDate) return { fresh: null, reason: "last_updated 미입력" };
const daysDiff = Math.round((new Date(today) - new Date(latestDate)) / 86400000);
return {
fresh: daysDiff <= 1,
last_updated: latestDate,
days_stale: daysDiff,
reason: daysDiff <= 1 ? "최신" : `${daysDiff}일 경과 (${latestDate})`,
collection_allowed: session.open,
market_session_open: session.open,
market_session_reason: session.reason,
};
} catch(e) {
return { fresh: null, reason: "읽기 오류: " + e.message };
}
}
function snapshotExecutionGate_(freshness) {
if (!freshness || freshness.fresh == null) {
return {
status: "BLOCK_EXECUTION",
reason: freshness && freshness.reason ? freshness.reason : "account_snapshot freshness unknown",
};
}
if (freshness.fresh === false) {
return {
status: "REVIEW_ONLY",
reason: freshness.reason || "snapshot stale — proposal only",
};
}
return {
status: "ALLOW_EXECUTION",
reason: freshness.reason || "최신",
};
}
function calcDerivedPriceMetrics(rows, latestFirst) {
if (!Array.isArray(rows) || rows.length === 0) return {};
const ordered = latestFirst ? rows.slice().reverse() : rows.slice(); // oldest -> latest
const latest = ordered[ordered.length - 1] || {};
const previous = ordered[ordered.length - 2] || {};
const prior = (n) => ordered[ordered.length - 1 - n] || null;
const lastN = (n) => ordered.slice(Math.max(0, ordered.length - n));
const prevN = (n) => ordered.slice(Math.max(0, ordered.length - 1 - n), ordered.length - 1);
return {
open: Number.isFinite(latest.open) ? latest.open : null,
high: Number.isFinite(latest.high) ? latest.high : null,
low: Number.isFinite(latest.low) ? latest.low : null,
volume: Number.isFinite(latest.volume) ? latest.volume : null,
prevClose: Number.isFinite(previous.close) ? previous.close : null,
avgVolume5D: prevN(5).length >= 5 ? avgNumber_(prevN(5).map(r => r.volume)) : null,
ma20: lastN(20).length >= 20 ? avgNumber_(lastN(20).map(r => r.close)) : null,
ma60: lastN(60).length >= 60 ? avgNumber_(lastN(60).map(r => r.close)) : null,
ret2D: prior(2) ? pctReturn_(latest.close, prior(2).close) : null,
ret5D: prior(5) ? pctReturn_(latest.close, prior(5).close) : null,
ret10D: prior(10) ? pctReturn_(latest.close, prior(10).close) : null,
ret20D: prior(20) ? pctReturn_(latest.close, prior(20).close) : null,
ret60D: prior(60) ? pctReturn_(latest.close, prior(60).close) : null,
};
}
// ── F1: 기술적 타이밍 지표 계산 ──────────────────────────────────────────────
// rows: oldest→latest OHLCV 배열. 25행 이상 필요.
function calcTimingMetrics_(rows) {
if (!Array.isArray(rows) || rows.length < 21) return {};
const closes = rows.map(r => r.close);
const n = closes.length;
const close = closes[n - 1];
// MA20 slope: (오늘 MA20 - 5일전 MA20) / 5일전 MA20 × 100
const ma20Today = closes.slice(n - 20).reduce((a, b) => a + b, 0) / 20;
let ma20Slope = null;
if (n >= 25) {
const ma20_5ago = closes.slice(n - 25, n - 5).reduce((a, b) => a + b, 0) / 20;
if (ma20_5ago > 0) ma20Slope = parseFloat(((ma20Today - ma20_5ago) / ma20_5ago * 100).toFixed(3));
}
// 이격도: (종가/MA20 - 1) × 100
const disparity = ma20Today > 0 ? parseFloat(((close / ma20Today - 1) * 100).toFixed(2)) : null;
// RSI 14 (Wilder's smoothed)
const rsi14 = calcRsi14_(closes);
// 볼린저 밴드 (20일, 2σ)
const bb20 = closes.slice(n - 20);
const bbMean = bb20.reduce((a, b) => a + b, 0) / 20;
const bbVar = bb20.reduce((s, c) => s + Math.pow(c - bbMean, 2), 0) / 20;
const bbStd = Math.sqrt(bbVar);
const bbUpper = bbMean + 2 * bbStd;
const bbLower = bbMean - 2 * bbStd;
const bbWidth = bbMean > 0 ? parseFloat(((bbUpper - bbLower) / bbMean * 100).toFixed(2)) : null;
const bbPos = (bbUpper > bbLower) ? parseFloat(((close - bbLower) / (bbUpper - bbLower) * 100).toFixed(1)) : null;
return {
ma20Slope,
disparity,
rsi14,
bbWidth,
bbPosition: bbPos,
bbUpper: Math.round(bbUpper),
bbLower: Math.round(bbLower),
};
}
// RSI14 — Wilder 방식. 최대 50개 바 사용해 초기화 편향 최소화.
// 14개만 초기화하면 ±5~8pt 오차 발생 — 사용 가능한 전체 데이터로 안정화.
function calcRsi14_(closes) {
if (closes.length < 15) return null;
const lookback = Math.min(closes.length, 50);
const c = closes.slice(closes.length - lookback);
let avgGain = 0, avgLoss = 0;
for (let i = 1; i <= 14; i++) {
const d = c[i] - c[i - 1];
if (d > 0) avgGain += d; else avgLoss -= d;
}
avgGain /= 14; avgLoss /= 14;
for (let i = 15; i < c.length; i++) {
const d = c[i] - c[i - 1];
avgGain = (avgGain * 13 + Math.max(0, d)) / 14;
avgLoss = (avgLoss * 13 + Math.max(0, -d)) / 14;
}
if (avgLoss === 0) return 100;
return parseFloat((100 - 100 / (1 + avgGain / avgLoss)).toFixed(1));
}
// ── F2: Entry Mode 게이트 ─────────────────────────────────────────────────────
// PULLBACK: 눌림목 매수 조건 / BREAKOUT: 돌파 매수 조건 / NEUTRAL: 대기
function calcEntryMode_(timing, price) {
const { ma20Slope, disparity, rsi14 } = timing;
if (!Number.isFinite(disparity) || !Number.isFinite(rsi14)) {
return { mode: "NEUTRAL", gate: "PENDING", reason: "지표_부족" };
}
const trendUp = Number.isFinite(ma20Slope) && ma20Slope > 0;
const valSurge = Number.isFinite(price.valSurge) ? price.valSurge : 0;
const pct52H = Number.isFinite(price.pct52WHigh) ? price.pct52WHigh : -100;
// 과열 — 두 전략 모두 진입 금지
if (disparity > 12 || rsi14 > 75) {
return { mode: "OVERBOUGHT", gate: "BLOCK", reason: `과열(이격${disparity}%_RSI${rsi14})` };
}
// 눌림목: 이격도 -5~+4% + MA20 상승 + RSI 35~58
if (trendUp && disparity >= -5 && disparity <= 4 && rsi14 >= 35 && rsi14 <= 58) {
return { mode: "PULLBACK", gate: "PASS", reason: `눌림목(이격${disparity}%_RSI${rsi14})` };
}
// 돌파: 52주 고점 -5% 이내 + 거래량 폭발 + RSI 50~72 + MA20 상승
if (trendUp && pct52H >= -5 && valSurge >= 50 && rsi14 > 50 && rsi14 <= 72) {
return { mode: "BREAKOUT", gate: "PASS", reason: `돌파(52WH${pct52H.toFixed(1)}%_VOL+${valSurge.toFixed(0)}%)` };
}
// MA20 하락 추세
if (!trendUp && Number.isFinite(ma20Slope)) {
return { mode: "NEUTRAL", gate: "PENDING", reason: `MA20하락추세(slope${ma20Slope.toFixed(2)}%)` };
}
return { mode: "NEUTRAL", gate: "PENDING", reason: `조건미충족(이격${disparity}%_RSI${rsi14})` };
}
// ── F3: 매도 타이밍 신호 ──────────────────────────────────────────────────────
// 복수 신호 발생 시 파이프(|) 구분. 포지션 없으면 빈 문자열.
function calcExitSignalDetail_(timing, price) {
const signals = [];
const { disparity, rsi14, ma20Slope } = timing;
const ret5D = Number.isFinite(price.ret5D) ? parseFloat(price.ret5D) : null;
const valSurge = Number.isFinite(price.valSurge) ? price.valSurge : null;
// 거래량 소진: 5일 수익률 양수인데 거래대금 평균 대비 -20% 미만
if (ret5D !== null && ret5D > 0 && valSurge !== null && valSurge < -20) {
signals.push("VOL_EXHAUSTION");
}
// MA20 붕괴: 종가 < MA20 AND MA20 하락
if (price.ok && Number.isFinite(price.close) && Number.isFinite(price.ma20) &&
price.close < price.ma20 && Number.isFinite(ma20Slope) && ma20Slope < 0) {
signals.push("MA20_BREAK");
}
// 극단 과열: 이격도 > 15%
if (Number.isFinite(disparity) && disparity > 15) signals.push("DISPARITY_TOP");
// RSI 과매수: RSI > 75
if (Number.isFinite(rsi14) && rsi14 > 75) signals.push("RSI_OVERBOUGHT");
return signals.join("|");
}
// ── F5: 타이밍 종합 액션 ──────────────────────────────────────────────────────
// 종목 점수(SS001)와 별개로 "지금 무엇을 할지"를 분리한다.
var calcEntryTimingSignal_ = function(ctx) {
const reasons = [];
let entryScore = 0;
let exitScore = 0;
const entryGate = String(ctx.entryModeGate ?? "");
const entryMode = String(ctx.entryMode ?? "");
const leaderGate = String(ctx.leaderGate ?? "");
const acGate = String(ctx.acGate ?? "");
const exitSignal = String(ctx.exitSignalDetail ?? "");
const flowCredit = parseFloat(ctx.flowCredit);
const leaderTotal = parseFloat(ctx.leaderTotal);
const rwPartial = parseInt(ctx.rwPartial, 10);
const rsi14 = parseFloat(ctx.rsi14);
const disparity = parseFloat(ctx.disparity);
const ma20Slope = parseFloat(ctx.ma20Slope);
const spreadPct = parseFloat(ctx.spreadPct);
const avgTradeValue5D = parseFloat(ctx.avgTradeValue5D);
const profitPct = parseFloat(ctx.profitPct);
const daysToTimeStop = parseInt(ctx.daysToTimeStop, 10);
if (entryGate === "PASS") { entryScore += 25; reasons.push(`entry_${entryMode}`); }
else if (entryGate === "BLOCK") { entryScore -= 25; reasons.push("entry_block"); }
if (Number.isFinite(leaderTotal)) {
if (leaderTotal >= 4) { entryScore += 20; reasons.push("leader_scan>=4"); }
else if (leaderTotal >= 3) { entryScore += 10; reasons.push("leader_watch"); }
}
if (leaderGate === "PASS" || leaderGate === "EXPLORE_CANDIDATE") entryScore += 10;
if (Number.isFinite(flowCredit)) {
if (flowCredit >= 0.7) { entryScore += 20; reasons.push("flow_strong"); }
else if (flowCredit >= 0.4) { entryScore += 10; reasons.push("flow_partial"); }
}
if (acGate === "CLEAR") { entryScore += 15; reasons.push("anti_climax_clear"); }
else if (acGate === "CAUTION") { entryScore += 5; reasons.push("anti_climax_caution"); }
else if (acGate === "BLOCK") { entryScore -= 35; exitScore += 15; reasons.push("anti_climax_block"); }
if (Number.isFinite(ma20Slope)) {
if (ma20Slope > 0) entryScore += 8;
else { entryScore -= 8; exitScore += 8; reasons.push("ma20_down"); }
}
if (Number.isFinite(disparity)) {
if (disparity >= -5 && disparity <= 4) entryScore += 10;
else if (disparity > 4 && disparity <= 8) entryScore += 5;
else if (disparity > 12) { entryScore -= 25; exitScore += 20; reasons.push("overextended"); }
else if (disparity < -10) { entryScore -= 10; exitScore += 10; reasons.push("trend_damage"); }
}
if (Number.isFinite(rsi14)) {
if (rsi14 >= 40 && rsi14 <= 65) entryScore += 10;
else if (rsi14 > 65 && rsi14 <= 72) entryScore += 4;
else if (rsi14 > 75) { entryScore -= 25; exitScore += 20; reasons.push("rsi_overbought"); }
else if (rsi14 < 35) { entryScore -= 5; exitScore += 8; reasons.push("weak_rsi"); }
}
if (Number.isFinite(avgTradeValue5D) && avgTradeValue5D >= 50
&& (!Number.isFinite(spreadPct) || spreadPct <= 0.8)) {
entryScore += 10;
} else {
entryScore -= 15;
reasons.push("liquidity_or_spread_fail");
}
// RW: 수급 기반 상대약세 — 신뢰도 높아 25pt/건 (구: 20pt). 기술지표: 노이즈 多로 10pt/건 (구: 18pt).
// 결과: RW=0 + 기술신호 4개 = 40pt → EXIT_REVIEW 미도달. RW=1 + 기술신호 2개 = 45pt → 대기.
// RW=2 단독 = 50pt → EXIT_REVIEW. RW=3 단독 = 75pt → STOP_OR_TIME_EXIT_READY.
if (Number.isFinite(rwPartial)) exitScore += Math.min(100, Math.max(0, rwPartial) * 25);
if (exitSignal) exitScore += exitSignal.split("|").filter(Boolean).length * 10;
if (Number.isFinite(daysToTimeStop) && daysToTimeStop >= 0 && daysToTimeStop <= 7) {
exitScore += 20;
reasons.push("time_stop_near");
}
if (Number.isFinite(profitPct) && profitPct >= 10) {
exitScore += 15;
reasons.push("profit_protect_zone");
}
entryScore = Math.max(0, Math.min(100, Math.round(entryScore)));
exitScore = Math.max(0, Math.min(100, Math.round(exitScore)));
let action = "HOLD_NO_TIMING_EDGE";
if (ctx.priceStatus !== "PRICE_OK" || !Number.isFinite(parseFloat(ctx.atr20))) {
action = "OBSERVE_DATA_MISSING";
} else if (exitScore >= 75 || (Number.isFinite(rwPartial) && rwPartial >= 4)) {
action = "STOP_OR_TIME_EXIT_READY";
} else if (exitScore >= 50 || (Number.isFinite(rwPartial) && rwPartial >= 3)) {
action = "EXIT_REVIEW";
} else if (entryGate === "BLOCK" || acGate === "BLOCK" || entryMode === "OVERBOUGHT") {
action = "NO_BUY_OVERHEATED";
} else if (entryScore >= 75 && entryGate === "PASS" && leaderTotal >= 4) {
action = entryMode === "BREAKOUT" ? "BUY_BREAKOUT_PILOT_ONLY" : "BUY_STAGE1_READY";
} else if (entryScore >= 60 && entryGate === "PASS") {
action = entryMode === "BREAKOUT" ? "BUY_BREAKOUT_PILOT_ONLY" : "BUY_PULLBACK_WAIT";
} else if (leaderTotal >= 3 || flowCredit >= 0.4) {
action = "WATCH_TIMING_SETUP";
}
return {
entry_score: entryScore,
exit_score: exitScore,
action,
reason: reasons.slice(0, 6).join("|"),
};
}
// Backward-compatible thin wrapper.
// Existing data_feed callers still expect calcTimingRoute_.
var calcTimingRoute_ = function(ctx) {
return calcEntryTimingSignal_(ctx || {});
}
// ── F6: 매도 신호·가격 산출 (방향 A: 수량 계산은 GAS 담당 아님) ──────────────────
// Sell_Qty는 GAS에서 산출하지 않는다. 신호 종류 + 가격 + 비율만 출력.
// 보유수량 × 비율 계산은 사용자가 ChatGPT에 캡처를 제공하는 단계에서 처리한다.
var calcExitSellAction_ = function(ctx) {
const close = parseFloat(ctx.close);
const stopPrice = parseFloat(ctx.stopPrice);
const trailingStop = parseFloat(ctx.trailingStop);
const tp1Price = parseFloat(ctx.tp1Price);
const tp2Price = parseFloat(ctx.tp2Price);
const profitPct = parseFloat(ctx.profitPct);
const rwPartial = parseInt(ctx.rwPartial, 10);
const timingExitScore = parseFloat(ctx.timingExitScore);
const daysToTimeStop = parseInt(ctx.daysToTimeStop, 10);
const timingAction = String(ctx.timingAction ?? "");
const exitSignal = String(ctx.exitSignalDetail ?? "");
const acGate = String(ctx.acGate ?? "");
// sell_signal_priority level 2: REGIME_RISK_OFF (spec/exit/stop_loss.yaml)
const regime = String(ctx.regime ?? "");
const atr20 = parseFloat(ctx.atr20);
let action = "HOLD";
let ratio = 0;
let reason = "";
let price = "";
let priceSource = "";
let priceBasis = "";
let executionWindow = "";
let orderType = "";
const stopCandidate = Number.isFinite(trailingStop) && trailingStop > 0
? trailingStop
: Number.isFinite(stopPrice) && stopPrice > 0
? stopPrice
: Number.isFinite(close) && close > 0
? close * 0.995
: null;
const protectiveLimit = Number.isFinite(close) && close > 0
? Math.round(Math.min(close * 0.995, stopCandidate ?? close * 0.995))
: "";
// ATR 기반 보호 하한: close - ATR20×0.3 (변동성 비례 버퍼). ATR 없으면 0.5% 폴백.
const atrBuffer = Number.isFinite(atr20) && atr20 > 0 ? atr20 * 0.3 : (Number.isFinite(close) ? close * 0.005 : 0);
const closeProtectLimit = Number.isFinite(close) && close > 0 ? Math.round(close - atrBuffer) : "";
// priority 1: hard stop / strong RW exit (spec sell_signal_priority level 1)
if (timingAction === "STOP_OR_TIME_EXIT_READY" || rwPartial >= 4) {
action = "EXIT_100";
ratio = 100;
reason = rwPartial >= 4 ? "RW_EXIT_STRONG" : "STOP_OR_TIME_EXIT_READY";
price = protectiveLimit;
priceSource = Number.isFinite(trailingStop) ? "TRAILING_STOP" : "STOP_OR_CLOSE";
priceBasis = Number.isFinite(trailingStop) ? "TRAILING_STOP_TRIGGER" : "STOP_OR_CLOSE_PROTECT";
executionWindow = "INTRADAY_ON_TRIGGER";
orderType = "PROTECTIVE_LIMIT_SELL";
// priority 2: REGIME_TRIM_50 — 방향 A에서 개별 종목 신호 아님.
// RISK_OFF 레짐 포트폴리오 축소 경고는 getDailyBrief() 매크로 섹션에서 처리.
// priority 3: RW 신호 강 (spec level 3)
} else if (rwPartial >= 3 || timingExitScore >= 75) {
action = "TRIM_70";
ratio = 70;
reason = rwPartial >= 3 ? "RW_EXIT" : "TIMING_EXIT_SCORE";
price = protectiveLimit;
priceSource = "RISK_REDUCTION";
priceBasis = "RISK_REDUCTION_CLOSE_PROTECT";
executionWindow = "INTRADAY_AFTER_09_30";
orderType = "PROTECTIVE_LIMIT_SELL";
// priority 4: trailing stop 가격 직접 이탈 (spec level 4) — timingAction과 독립적으로 직접 비교
} else if (Number.isFinite(trailingStop) && trailingStop > 0 && Number.isFinite(close) && close <= trailingStop) {
action = "TRAILING_STOP_BREACH";
ratio = 70;
reason = "TRAILING_STOP_PRICE_BREACH";
price = Math.round(trailingStop); // 트레일링 스탑 이탈: 스탑 가격 자체가 보호선 — min 적용 금지
priceSource = "TRAILING_STOP_PRICE";
priceBasis = "TRAILING_STOP_TRIGGER";
executionWindow = "INTRADAY_ON_TRIGGER";
orderType = "PROTECTIVE_LIMIT_SELL";
// priority 4 (계속): RW 신호 중 (spec level 3 하위)
// RW=0 + 기술지표만으로는 TRIM_50 차단 — 수급 확인(rwPartial>=1) 필수
} else if (rwPartial >= 2 || (rwPartial >= 1 && timingExitScore >= 50)) {
action = "TRIM_50";
ratio = 50;
reason = rwPartial >= 2 ? "RW_REVIEW" : "TIMING_EXIT_REVIEW";
price = closeProtectLimit;
priceSource = "RELATIVE_WEAKNESS_CLOSE";
priceBasis = "PRIOR_CLOSE_X_0.998";
executionWindow = "INTRADAY_AFTER_09_30";
orderType = "LIMIT_SELL";
// priority 4b: RW 약세 초기 + 기술지표 경계 — 33% 선제 경량화
} else if (rwPartial >= 1 && timingExitScore >= 30) {
action = "TRIM_33";
ratio = 33;
reason = "RW_EARLY_WARNING";
price = closeProtectLimit;
priceSource = "EARLY_WARNING_CLOSE";
priceBasis = "PRIOR_CLOSE_X_0.998";
executionWindow = "INTRADAY_AFTER_09_30";
orderType = "LIMIT_SELL";
// priority 4c: RW 약세 감지 단독 (기술지표 미확인) — 25% 최소 경계
} else if (rwPartial >= 1) {
action = "TRIM_25";
ratio = 25;
reason = "RW_SIGNAL_ONLY";
price = closeProtectLimit;
priceSource = "SIGNAL_ONLY_CLOSE";
priceBasis = "PRIOR_CLOSE_X_0.998";
executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN";
orderType = "LIMIT_SELL";
// priority 5: 익절 사다리 (spec level 5) — time_stop보다 우선
} else if (Number.isFinite(profitPct) && profitPct >= 50) {
action = "PROFIT_TRIM_50";
ratio = 50;
reason = "PROFIT_PROTECT_50";
price = Number.isFinite(tp2Price) && tp2Price > 0 ? Math.round(tp2Price) : closeProtectLimit;
priceSource = Number.isFinite(tp2Price) ? "TP2_PRICE" : "CLOSE_PROFIT_PROTECT";
priceBasis = Number.isFinite(tp2Price) ? "TAKE_PROFIT_TIER2_PRICE" : "PRIOR_CLOSE_X_0.998";
executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW";
orderType = "LIMIT_SELL";
} else if (Number.isFinite(profitPct) && profitPct >= 30) {
action = "PROFIT_TRIM_35";
ratio = 35;
reason = "PROFIT_PROTECT_30";
price = Number.isFinite(tp2Price) && tp2Price > 0 ? Math.round(tp2Price) : closeProtectLimit;
priceSource = Number.isFinite(tp2Price) ? "TP2_PRICE" : "CLOSE_PROFIT_PROTECT";
priceBasis = Number.isFinite(tp2Price) ? "TAKE_PROFIT_TIER2_PRICE" : "PRIOR_CLOSE_X_0.998";
executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW";
orderType = "LIMIT_SELL";
} else if (Number.isFinite(profitPct) && profitPct >= 20) {
action = "PROFIT_TRIM_25";
ratio = 25;
reason = "PROFIT_PROTECT_20";
price = Number.isFinite(tp1Price) && tp1Price > 0 ? Math.round(tp1Price) : closeProtectLimit;
priceSource = Number.isFinite(tp1Price) ? "TP1_PRICE" : "CLOSE_PROFIT_PROTECT";
priceBasis = Number.isFinite(tp1Price) ? "TAKE_PROFIT_TIER1_PRICE" : "PRIOR_CLOSE_X_0.998";
executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW";
orderType = "LIMIT_SELL";
} else if (Number.isFinite(profitPct) && profitPct >= 10) {
action = "TAKE_PROFIT_TIER1";
ratio = 25;
reason = "TP1_PROFIT_10PCT";
price = Number.isFinite(tp1Price) && tp1Price > 0 ? Math.round(tp1Price) : closeProtectLimit;
priceSource = Number.isFinite(tp1Price) ? "TP1_PRICE" : "CLOSE_PROFIT_PROTECT";
priceBasis = Number.isFinite(tp1Price) ? "TAKE_PROFIT_TIER1_PRICE" : "PRIOR_CLOSE_X_0.998";
executionWindow = "INTRADAY_LIMIT_OR_CLOSE_REVIEW";
orderType = "LIMIT_SELL";
// priority 6: 시간 손절 (spec level 6) — 익절 사다리보다 후순위; 손절·레짐·RW 없을 때만 도달
} else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 0) {
action = "TIME_EXIT_100";
ratio = 100;
reason = "TIME_STOP_EXPIRED";
price = protectiveLimit;
priceSource = "TIME_STOP_CLOSE";
priceBasis = "TIME_STOP_CLOSE_PROTECT";
executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN";
orderType = "PROTECTIVE_LIMIT_SELL";
} else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 7) {
action = "TIME_TRIM_50";
ratio = 50;
reason = "TIME_STOP_NEAR";
price = closeProtectLimit;
priceSource = "TIME_STOP_NEAR_CLOSE";
priceBasis = "ATR_PROTECT_LIMIT";
executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN";
orderType = "LIMIT_SELL";
// priority 6b: 타임스탑 14일 이내 조기 경보 — 25% 선제 축소
} else if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 14) {
action = "TIME_TRIM_25";
ratio = 25;
reason = "TIME_STOP_APPROACHING";
price = closeProtectLimit;
priceSource = "TIME_STOP_APPROACHING_CLOSE";
priceBasis = "ATR_PROTECT_LIMIT";
executionWindow = "CLOSE_REVIEW_OR_NEXT_OPEN";
orderType = "LIMIT_SELL";
}
const cashPreservePlan = calcCashPreservationPlan_({
sellAction: action,
cashFloorStatus: String(ctx.cashFloorStatus ?? ""),
regime,
isCoreLeader: !!ctx.isCoreLeader,
isEtf: !!ctx.isEtf,
liquidityStatus: String(ctx.liquidityStatus ?? ""),
spreadStatus: String(ctx.spreadStatus ?? ""),
accountType: String(ctx.accountType ?? ""),
profitPct,
rwPartial,
reboundHoldbackScore: parseFloat(ctx.reboundHoldbackScore),
});
if (action !== "EXIT_100" && action !== "TRAILING_STOP_BREACH" && action !== "HOLD") {
const targetRatio = cashPreservePlan.recommended_ratio;
if (Number.isFinite(targetRatio) && targetRatio > 0 && targetRatio < ratio) {
ratio = targetRatio;
if (ratio <= 25) action = "TRIM_25";
else if (ratio <= 33) action = "TRIM_33";
else action = "TRIM_50";
reason = reason ? `${reason}|CASH_PRESERVE:${cashPreservePlan.style}` : `CASH_PRESERVE:${cashPreservePlan.style}`;
}
}
// SL003_PRIORITY_MATRIX: 복수 손절 조건 동시 발동 시 max(prices) 적용 — spec/exit/stop_loss.yaml
// TP 계열(PROFIT_TRIM_*, TAKE_PROFIT_TIER1)은 별도 프레임워크이므로 이 블록 적용 제외
const isStopTypeAction_ = /^(EXIT_100|TRIM_70|TRAILING_STOP_BREACH|TRIM_50|TRIM_33|TRIM_25|TIME_EXIT_100|TIME_TRIM_50|TIME_TRIM_25)$/.test(action);
if (isStopTypeAction_ && Number.isFinite(close) && close > 0) {
const slpCands_ = [];
const pushSlp_ = (src, p) => { if (Number.isFinite(p) && p > 0) slpCands_.push({ src, p }); };
if (timingAction === "STOP_OR_TIME_EXIT_READY" || rwPartial >= 4) pushSlp_("HARD_STOP", protectiveLimit);
// REGIME 후보는 방향 A에서 포트폴리오 레벨 처리 — SL003에서 제외
if (rwPartial >= 3 || timingExitScore >= 75) pushSlp_("RW_TRIM70", protectiveLimit);
if (Number.isFinite(trailingStop) && trailingStop > 0 && close <= trailingStop)
pushSlp_("TRAILING", Math.round(trailingStop)); // 트레일링 스탑 가격이 보호선
if (rwPartial >= 2 || (rwPartial >= 1 && timingExitScore >= 50)) pushSlp_("RW_TRIM50", closeProtectLimit);
if (Number.isFinite(daysToTimeStop) && daysToTimeStop <= 7) pushSlp_("TIME_STOP", closeProtectLimit);
if (slpCands_.length >= 2) {
const maxSlp_ = slpCands_.reduce((a, b) => b.p > a.p ? b : a);
const curPrice_ = parseFloat(price);
if (maxSlp_.p > (Number.isFinite(curPrice_) ? curPrice_ : 0)) {
price = maxSlp_.p;
priceSource = "PRIORITY_MATRIX_MAX";
priceBasis = `SL003_MAX(${slpCands_.map(c => `${c.src}:${c.p}`).join("|")})`;
}
}
}
// 방향 A: 수량 계산 없음. 가격이 유효하면 SIGNAL_CONFIRMED.
let validation = "NO_SELL_ACTION";
if (action !== "HOLD") {
validation = (Number.isFinite(parseFloat(price)) && parseFloat(price) > 0)
? "SIGNAL_CONFIRMED"
: "NO_SELL_PRICE";
}
return {
action,
ratio_pct: ratio,
limit_price: price,
price_source: priceSource,
price_basis: priceBasis,
execution_window: executionWindow,
order_type: orderType,
reason,
validation,
cash_preserve_style: cashPreservePlan.style,
cash_preserve_ratio: cashPreservePlan.recommended_ratio,
cash_preserve_reason: cashPreservePlan.reasons,
};
}
// Backward-compatible thin wrapper.
// Existing data_feed callers still expect calcSellRoute_.
var calcSellRoute_ = function(ctx) {
return calcExitSellAction_(ctx || {});
}
// ── [2026-05-21_CLA_HARNESS_V1] REPLACEMENT_ALPHA_GATE_V1 ───────────────────
/**
* CLA 레짐에서 위성 신규 BUY 전 코어 대비 알파 우위 검증.
* spec/13_formula_registry.yaml:REPLACEMENT_ALPHA_GATE_V1
* @return {{ rag_v1: 'PASS'|'FAIL'|'EXEMPT', rag_reason: string }}
*/
function validateReplacementAlpha_(ctx) {
const posRec = ctx.posRec;
if (posRec && posRec.position_type === 'core') {
return { rag_v1: 'EXEMPT', rag_reason: 'core_exempt' };
}
const r = String(ctx.globalRegimePrelim_ || '').toUpperCase();
const isCLA = r.indexOf('CONCENTRATED_LEADER_ADVANCE') >= 0 || r === 'CLA';
if (!isCLA) return { rag_v1: 'EXEMPT', rag_reason: 'regime_not_cla' };
const rsVerdict = String(ctx.rs_verdict || 'UNKNOWN');
const ss001Norm = typeof ctx.ss001_norm === 'number' ? ctx.ss001_norm : null;
const excessRet10d = typeof ctx.excess_ret_10d === 'number' ? ctx.excess_ret_10d : null;
const coreAvgSS001 = typeof ctx.coreAvgSS001 === 'number' ? ctx.coreAvgSS001 : 60;
const condA = ['LEADER', 'MARKET'].includes(rsVerdict);
const condB = ss001Norm !== null && ss001Norm >= coreAvgSS001 - 10;
const condC = excessRet10d !== null && excessRet10d >= -5;
const condD = excessRet10d === null || excessRet10d >= 0 || rsVerdict === 'LEADER';
const pass = condA && condB && condC && condD;
return {
rag_v1: pass ? 'PASS' : 'FAIL',
rag_reason: !condA ? 'rs_verdict_weak' :
!condB ? 'ss001_below_core' :
!condC ? 'excess_ret_breach' :
!condD ? 'rs_slope_negative' : 'pass'
};
}
// ── F7: 최종 액션 우선순위 엔진 ─────────────────────────────────────────────
// LLM이 호출마다 임의 판단하지 않도록 최종 액션·순위 점수를 룰 엔진에서 고정한다.
var calcPortfolioActionRoute_ = function(ctx) {
const sellAction = String(ctx.sellAction ?? "HOLD");
const sellValidation = String(ctx.sellValidation ?? "");
const allowedAction = String(ctx.allowedAction ?? "");
const timingAction = String(ctx.timingAction ?? "");
const timingEntry = parseFloat(ctx.timingScoreEntry);
const timingExit = parseFloat(ctx.timingScoreExit);
const ss001Total = parseFloat(ctx.ss001Total);
const flowCredit = parseFloat(ctx.flowCredit);
const leaderTotal = parseFloat(ctx.leaderTotal);
const rwPartial = parseFloat(ctx.rwPartial);
const profitPct = parseFloat(ctx.profitPct);
const daysToTimeStop = parseFloat(ctx.daysToTimeStop);
const weightPct = parseFloat(ctx.weightPct);
const acGate = String(ctx.acGate ?? "");
const liquidityStatus = String(ctx.liquidityStatus ?? "");
const spreadStatus = String(ctx.spreadStatus ?? "");
const dartRisk = !!ctx.dartRisk;
const missingFields = String(ctx.missingFields ?? "");
let finalAction = "HOLD";
let actionPriority = 99;
let sourceTag = "RULE_ENGINE";
if (sellAction !== "HOLD" && sellValidation === "SIGNAL_CONFIRMED") {
// 미보유(weightPct=0) 종목에 SELL_READY를 주면 주문수량=0 이므로 WATCH_EXIT_SIGNAL 로 다운그레이드
if (!(weightPct > 0)) {
finalAction = "WATCH_EXIT_SIGNAL";
actionPriority = 35;
} else {
finalAction = "SELL_READY";
actionPriority = 10;
}
} else if (allowedAction === "EXIT_SIGNAL" || timingAction === "STOP_OR_TIME_EXIT_READY") {
finalAction = "EXIT_SIGNAL";
actionPriority = 28;
} else if (allowedAction === "REVIEW_EXIT" || timingAction === "EXIT_REVIEW") {
finalAction = "EXIT_REVIEW";
actionPriority = 32;
} else if (timingAction === "NO_BUY_OVERHEATED" && !dartRisk) {
finalAction = "NO_BUY_OVERHEATED";
actionPriority = 50;
} else if (allowedAction === "BUY_STAGE1_READY" || timingAction === "BUY_STAGE1_READY") {
finalAction = "BUY_STAGE1_READY";
actionPriority = 60;
} else if (allowedAction === "BUY_BREAKOUT_PILOT_ONLY" || timingAction === "BUY_BREAKOUT_PILOT_ONLY") {
finalAction = "BUY_BREAKOUT_PILOT_ONLY";
actionPriority = 70;
} else if (allowedAction === "BUY_PULLBACK_WAIT" || timingAction === "BUY_PULLBACK_WAIT") {
finalAction = "BUY_PULLBACK_WAIT";
actionPriority = 80;
} else if (allowedAction === "WATCH_CANDIDATE") {
finalAction = "WATCH_TIMING_SETUP";
actionPriority = 90;
}
if (missingFields) sourceTag = "RULE_ENGINE_WITH_MISSING_DATA";
const timeStopUrgency = Number.isFinite(daysToTimeStop) && daysToTimeStop >= 0
? Math.max(0, 20 - Math.min(20, daysToTimeStop * 3))
: 0;
const overweightPenalty = Number.isFinite(weightPct) && weightPct > 7 ? 15 : 0;
const overheatPenalty = acGate === "BLOCK" ? 30 : acGate === "CAUTION" ? 10 : 0;
const liquidityPenalty =
["LOW", "DATA_MISSING"].includes(liquidityStatus) ||
["BLOCK", "WIDE", "QUOTE_NO_MATCH"].includes(spreadStatus)
? 15
: 0;
let priorityScore;
if (actionPriority <= 40) {
priorityScore =
(Number.isFinite(timingExit) ? timingExit : 0) * 0.35 +
(Number.isFinite(rwPartial) ? rwPartial : 0) * 15 +
Math.max(0, Number.isFinite(profitPct) ? profitPct : 0) * 0.30 +
timeStopUrgency +
overweightPenalty;
} else if (actionPriority >= 50 && actionPriority <= 80) {
priorityScore =
(Number.isFinite(timingEntry) ? timingEntry : 0) * 0.35 +
(Number.isFinite(ss001Total) ? ss001Total : 0) * 0.30 +
(Number.isFinite(flowCredit) ? flowCredit : 0) * 20 +
(Number.isFinite(leaderTotal) ? leaderTotal : 0) * 5 -
overheatPenalty -
liquidityPenalty;
} else {
priorityScore =
(Number.isFinite(timingEntry) ? timingEntry : 0) * 0.20 +
(Number.isFinite(timingExit) ? timingExit : 0) * 0.20 +
(Number.isFinite(flowCredit) ? flowCredit : 0) * 10;
}
return {
final_action: finalAction,
action_priority: actionPriority,
priority_score: parseFloat(Math.max(0, priorityScore).toFixed(2)),
source_tag: sourceTag,
};
}
// Backward-compatible thin wrapper.
// Existing data_feed callers still expect calcFinalRoute_.
var calcFinalRoute_ = function(ctx) {
const d = calcPortfolioActionRoute_(ctx || {});
return {
final_action: d.final_action,
action_priority: d.action_priority,
priority_score: d.priority_score,
route_source: d.source_tag,
};
}
// ── SS001 종목 점수 계산 (spec/08_scoring_rules.yaml SS001_SECTOR_MODEL_SCORE) ──
// runDataFeed 루프에서 분리. 1개 종목 → 점수 객체 반환.
// ctx 필드: rsPct20D, avgTV5D, avgTV20D, flowCredit, epsRevisionStatus,
// regimePrelim, isKosdaq, sfMedPE, sfMedPBR, forwardPE, pbrVal, epsGrowth1y
function calcSS001Score_(ctx) {
// SS001_P: price_strength (max 25) — RS_Pct_20D → percentile 변환
const rsPercentile = Number.isFinite(ctx.rsPct20D) ? (100 - ctx.rsPct20D) : null;
const ss001_p = rsPercentile !== null ? (rsPercentile <= 30 ? 25 : rsPercentile <= 60 ? 15 : 0) : 0;
// SS001_V: volume_quality (max 15)
const volRatio = Number.isFinite(ctx.avgTV5D) && Number.isFinite(ctx.avgTV20D) && ctx.avgTV20D > 0
? ctx.avgTV5D / ctx.avgTV20D : null;
const ss001_v = volRatio !== null ? (volRatio >= 1.20 ? 15 : volRatio >= 0.80 ? 8 : 0) : 0;
// SS001_F: flow_quality (max 25)
const fc = ctx.flowCredit ?? 0;
const ss001_f = fc >= 0.70 ? 25 : fc >= 0.40 ? 12 : 0;
// SS001_E: earnings_revision (max 20)
const ss001_e = ctx.epsRevisionStatus === "UP" ? 20 : ctx.epsRevisionStatus === "FLAT" ? 10 : 0;
// SS001_M: macro_regime (max 10)
const r = ctx.regimePrelim ?? "";
const ss001_m = (r === "RISK_ON" || r === "LEADER_CONCENTRATION" || r === "SECULAR_LEADER_RISK_ON")
? 10 : r === "NEUTRAL" ? 5 : 0;
// SS001_VAL: valuation (max 5 KOSPI / max 12 KOSDAQ)
let ss001_val = 0, pegVal = "", pegGate = "";
if (ctx.isKosdaq) {
const epsG = Number.isFinite(ctx.epsGrowth1y) && ctx.epsGrowth1y > 0 ? ctx.epsGrowth1y : null;
if (Number.isFinite(ctx.forwardPE) && epsG !== null) {
pegVal = parseFloat((ctx.forwardPE / epsG).toFixed(2));
pegGate = pegVal <= 1.5 ? "PASS" : pegVal <= 2.5 ? "CAUTION" : "REJECT";
ss001_val = pegVal <= 1.0 ? 12 : pegVal <= 1.5 ? 9 : pegVal <= 2.0 ? 5 : pegVal <= 2.5 ? 2 : 0;
} else if (Number.isFinite(ctx.forwardPE) && Number.isFinite(ctx.sfMedPE) && ctx.sfMedPE > 0) {
pegGate = "FALLBACK";
ss001_val = ctx.forwardPE <= ctx.sfMedPE * 2.0 ? 9 : ctx.forwardPE <= ctx.sfMedPE * 3.0 ? 4 : 0;
}
} else {
const peOk = Number.isFinite(ctx.forwardPE) && Number.isFinite(ctx.sfMedPE) && ctx.sfMedPE > 0;
const pbrOk = Number.isFinite(ctx.pbrVal) && Number.isFinite(ctx.sfMedPBR) && ctx.sfMedPBR > 0;
if (peOk || pbrOk) {
const atOrBelow = (peOk && ctx.forwardPE <= ctx.sfMedPE) || (pbrOk && ctx.pbrVal <= ctx.sfMedPBR);
const at1_5x = (peOk && ctx.forwardPE <= ctx.sfMedPE * 1.5) || (pbrOk && ctx.pbrVal <= ctx.sfMedPBR * 1.5);
ss001_val = atOrBelow ? 5 : at1_5x ? 2 : 0;
}
}
const ss001_total = ss001_p + ss001_v + ss001_f + ss001_e + ss001_m + ss001_val;
const ss001_norm = ss001_total / (ctx.isKosdaq ? 107 : 100) * 100;
const ss001_grade = ss001_norm >= 80 ? "A" : ss001_norm >= 65 ? "B" : ss001_norm >= 50 ? "C" : "D";
return { ss001_p, ss001_v, ss001_f, ss001_e, ss001_m, ss001_val,
ss001_total, ss001_norm, ss001_grade, pegVal, pegGate };
}
function buildAllowedAction(score, priceStatus, atr20, dartSummary, flowOk, avgTradingValue5D, spreadPct) {
if (priceStatus !== "PRICE_OK" || !Number.isFinite(atr20)) return "OBSERVE_ONLY";
if (dartSummary?.risk) return "HOLD_NO_ADD";
if (!flowOk) return "NO_ADD";
if (Number.isFinite(avgTradingValue5D) && avgTradingValue5D < 50) return "NO_ADD";
if (Number.isFinite(spreadPct) && spreadPct > 0.8) return "NO_ADD";
if (score >= 70 && dartSummary?.status === "NAVER_NOTICE_EMPTY") return "HOLD";
if (score >= 50) return "CONDITIONAL_HOLD";
return "SELL_ALLOWED";
}
function calcCoreCandidateQualityGrade_(ctx) {
const score = parseFloat(ctx.rotationScore);
const flowOk = String(ctx.flowOk ?? "") === "Y" || ctx.flowOk === true;
const priceStatus = String(ctx.priceStatus ?? "");
const liquidityStatus = String(ctx.liquidityStatus ?? "");
const dartRisk = String(ctx.dartRisk ?? "").trim();
const missing = String(ctx.missingFields ?? "").trim();
if (priceStatus !== "PRICE_OK" || missing || dartRisk || ["LOW", "DATA_MISSING"].includes(liquidityStatus)) return "D";
if (Number.isFinite(score) && score >= 80 && flowOk) return "A";
if (Number.isFinite(score) && score >= 65 && flowOk) return "B";
if (Number.isFinite(score) && score >= 50) return "C";
return "D";
}
function calcT1ForcedSellRisk_(ctx) {
let score = 0;
const reasons = [];
const sellAction = String(ctx.sellAction ?? "");
const sellValidation = String(ctx.sellValidation ?? "");
const timingExit = parseFloat(ctx.timingScoreExit);
const rwPartial = parseFloat(ctx.rwPartial);
const rsi14 = parseFloat(ctx.rsi14);
const disparity = parseFloat(ctx.disparity);
const valSurge = parseFloat(ctx.valSurgePct);
const ret5D = parseFloat(ctx.ret5D);
const dartRisk = String(ctx.dartRisk ?? "").trim();
const lateChase = parseFloat(ctx.lateChaseRiskScore);
const distribution = parseFloat(ctx.distributionRiskScore);
if (sellAction && sellAction !== "HOLD" && sellValidation !== "NO_SELL_ACTION") {
score += 40;
reasons.push("SELL_ACTION_ACTIVE");
}
if (Number.isFinite(timingExit) && timingExit >= 50) {
score += 25;
reasons.push("TIMING_EXIT>=50");
}
if (Number.isFinite(rwPartial) && rwPartial >= 2) {
score += 25;
reasons.push("RW>=2");
}
if (Number.isFinite(distribution) && distribution >= 70) {
score += 30;
reasons.push("DISTRIBUTION>=70");
}
if (Number.isFinite(lateChase) && lateChase >= 70) {
score += 25;
reasons.push("LATE_CHASE>=70");
}
if ((Number.isFinite(rsi14) && rsi14 > 75) || (Number.isFinite(disparity) && disparity > 12)) {
score += 20;
reasons.push("OVERHEATED");
}
if (Number.isFinite(valSurge) && valSurge >= 40 && Number.isFinite(ret5D) && ret5D > 8) {
score += 15;
reasons.push("SURGE_AFTER_RUNUP");
}
if (dartRisk) {
score += 30;
reasons.push("DART_RISK");
}
score = Math.max(0, Math.min(100, Math.round(score)));
const state = score >= 70 ? "BUY_BLOCKED_T1_EXIT_RISK" : score >= 50 ? "WATCH_ONLY_T1_RISK" : "PASS";
return { score, state, reason: reasons.join("|") || "PASS" };
}
function calcSellConflictScore_(ctx) {
let score = 0;
const reasons = [];
const sellFinal = String(ctx.sellFinal ?? "");
const sellAction = String(ctx.sellAction ?? "");
const cashStyle = String(ctx.cashPreserveStyle ?? "");
const allowedAction = String(ctx.allowedAction ?? "");
if (["SELL_READY", "EXIT_SIGNAL", "EXIT_REVIEW"].includes(sellFinal) || (sellAction && sellAction !== "HOLD")) {
score += 55;
reasons.push("SELL_SIGNAL_ACTIVE");
}
if (cashStyle && cashStyle !== "NONE") {
score += 20;
reasons.push("CASH_PRESERVE_ACTIVE");
}
if (["NO_ADD", "HOLD_NO_ADD", "OBSERVE_ONLY"].includes(allowedAction)) {
score += 20;
reasons.push("NO_ADD_GATE");
}
score = Math.max(0, Math.min(100, Math.round(score)));
const state = score >= 70 ? "BUY_BLOCKED_SELL_CONFLICT" : score >= 40 ? "SELL_OR_TRIM_FIRST" : "PASS";
return { score, state, reason: reasons.join("|") || "PASS" };
}
function calcCoreSatelliteExecutionState_(ctx) {
const quality = String(ctx.candidateQualityGrade ?? "");
const timingAction = String(ctx.timingAction ?? "");
const entryGate = String(ctx.entryModeGate ?? "");
const t1State = String(ctx.t1State ?? "");
const sellConflictState = String(ctx.sellConflictState ?? "");
const allowedAction = String(ctx.allowedAction ?? "");
if (sellConflictState === "BUY_BLOCKED_SELL_CONFLICT" || sellConflictState === "SELL_OR_TRIM_FIRST") return sellConflictState;
if (t1State === "BUY_BLOCKED_T1_EXIT_RISK" || t1State === "WATCH_ONLY_T1_RISK") return t1State;
if (["NO_ADD", "HOLD_NO_ADD", "OBSERVE_ONLY"].includes(allowedAction)) return "BUY_BLOCKED_PORTFOLIO_GUARD";
if (quality === "A" && entryGate === "PASS" && ["BUY_STAGE1_READY", "BUY_BREAKOUT_PILOT_ONLY"].includes(timingAction)) return "BUY_PILOT_ALLOWED";
if (quality === "A" || quality === "B") {
if (entryGate === "PASS") return "WATCH_BREAKOUT_RETEST";
return "WATCH_PULLBACK";
}
return "CANDIDATE_ONLY";
}
function calcApexTradePlan_(h, df, h1, alphaRow, ftRow, distRow, priceRow, orderRow, sq, profitRow, cashShortfallInfo, saqgState) {
var buyState = 'BLOCKED';
var buyReasons = [];
if (h1.cashFloorStatus !== 'PASS') buyReasons.push('cash_floor_not_pass');
if (h1.heatGate === 'BLOCK_NEW_BUY') buyReasons.push('heat_block_new_buy');
if (distRow.anti_distribution_state !== 'PASS') buyReasons.push('distribution_' + distRow.anti_distribution_state);
if (alphaRow.lead_entry_state === 'PILOT_ALLOWED' && buyReasons.length === 0) buyState = 'ALLOW_PILOT';
else if (ftRow.follow_through_state === 'CONFIRMED_ADD_ON' && buyReasons.length === 0) buyState = 'ALLOW_ADD_ON';
else if (buyReasons.length === 0) buyState = 'WATCH';
if (saqgState === 'EXCLUDED') {
buyState = 'BLOCKED';
buyReasons.push('saqg_EXCLUDED');
} else if (saqgState === 'WATCHLIST_ONLY' && (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON')) {
buyState = 'WATCH';
buyReasons.push('saqg_WATCHLIST_ONLY');
}
var style = 'URGENT_LIQUIDITY_TRIM';
if ((df.rsi14 && df.rsi14 < 35) || (df.bbPosition && df.bbPosition < 20) || (df.ma20 && h.close && h.close < df.ma20 * 0.92)) {
style = 'OVERSOLD_REBOUND_SELL';
} else if (distRow.anti_distribution_state === 'BLOCK_BUY') {
style = 'DISTRIBUTION_EXIT';
} else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20'
|| profitRow.profit_preservation_state === 'PROFIT_LOCK_30'
|| profitRow.profit_preservation_state === 'APEX_TRAILING') {
style = 'PROFIT_PROTECT_TRIM';
}
var baseQty = typeof sq.sell_qty === 'number' ? sq.sell_qty : 0;
var close = h.close || df.close || 0;
var prevClose = df.prevClose || close;
var atr20 = df.atr20 || 0;
var holdingQty = h.holdingQty || 0;
var shortfallMin = cashShortfallInfo.cash_shortfall_min_krw || 0;
var immediateQty;
var reboundQty;
var k2Emergency;
if (style === 'OVERSOLD_REBOUND_SELL') {
var halfQty = Math.floor(baseQty / 2);
var halfExpectedKrw = halfQty * close;
k2Emergency = shortfallMin > 0 && (halfExpectedKrw * 2 < shortfallMin);
if (k2Emergency) {
immediateQty = baseQty;
reboundQty = 0;
} else {
immediateQty = halfQty;
reboundQty = Math.max(0, baseQty - halfQty);
}
var overSoldCap = holdingQty;
if (profitRow.profit_preservation_state === 'PROFIT_LOCK_30' || profitRow.profit_preservation_state === 'APEX_TRAILING') {
overSoldCap = Math.floor(holdingQty * 0.40);
} else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20') {
overSoldCap = Math.floor(holdingQty * 0.35);
} else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_10') {
overSoldCap = Math.floor(holdingQty * 0.30);
} else {
overSoldCap = Math.floor(holdingQty * 0.50);
}
immediateQty = Math.min(immediateQty, overSoldCap);
} else {
k2Emergency = false;
var capPct = 50;
if (style === 'PROFIT_PROTECT_TRIM') {
if (profitRow.profit_preservation_state === 'PROFIT_LOCK_30' || profitRow.profit_preservation_state === 'APEX_TRAILING') capPct = 50;
else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_20') capPct = 35;
else capPct = 25;
} else if (style === 'DISTRIBUTION_EXIT') {
capPct = 50;
}
immediateQty = Math.min(baseQty, Math.floor(holdingQty * capPct / 100));
reboundQty = 0;
}
var hasPosition = holdingQty > 0;
var tranchePhase;
var currentTrancheAllowedPct;
var nextTrancheCondition;
if (!hasPosition) {
if (alphaRow.lead_entry_state === 'PILOT_ALLOWED' && buyState === 'ALLOW_PILOT') {
tranchePhase = 'TRANCHE_1_PILOT';
currentTrancheAllowedPct = 30;
nextTrancheCondition = 'CONFIRMED_ADD_ON';
} else {
tranchePhase = 'WAIT_PILOT_SETUP';
currentTrancheAllowedPct = 0;
nextTrancheCondition = 'ALPHA_LEAD_SCORE_GTE_75_AND_DISTRIBUTION_PASS';
}
} else if (ftRow.follow_through_state === 'CONFIRMED_ADD_ON' && buyState === 'ALLOW_ADD_ON') {
tranchePhase = 'TRANCHE_2_ADD_ON';
currentTrancheAllowedPct = 30;
nextTrancheCondition = 'SECONDARY_PULLBACK_TO_MA20';
} else if (alphaRow.close_vs_ma20_pct !== null && alphaRow.close_vs_ma20_pct <= 2
&& profitRow.profit_pct > 3 && ftRow.follow_through_state !== 'FAILED_BREAKOUT'
&& buyState === 'ALLOW_ADD_ON') {
tranchePhase = 'TRANCHE_3_PULLBACK_ADD';
currentTrancheAllowedPct = 40;
nextTrancheCondition = 'HOLD_FULL_POSITION';
} else {
tranchePhase = 'HOLD_CURRENT';
currentTrancheAllowedPct = 0;
nextTrancheCondition = ftRow.follow_through_state === 'FAILED_BREAKOUT'
? 'RECOVERY_ABOVE_MA20' : 'CONFIRMED_ADD_ON_OR_PULLBACK';
}
var sellRawPrice = null;
if (close > 0) {
if (style === 'URGENT_LIQUIDITY_TRIM') {
sellRawPrice = prevClose > 0 ? Math.min(close, prevClose * 0.998) : close * 0.998;
} else if (style === 'OVERSOLD_REBOUND_SELL') {
sellRawPrice = close;
} else if (style === 'DISTRIBUTION_EXIT') {
sellRawPrice = atr20 > 0 ? close - 0.25 * atr20 : close * 0.997;
} else if (style === 'PROFIT_PROTECT_TRIM') {
var ratchetStop = priceRow.ratchet_stop_price || 0;
sellRawPrice = ratchetStop > 0 ? Math.max(ratchetStop, close * 0.999) : close * 0.999;
}
}
var buyRawPrice = null;
if (close > 0) {
if (buyState === 'ALLOW_PILOT') {
buyRawPrice = Math.min(close * 1.002, df.ma20 > 0 ? df.ma20 * 1.08 : close * 1.002);
} else if (buyState === 'ALLOW_ADD_ON') {
buyRawPrice = prevClose > 0 ? Math.min(close * 1.002, prevClose * 1.01) : close * 1.002;
}
}
var normalizedSellPrice = (sellRawPrice && sellRawPrice > 0) ? tickNormalize_(sellRawPrice) : null;
var normalizedBuyPrice = (buyRawPrice && buyRawPrice > 0) ? tickNormalize_(buyRawPrice) : null;
var htsLimitPrice = orderRow.limit_price_krw
? tickNormalize_(orderRow.limit_price_krw)
: normalizedSellPrice || normalizedBuyPrice;
return {
buyState: buyState,
buyReasons: buyReasons,
style: style,
immediateQty: immediateQty,
reboundQty: reboundQty,
k2Emergency: k2Emergency,
tranchePhase: tranchePhase,
currentTrancheAllowedPct: currentTrancheAllowedPct,
nextTrancheCondition: nextTrancheCondition,
normalizedSellPrice: normalizedSellPrice,
normalizedBuyPrice: normalizedBuyPrice,
htsLimitPrice: htsLimitPrice,
};
}
// ── account_snapshot 읽기 → TOTAL_HEAT_V1 계산 ───────────────────────────────
// account_snapshot이 보유수량·평단·선택 손절가의 단일 원장이다.
// stop_price 미입력이면 ATR 기반 추정으로 대체.
// total_asset_krw를 인수로 받아야 정확한 열%를 계산할 수 있음; 미제공 시 null.
function readAccountSnapshotHeat_(total_asset_krw) {
const UNKNOWN = { total_heat_pct: null, total_heat_krw: null,
hf005_status: "UNKNOWN (account_snapshot 없음)", positions_count: 0 };
try {
const ss = getSpreadsheet_();
const snapshot = readAccountSnapshotMap_();
if (!snapshot.rows_confirmed) return UNKNOWN;
// data_feed ATR20 읽기 (stop_price 미입력 시 추정용)
const atrMap = {};
try {
const dfSheet = ss.getSheetByName("data_feed");
if (dfSheet) {
const dfData = dfSheet.getDataRange().getValues();
const dfHdr = dfData[1]?.map(h => String(h).trim()) ?? [];
const dfTkr = dfHdr.indexOf("Ticker");
const dfAtr = dfHdr.indexOf("ATR20");
const dfClose= dfHdr.indexOf("Close");
if (dfTkr >= 0 && dfAtr >= 0 && dfClose >= 0) {
for (let i = 2; i < dfData.length; i++) {
const tk = String(dfData[i][dfTkr]).trim();
const atr = parseFloat(dfData[i][dfAtr]);
const cls = parseFloat(dfData[i][dfClose]);
if (tk && Number.isFinite(atr) && Number.isFinite(cls)) atrMap[tk] = { atr20: atr, close: cls };
}
}
}
} catch(e2) { }
let totalHeatKrw = 0;
let posCount = 0;
let hasEstimate = false;
const details = [];
Object.values(snapshot.positions).forEach(pos => {
const qty = parseInt(pos.quantity, 10);
if (!Number.isFinite(qty) || qty <= 0) return;
const entry = parseFloat(pos.average_cost ?? pos.entry_price);
if (!Number.isFinite(entry) || entry <= 0) return;
let stop = parseFloat(pos.stop_price);
if (!Number.isFinite(stop) || stop <= 0) {
// ATR 기반 추정
const tk = pos.ticker;
const atrInfo = atrMap[tk];
if (atrInfo) {
stop = entry - atrInfo.atr20 * THRESHOLDS.ATR_TRAILING_MULT;
hasEstimate = true;
} else {
stop = entry * 0.92; // 8% 고정 추정
hasEstimate = true;
}
}
if (stop >= entry) return; // PS002 위반 행 건너뜀
const heatKrw = (entry - stop) * qty;
totalHeatKrw += heatKrw;
posCount++;
details.push(`${qty}주×${Math.round(entry-stop)}원`);
});
if (posCount === 0) return { total_heat_pct: 0, total_heat_krw: 0,
hf005_status: "PASS (포지션 없음)", positions_count: 0 };
const estTag = hasEstimate ? "(ATR추정)" : "";
if (!Number.isFinite(total_asset_krw) || total_asset_krw <= 0) {
return {
total_heat_pct: null,
total_heat_krw: Math.round(totalHeatKrw),
hf005_status: `UNKNOWN (총자산 미제공)${estTag}`,
positions_count: posCount,
};
}
const heatPct = (totalHeatKrw / total_asset_krw) * 100;
const hf005 = heatPct >= 10
? `BLOCK (>= 10%: ${heatPct.toFixed(1)}%)${estTag}`
: `PASS (< 10%: ${heatPct.toFixed(1)}%)${estTag}`;
return {
total_heat_pct: parseFloat(heatPct.toFixed(2)),
total_heat_krw: Math.round(totalHeatKrw),
hf005_status: hf005,
positions_count: posCount,
};
} catch(e) {
handleFetchError_("readAccountSnapshotHeat_", e, "WARN");
return { total_heat_pct: null, total_heat_krw: null,
hf005_status: "ERROR: " + e.message, positions_count: 0 };
}
}
// 상승 추세 보존 점수: 높을수록 매도 우선순위를 늦춘다.
function calcReboundHoldbackScore_(ctx) {
const close = parseFloat(ctx.close);
const ma20 = parseFloat(ctx.ma20);
const ma60 = parseFloat(ctx.ma60);
const ma20Slope = parseFloat(ctx.ma20Slope);
const rsi14 = parseFloat(ctx.rsi14);
const bbPosition = parseFloat(ctx.bbPosition);
const flowCredit = parseFloat(ctx.flowCredit);
const leaderTotal = parseFloat(ctx.leaderTotal);
const leaderGate = String(ctx.leaderGate ?? "");
const bandStatus = String(ctx.bandStatus ?? "");
const profitPct = parseFloat(ctx.profitPct);
const isCoreLeader = !!ctx.isCoreLeader;
let score = 0;
const reasons = [];
const aboveMa20 = Number.isFinite(close) && Number.isFinite(ma20) && close >= ma20;
const aboveMa60 = Number.isFinite(close) && Number.isFinite(ma60) && close >= ma60;
if (isCoreLeader && aboveMa20 && Number.isFinite(ma20Slope) && ma20Slope > 0) {
score += 12;
reasons.push("core_uptrend:+12");
} else if (aboveMa20 && Number.isFinite(ma20Slope) && ma20Slope > 0) {
score += 8;
reasons.push("trend_hold:+8");
}
if (Number.isFinite(leaderTotal) && leaderTotal >= 80) {
score += 6;
reasons.push("leader_total:+6");
} else if (leaderGate === "PASS") {
score += 4;
reasons.push("leader_pass:+4");
}
if (Number.isFinite(flowCredit) && flowCredit >= 0.7) {
score += 6;
reasons.push("flow_strong:+6");
}
if (Number.isFinite(rsi14)) {
if (rsi14 <= 62) {
score += 4;
reasons.push("rsi_room:+4");
} else if (rsi14 >= 72) {
score -= 6;
reasons.push("rsi_hot:-6");
}
}
if (Number.isFinite(bbPosition) && bbPosition <= 0.7) {
score += 3;
reasons.push("bb_room:+3");
}
if (bandStatus === "UNDERWEIGHT") {
score += 3;
reasons.push("band_under:+3");
}
if (Number.isFinite(profitPct) && profitPct >= 0 && aboveMa20 && aboveMa60) {
score += 3;
reasons.push("runner:+3");
}
return {
score: Math.max(0, Math.min(30, score)),
reasons: reasons.join(" | "),
};
}
// 현금확보 시 반등 보존형 감축 계획.
// score는 sell_priority_score에서 보호 보너스로 쓰고, recommended_ratio는 주문 감축비율로 쓴다.
function calcCashPreservationPlan_(ctx) {
const cashFloorStatus = String(ctx.cashFloorStatus ?? "");
const regime = String(ctx.regime ?? "");
const sellAction = String(ctx.sellAction ?? ctx.action ?? "");
const isSellLike = /(SELL|TRIM|EXIT)/.test(sellAction);
const isCoreLeader = !!ctx.isCoreLeader;
const isEtf = !!ctx.isEtf;
const liquidityStatus = String(ctx.liquidityStatus ?? "");
const spreadStatus = String(ctx.spreadStatus ?? "");
const accountType = String(ctx.accountType ?? "");
const profitPct = parseFloat(ctx.profitPct);
const rwPartial = parseInt(ctx.rwPartial, 10) || 0;
const reboundHoldback = parseFloat(ctx.reboundHoldbackScore);
const holdbackScore = Number.isFinite(reboundHoldback) ? reboundHoldback : 0;
let recommendedRatio = isSellLike ? 50 : 0;
let style = "STEP_50";
let protectionBonus = 0;
const reasons = [];
if (isCoreLeader && holdbackScore >= 12) {
style = "CORE_LAST";
recommendedRatio = cashFloorStatus === "TRIM_REQUIRED" ? 25 : 0;
protectionBonus += 12;
reasons.push("core_last");
} else if (holdbackScore >= 18) {
style = "STEP_25";
recommendedRatio = 25;
protectionBonus += 10;
reasons.push("strong_rebound");
} else if (holdbackScore >= 10) {
style = "STEP_33";
recommendedRatio = 33;
protectionBonus += 6;
reasons.push("rebound_preserve");
}
if (isEtf && holdbackScore < 10) {
protectionBonus -= 2;
reasons.push("etf_cash_raise");
}
if (cashFloorStatus === "TRIM_REQUIRED" || /RISK_OFF/.test(regime)) {
protectionBonus += 2;
reasons.push("cash_preserve");
}
if (liquidityStatus === "LOW" || spreadStatus === "WIDE" || spreadStatus === "BLOCK") {
protectionBonus += 4;
reasons.push("impact_avoid");
}
if (accountType === "일반계좌" && Number.isFinite(profitPct) && profitPct > 0) {
protectionBonus += profitPct >= 20 ? 3 : 2;
reasons.push("tax_drag");
} else if (accountType === "일반계좌" && Number.isFinite(profitPct) && profitPct < 0) {
protectionBonus -= 2;
reasons.push("tax_loss_harvest");
}
if (rwPartial >= 3 && !isCoreLeader) {
recommendedRatio = Math.max(recommendedRatio, 50);
protectionBonus -= 4;
reasons.push("rw_force");
}
if (cashFloorStatus === "HARD_BLOCK") {
recommendedRatio = Math.max(recommendedRatio, 50);
reasons.push("cash_hard_block");
}
if (!isSellLike) recommendedRatio = 0;
recommendedRatio = Math.max(0, Math.min(50, recommendedRatio));
return {
style,
recommended_ratio: recommendedRatio,
protection_bonus: Math.max(0, Math.round(protectionBonus)),
reasons: reasons.join(" | "),
};
}
// ── 메인: 보유 종목 완성도 매트릭스 ─────────────────────────────────────
// data_feed는 보유 종목 원장 + 완성도 매트릭스의 canonical output.
// ── Sell_Priority_Score 산출 헬퍼 ────────────────────────────────────────────
// spec: spec/risk/portfolio_exposure.yaml:sell_priority_engine.candidate_scoring
// 입력: row 배열(data_feed headers 순서), headers 배열, sectorExposureMap(섹터→총비중%)
// 반환: { score, breakdown, priority_level, is_etf, is_core_leader }
// 호출 시점: runDataFeed post-loop(섹터집계 완료 후) & getDailyBrief/runSellPriority
var calcSellSignalSanityScore_ = function(row, headers, sectorExposureMap) {
const get = (col) => {
const i = headers.indexOf(col);
return i >= 0 ? row[i] : undefined;
};
const flt = (col) => { const v = parseFloat(get(col)); return Number.isFinite(v) ? v : null; };
const finalAction = String(get("Final_Action") ?? "");
const sellAction = String(get("Sell_Action") ?? "");
const ticker = String(get("Ticker") ?? "");
const name_ = String(get("Name") ?? "");
const rwPartial = parseInt(get("RW_Partial")) || 0;
const weightPct = flt("Weight_Pct") ?? 0;
const profitPct = flt("Profit_Pct");
const close_ = flt("Close");
const ma20_ = flt("MA20");
const ma60_ = flt("MA60");
const ma20Slope_ = flt("MA20_Slope");
const rsi14_ = flt("RSI14");
const bbPos_ = flt("BB_Position");
const flowCredit_ = flt("Flow_Credit");
const leaderTotal_= flt("Leader_Scan_Total");
const leaderGate_ = String(get("Leader_Gate") ?? "");
const bandStatus_ = String(get("Band_Status") ?? "");
const ss001Grade = String(get("SS001_Grade") ?? "");
const liquidityStatus_ = String(get("Liquidity_Status") ?? "");
const avgTradeValue5DM_ = flt("AvgTradeValue_5D_M");
const avgTradeValue5DKrw_= flt("AvgTradeValue_5D_KRW");
const spreadStatus_ = String(get("Spread_Status") ?? "");
const accountType_ = String(get("account_type") ?? get("Account_Type") ?? "");
const taxCostEstimate_ = flt("Tax_Cost_Estimate");
// ETF 여부: 이름 패턴 기준
const isEtf = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(name_);
// 직접 코어 주도주 (삼성전자·SK하이닉스)
const isCoreLeader = (ticker === "005930" || ticker === "000660");
// 상승추세 여부
const inUptrend = Number.isFinite(close_) && Number.isFinite(ma20_) && close_ >= ma20_;
// 섹터 총노출
const sector = TICKER_SECTOR_MAP[ticker] ?? "";
const sectorExp = (sectorExposureMap ?? {})[sector] ?? 0;
let score = 0;
const breakdown = [];
// ── 1. hard_precedence_points ─────────────────────────────────────────────
if (sellAction === "EXIT_100" || finalAction === "EXIT_SIGNAL") {
score += THRESHOLDS.SP_HARD_STOP;
breakdown.push(`hard_stop:+${THRESHOLDS.SP_HARD_STOP}`);
} else if (finalAction === "SELL_READY" ||
sellAction.includes("TRIM") || sellAction.includes("EXIT")) {
score += THRESHOLDS.SP_SELL_SIGNAL;
breakdown.push(`sell_signal:+${THRESHOLDS.SP_SELL_SIGNAL}`);
} else if (finalAction === "EXIT_REVIEW") {
score += THRESHOLDS.SP_HOLDINGS_ROTATE;
breakdown.push(`exit_review:+${THRESHOLDS.SP_HOLDINGS_ROTATE}`);
} else if (Number.isFinite(profitPct) && profitPct >= 10) {
score += THRESHOLDS["SP_TAKE_PROFIT"];
breakdown.push(`take_profit:+${THRESHOLDS["SP_TAKE_PROFIT"]}`);
}
// ── 2. duplicate_exposure_points (ETF 중복 노출) ──────────────────────────
if (isEtf) {
if (sectorExp >= THRESHOLDS.SP_DUPLICATE_THRESH) {
score += THRESHOLDS.SP_ETF_DUPLICATE;
breakdown.push(`etf_dup(${sector}${sectorExp.toFixed(1)}%):+${THRESHOLDS.SP_ETF_DUPLICATE}`);
} else if (sectorExp >= 10) {
score += THRESHOLDS.SP_ETF_MODERATE;
breakdown.push(`etf_moderate:+${THRESHOLDS.SP_ETF_MODERATE}`);
}
}
// ── 3. cash_relief_points (보유 비중 → 현금 회복 기여) ────────────────────
if (weightPct >= 3) {
score += THRESHOLDS.SP_CASH_LARGE;
breakdown.push(`cash_${weightPct.toFixed(1)}%:+${THRESHOLDS.SP_CASH_LARGE}`);
} else if (weightPct >= 1) {
score += THRESHOLDS.SP_CASH_MID;
breakdown.push(`cash_mid:+${THRESHOLDS.SP_CASH_MID}`);
} else {
score += THRESHOLDS.SP_CASH_SMALL;
}
// ── 4. weakness_points ───────────────────────────────────────────────────
if (rwPartial >= 4) {
score += THRESHOLDS.SP_RW4;
breakdown.push(`rw${rwPartial}:+${THRESHOLDS.SP_RW4}`);
} else if (rwPartial === 3) {
score += THRESHOLDS.SP_RW3;
breakdown.push(`rw3:+${THRESHOLDS.SP_RW3}`);
} else if (rwPartial === 2) {
score += THRESHOLDS.SP_RW2;
breakdown.push(`rw2:+${THRESHOLDS.SP_RW2}`);
}
if (Number.isFinite(close_) && Number.isFinite(ma20_) && close_ < ma20_) {
score += THRESHOLDS.SP_BELOW_MA20;
breakdown.push(`below_ma20:+${THRESHOLDS.SP_BELOW_MA20}`);
}
// 손실 위성: -10% 이하, 비ETF, 비코어리더
if (!isEtf && !isCoreLeader && Number.isFinite(profitPct) && profitPct <= -10) {
score += THRESHOLDS.SP_LOSS_SATELLITE;
breakdown.push(`loss_sat(${profitPct.toFixed(1)}%):+${THRESHOLDS.SP_LOSS_SATELLITE}`);
}
// ── 5. overweight_points ─────────────────────────────────────────────────
const targetW = isEtf ? 7 : (isCoreLeader ? 15 : 7);
const excess = weightPct - targetW;
if (excess >= 5) {
score += THRESHOLDS.SP_OVERWEIGHT_LARGE;
breakdown.push(`overweight:+${THRESHOLDS.SP_OVERWEIGHT_LARGE}`);
} else if (excess >= 2) {
score += THRESHOLDS.SP_OVERWEIGHT_MID;
breakdown.push(`overweight:+${THRESHOLDS.SP_OVERWEIGHT_MID}`);
}
// ── 6. core_quality_protection_points (음수 패널티) ──────────────────────
if (isCoreLeader && inUptrend) {
score += THRESHOLDS.SP_CORE_LEADER; // -20
breakdown.push(`core_leader_uptrend:${THRESHOLDS.SP_CORE_LEADER}`);
}
if (ss001Grade === "A") {
score += THRESHOLDS.SP_SS001_A; // -12
breakdown.push(`ss001_A:${THRESHOLDS.SP_SS001_A}`);
}
const reboundHoldback_ = calcReboundHoldbackScore_({
close: close_,
ma20: ma20_,
ma60: ma60_,
ma20Slope: ma20Slope_,
rsi14: rsi14_,
bbPosition: bbPos_,
flowCredit: flowCredit_,
leaderTotal: leaderTotal_,
leaderGate: leaderGate_,
bandStatus: bandStatus_,
profitPct: profitPct,
isCoreLeader: isCoreLeader,
});
if (reboundHoldback_.score > 0) {
score -= reboundHoldback_.score;
breakdown.push(`rebound_holdback:-${reboundHoldback_.score}${reboundHoldback_.reasons ? `(${reboundHoldback_.reasons})` : ""}`);
}
const preservationPlan_ = calcCashPreservationPlan_({
sellAction: finalAction,
cashFloorStatus: String(get("Cash_Floor_Status") ?? ""),
regime: String(get("Market_Regime") ?? ""),
isCoreLeader: isCoreLeader,
isEtf: isEtf,
liquidityStatus: liquidityStatus_,
spreadStatus: spreadStatus_,
accountType: accountType_,
profitPct: profitPct,
rwPartial: rwPartial,
reboundHoldbackScore: reboundHoldback_.score,
});
if (preservationPlan_.protection_bonus > 0) {
score -= preservationPlan_.protection_bonus;
breakdown.push(`cash_preserve:-${preservationPlan_.protection_bonus}${preservationPlan_.reasons ? `(${preservationPlan_.reasons})` : ""}`);
}
if (liquidityStatus_ === "OK" || (Number.isFinite(avgTradeValue5DM_) && avgTradeValue5DM_ >= 1000) || (Number.isFinite(avgTradeValue5DKrw_) && avgTradeValue5DKrw_ >= 1000000000)) {
score += 5;
breakdown.push("liquidity_ok:+5");
} else if (liquidityStatus_ === "LOW" || (Number.isFinite(avgTradeValue5DM_) && avgTradeValue5DM_ > 0 && avgTradeValue5DM_ < 100)) {
score -= 10;
breakdown.push("liquidity_low:-10");
}
let taxPenalty = 3;
let taxReason = "tax_unknown";
if (accountType_ === "ISA" || accountType_ === "연금저축") {
taxPenalty = 0;
taxReason = "tax_exempt";
} else if (Number.isFinite(taxCostEstimate_) && taxCostEstimate_ > 0) {
taxPenalty = Math.min(10, Math.round(taxCostEstimate_));
taxReason = "tax_cost_estimate";
} else if (Number.isFinite(profitPct) && profitPct > 0) {
taxPenalty = profitPct >= 20 ? 10 : 5;
taxReason = "tax_drag";
} else if (Number.isFinite(profitPct) && profitPct < 0) {
taxPenalty = -5;
taxReason = "tax_loss_harvest";
}
score -= taxPenalty;
breakdown.push(`tax_penalty:-${taxPenalty}${taxReason ? `(${taxReason})` : ""}`);
// 우선순위 단계 레이블 (spec: funding_order ①~④)
let priority_level;
if (sellAction === "EXIT_100" || finalAction === "EXIT_SIGNAL") {
priority_level = "1_hard_stop";
} else if (finalAction === "SELL_READY") {
priority_level = "2_sell_signal";
} else if (isEtf && sectorExp >= 10) {
priority_level = "3_duplicate_etf";
} else if (!isEtf && !isCoreLeader && Number.isFinite(profitPct) && profitPct <= -10) {
priority_level = "4_loss_satellite";
} else if (!isCoreLeader && rwPartial >= 3) {
priority_level = "5_rw_weakness";
} else if (Number.isFinite(profitPct) && profitPct >= 10) {
priority_level = "6_profit_lock";
} else if (isCoreLeader && inUptrend) {
priority_level = "9_core_leader_last";
} else {
priority_level = "7_general_rebalance";
}
return {
score: Math.min(100, Math.max(0, score)),
breakdown: breakdown.join(" | "),
priority_level,
is_etf: isEtf,
is_core_leader: isCoreLeader,
sector,
sector_exposure_pct: parseFloat(sectorExp.toFixed(1)),
rebound_holdback_score: reboundHoldback_.score,
rebound_holdback_reason: reboundHoldback_.reasons,
cash_preserve_style: preservationPlan_.style,
cash_preserve_ratio: preservationPlan_.recommended_ratio,
cash_preserve_reason: preservationPlan_.reasons,
};
}
// Backward-compatible thin wrapper.
// Existing data_feed callers still expect calcSellPriorityScore_.
var calcSellPriorityScore_ = function(row, headers, sectorExposureMap) {
return calcSellSignalSanityScore_(row, headers, sectorExposureMap);
};
// ── sell_priority_engine: 전 보유종목 매도 우선순위 순위표 ──────────────────
// spec: spec/risk/portfolio_exposure.yaml:sell_priority_engine
// doGet: ?view=sell_priority
// 활성화 조건: 현금 < 목표, REGIME_TRIM_50, 또는 SELL/TRIM 후보 2개 이상
// 핵심 보호 원칙: SK하이닉스·삼성전자(코어 주도주)는 hard_stop 없이는 마지막 순위
function runSellPriority() {
const port = getPortfolioJson();
const macro = getMacroJson();
const holdings = port.holdings ?? [];
const regime_ = String(macro.market_regime ?? "");
const computedAt_ = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd'T'HH:mm:ssXXX");
// 섹터 노출 집계
const sectorExpMap_ = {};
holdings.forEach(h => {
const sec_ = TICKER_SECTOR_MAP[h.Ticker] ?? "";
const w_ = parseFloat(h.Weight_Pct);
if (sec_ && Number.isFinite(w_) && w_ > 0)
sectorExpMap_[sec_] = (sectorExpMap_[sec_] || 0) + w_;
});
const validWeightCount_ = holdings.filter(h => {
const w = parseFloat(h.Weight_Pct);
return Number.isFinite(w) && w > 0;
}).length;
const missingWeightCount_ = holdings.length - validWeightCount_;
const asConfirmStats_ = getAccountSnapshotConfirmStats_();
const rows_ = holdings
.filter(h => {
const w = parseFloat(h.Weight_Pct);
return Number.isFinite(w) && w > 0;
})
.map(h => {
const isEtf_ = /KODEX|TIGER|KBSTAR|ARIRANG|HANARO|TIMEFOLIO/.test(h.Name);
const isCL_ = (h.Ticker === "005930" || h.Ticker === "000660");
const sec_ = TICKER_SECTOR_MAP[h.Ticker] ?? "";
const sExp_ = sectorExpMap_[sec_] ?? 0;
const pctP_ = parseFloat(h.Profit_Pct);
const rw_ = parseInt(h.RW_Partial) || 0;
const cl_ = parseFloat(h.Close);
const ma20_ = parseFloat(h.MA20);
const ma60_ = parseFloat(h.MA60);
const ma20Slope_ = parseFloat(h.MA20_Slope);
const rsi14_ = parseFloat(h.RSI14);
const bbPos_ = parseFloat(h.BB_Position);
const flowCredit_ = parseFloat(h.Flow_Credit);
const leaderTotal_ = parseFloat(h.Leader_Scan_Total);
const leaderGate_ = String(h.Leader_Gate ?? "");
const bandStatus_ = String(h.Band_Status ?? "");
const inUp_ = Number.isFinite(cl_) && Number.isFinite(ma20_) && cl_ >= ma20_;
const precomp = parseFloat(h.Sell_Priority_Score);
let score_;
if (Number.isFinite(precomp)) {
score_ = precomp;
} else {
score_ = 0;
if (h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") score_ += 50;
else if (h.Final_Action === "SELL_READY") score_ += 40;
else if (isEtf_ && sExp_ >= THRESHOLDS.SP_DUPLICATE_THRESH) score_ += 20;
if (rw_ >= 4) score_ += 20; else if (rw_ === 3) score_ += 15; else if (rw_ === 2) score_ += 8;
if (!isEtf_ && !isCL_ && Number.isFinite(pctP_) && pctP_ <= -10) score_ += 12;
if (isCL_ && inUp_) score_ -= 20;
if (h.SS001_Grade === "A") score_ -= 12;
score_ = Math.max(0, score_);
}
const reboundHoldback_ = calcReboundHoldbackScore_({
close: cl_,
ma20: ma20_,
ma60: ma60_,
ma20Slope: ma20Slope_,
rsi14: rsi14_,
bbPosition: bbPos_,
flowCredit: flowCredit_,
leaderTotal: leaderTotal_,
leaderGate: leaderGate_,
bandStatus: bandStatus_,
profitPct: pctP_,
isCoreLeader: isCL_,
});
const preservationPlan_ = calcCashPreservationPlan_({
sellAction: h.Sell_Action,
cashFloorStatus: String(macro.cash_floor_status ?? ""),
regime: regime_,
isCoreLeader: isCL_,
isEtf: isEtf_,
liquidityStatus: String(h.Liquidity_Status ?? h.LiquidityStatus ?? ""),
spreadStatus: String(h.Spread_Status ?? h.SpreadStatus ?? ""),
accountType: String(h.account_type ?? h.Account_Type ?? ""),
profitPct: pctP_,
rwPartial: rw_,
reboundHoldbackScore: reboundHoldback_.score,
});
const netScore_ = Number.isFinite(precomp)
? Math.min(100, Math.max(0, score_))
: Math.min(100, Math.max(0, score_ - reboundHoldback_.score));
const actionGroup_ =
(h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") ? "EXIT" :
String(h.Sell_Action ?? "").startsWith("TRIM") ? "TRIM" :
String(h.Sell_Action ?? "") === "HOLD" ? "HOLD" : "WATCH";
const actionGroupOrder_ =
actionGroup_ === "EXIT" ? 1 :
actionGroup_ === "TRIM" ? 2 :
actionGroup_ === "HOLD" ? 3 : 4;
let tier_, tierLabel_;
if (h.Final_Action === "EXIT_SIGNAL" || h.Sell_Action === "EXIT_100") {
tier_ = 1; tierLabel_ = "①하드스탑";
} else if (h.Final_Action === "SELL_READY") {
tier_ = 2; tierLabel_ = "②매도신호";
} else if (isEtf_ && sExp_ >= 10) {
tier_ = 3; tierLabel_ = "③중복ETF";
} else if (!isEtf_ && !isCL_ && Number.isFinite(pctP_) && pctP_ <= -10) {
tier_ = 4; tierLabel_ = "④손실위성";
} else if (!isCL_ && rw_ >= 3) {
tier_ = 5; tierLabel_ = "⑤RW약세";
} else if (!isCL_ && Number.isFinite(pctP_) && pctP_ >= 10) {
tier_ = 6; tierLabel_ = "⑥익절후보";
} else if (isCL_ && inUp_) {
tier_ = 9; tierLabel_ = "⑨코어주도주[마지막]";
} else {
tier_ = 7; tierLabel_ = "⑦일반";
}
return {
rank: 0,
tier: tier_, tier_label: tierLabel_,
ticker: h.Ticker, name: h.Name,
weight_pct: h.Weight_Pct, profit_pct: h.Profit_Pct,
rw_partial: rw_, ss001_grade: h.SS001_Grade,
sector: sec_, sector_exp_pct: parseFloat(sExp_.toFixed(1)),
is_etf: isEtf_, is_core_leader: isCL_,
final_action: h.Final_Action, sell_action: h.Sell_Action,
action_group: actionGroup_,
action_group_order: actionGroupOrder_,
sell_ratio_pct: h.Sell_Ratio_Pct,
sell_qty: h.Sell_Qty,
sell_limit_price: h.Sell_Limit_Price,
sell_validation: h.Sell_Validation,
action_reason: h.Action_Reason,
action_params: h.Action_Params ?? "",
score: netScore_,
sell_priority_score: netScore_,
raw_sell_priority_score: score_,
rebound_holdback_score: reboundHoldback_.score,
rebound_holdback_reason: reboundHoldback_.reasons,
cash_preserve_style: preservationPlan_.style,
cash_preserve_ratio: preservationPlan_.recommended_ratio,
cash_preserve_reason: preservationPlan_.reasons,
trim_style: isCL_ && inUp_
? "CORE_LAST"
: reboundHoldback_.score >= 18
? "STEP_25"
: reboundHoldback_.score >= 10
? "STEP_33"
: "STEP_50",
hold_reason: (isCL_ && inUp_)
? "core_leader_uptrend — 매도 마지막(spec:portfolio_exposure.yaml:funding_order④)" : "",
quantity_note: "매도수량은 HTS 캡처 제공 후 결정. 미제공 시 수량 기재 금지(spec:00_execution_contract.yaml:P1규칙).",
};
})
// Hard-lock sort policy: tier asc -> score desc -> action_group_order asc
.sort((a, b) => a.tier - b.tier || b.sell_priority_score - a.sell_priority_score || a.action_group_order - b.action_group_order);
rows_.forEach((r, i) => { r.rank = i + 1; });
const sheetHeaders_ = [
"Rank","Tier","Tier_Label","Action_Group","Ticker","Name","Sector","Weight_Pct","Profit_Pct",
"Final_Action","Sell_Action","Sell_Ratio_Pct","Sell_Qty","Sell_Limit_Price","Sell_Validation",
"Sell_Priority_Score","Raw_Sell_Priority_Score","Rebound_Holdback_Score",
"Cash_Preserve_Style","Cash_Preserve_Ratio","Cash_Preserve_Reason",
"Trim_Style","Hold_Reason","Action_Reason","Action_Params",
"Computed_At","Engine_Version","Sort_Policy_ID","Source_Context_Checksum"
];
const sourceContextChecksum_ = computeStringChecksum_(JSON.stringify({
market_regime: regime_,
cash_floor_status: String(macro.cash_floor_status ?? ""),
holdings_count: rows_.length,
holdings_keys: rows_.map(r => `${r.ticker}:${r.final_action}:${r.sell_action}:${r.tier}:${r.sell_priority_score}`)
}));
let sheetRows_ = rows_.map(r => ([
r.rank,
r.tier,
r.tier_label,
r.action_group,
r.ticker,
r.name,
r.sector,
r.weight_pct,
r.profit_pct,
r.final_action,
r.sell_action,
r.sell_ratio_pct ?? "",
r.sell_qty ?? "",
r.sell_limit_price,
r.sell_validation ?? "",
r.sell_priority_score,
r.raw_sell_priority_score,
r.rebound_holdback_score,
r.cash_preserve_style,
r.cash_preserve_ratio,
r.cash_preserve_reason,
r.trim_style,
r.hold_reason,
r.action_reason ?? "",
r.action_params,
computedAt_,
"sell_priority_engine_v2",
"SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION",
sourceContextChecksum_,
]));
// 데이터 준비 미흡 상태를 빈 시트로 숨기지 않고 명시적으로 기록한다.
if (!sheetRows_.length) {
sheetRows_ = [[
0,
"",
"DATA_MISSING",
"WATCH",
"",
"매도우선순위 산출 불가",
"",
"",
"",
"",
"",
"",
"",
"",
"DATA_MISSING",
"",
"",
"",
"",
"",
"",
"",
"",
`[SELL_PRIORITY_INPUT_MISSING] holdings=${holdings.length}, valid_weight=${validWeightCount_}, missing_weight=${missingWeightCount_}`,
"runDataFeed/account_snapshot 갱신 후 재실행 필요"
+ ` | account_snapshot confirmed=${asConfirmStats_.confirmed_rows}/${asConfirmStats_.rows_read}`
+ ` parse_ok_unconfirmed=${asConfirmStats_.parse_ok_unconfirmed}`,
computedAt_,
"sell_priority_engine_v2",
"SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION",
sourceContextChecksum_,
]];
}
writeToSheet("sell_priority", sheetHeaders_, sheetRows_);
const cashPct_ = parseFloat(macro.immediate_cash_pct ?? macro.cash_pct ?? "");
return {
engine: "sell_priority_engine_v2",
status: rows_.length ? "READY" : "DATA_MISSING",
activation_reason: regime_.includes("RISK_OFF") ? "REGIME_TRIM_50"
: (Number.isFinite(cashPct_) && cashPct_ < 10
? `cash_below_floor(${cashPct_.toFixed(1)}%)` : "manual_request"),
market_regime: regime_,
computed_at: computedAt_,
engine_version: "sell_priority_engine_v2",
sort_policy_id: "SELL_PRIORITY_SORT_V2_TIER_SCORE_ACTION",
sector_exposure: sectorExpMap_,
prohibition: [
"주도주(SK하이닉스·삼성전자) 매도는 hard_stop 또는 thesis 훼손 근거 필수(tier=9는 마지막).",
"매도수량은 HTS 캡처 제공 후 결정. 수량 미제공 시 수량 기재 금지(spec:P1규칙).",
],
source_context_checksum: sourceContextChecksum_,
diagnostics: {
holdings_count: holdings.length,
valid_weight_count: validWeightCount_,
missing_weight_count: missingWeightCount_,
account_snapshot_rows_read: asConfirmStats_.rows_read,
account_snapshot_confirmed_rows: asConfirmStats_.confirmed_rows,
account_snapshot_parse_ok_unconfirmed: asConfirmStats_.parse_ok_unconfirmed,
},
sell_priority_checksum: computeStringChecksum_(JSON.stringify(rows_.map(function(r) {
return {
rank: r.rank,
ticker: r.ticker,
tier: r.tier,
sell_priority_score: r.sell_priority_score,
final_action: r.final_action,
sell_action: r.sell_action
};
}))),
sell_priority_table: rows_,
candidates: rows_, // backward-compat alias
};
}
function getAccountSnapshotConfirmStats_() {
var out = { rows_read: 0, confirmed_rows: 0, parse_ok_unconfirmed: 0 };
try {
var ss = getSpreadsheet_();
var sh = ss.getSheetByName("account_snapshot");
if (!sh) return out;
var data = sh.getDataRange().getValues();
if (!data || data.length < 3) return out;
var hdr = data[1].map(function(h) { return String(h || "").trim(); });
var statusIdx = hdr.indexOf("parse_status");
var confIdx = hdr.indexOf("user_confirmed");
if (statusIdx < 0) return out;
for (var i = 2; i < data.length; i++) {
var parseStatus = String(data[i][statusIdx] || "").trim();
var confirmed = confIdx >= 0 ? String(data[i][confIdx] || "").trim().toUpperCase() : "";
if (!parseStatus && !confirmed) continue;
out.rows_read++;
var isParseOk = parseStatus === "CAPTURE_READ_OK";
var hasConfirm = confirmed === "Y" || confirmed === "YES" || confirmed === "TRUE" || confirmed === "1";
if (isParseOk && hasConfirm) out.confirmed_rows++;
if (isParseOk && !hasConfirm) out.parse_ok_unconfirmed++;
}
} catch (e) {}
return out;
}
// ============================================================================
// INTEGRATED HARNESS
// ============================================================================
/**
* [HARNESS] gas_data_feed.gs 통합 하네스 — H3 확장판
*
* H1: 포트폴리오 가드 (intraday_lock, cash_floor, total_heat)
* H2: 매도후보 순위 (Sell_Priority_Score 0~100 clamp, tier 배정)
* H3: 수량 사전산출 (Sell_Qty, POSITION_SIZE_V1 입력값)
* H4: 가격 사전산출 (STOP_PRICE_CORE_V1 + TICK_NORMALIZER_V1)
* H5: 결정 상태머신 게이팅 (Final_Action per ticker + gate_trace)
* H6: Blueprint 무결성 해시 (row_count + CRC32_V1 checksum, LLM 위변조 탐지)
*
* 호출: runEventRisk() 완료 후 runHarnessRefresh_() → buildHarnessContext_()
* 출력: harness_context 시트 (key-value)
* → Python converter → blueprint_checksum 검증 → JSON data._harness_context
* → LLM: HARNESS_AUTHORITATIVE_V3 지침 (Zero-Adjective + Structured Output)
*
* 버전: 2026-05-18-H3
*/
// ── 상수 ─────────────────────────────────────────────────────────────────────
var HARNESS_VERSION = '2026-05-22-3RD_HARNESS_V1';
var HARNESS_SHEET_NAME = 'harness_context';
var AS_SHEET_NAME = 'account_snapshot';
var SETTINGS_SHEET_NAME = 'settings';
var DATA_FEED_SHEET_NAME = 'data_feed';
// 헤더 행 위치 (0-indexed)
var AS_HEADER_ROW_IDX = 1;
var DF_HEADER_ROW_IDX = 1;
// 코어 주도주 — tier=9 (마지막 매도 순위) 고정
// spec/risk/portfolio_exposure.yaml:regime_leading_sector_protection
var CORE_TICKERS = ['005930', '000660']; // 삼성전자, SK하이닉스
// P4: 장중 차단 키워드 (spec/00_execution_contract.yaml:P4.keyword_lock)
var INTRADAY_BLOCKED_KEYWORDS = ['EXIT_100', 'SELL_FULL', 'EXIT_FULL', 'BUY', 'STAGED_BUY'];
var INTRADAY_CUTOFF_MINUTES = 15 * 60 + 30; // 15:30 KST
// P4: 장중 허용 액션 목록 — 이 목록 외 모든 매도/매수 액션은 장중 금지
// (차단목록 기반 다운그레이드를 통과한 후 최종 허용 여부를 이중 검증)
var INTRADAY_ALLOWED_ACTIONS = [
'HOLD', 'WATCH', 'TRIM_25', 'TRIM_33', 'TRIM_50',
'OBSERVE_DATA_MISSING', 'INSUFFICIENT_DATA', 'NO_BUY_OVERHEATED'
];
// Heat 게이트 (spec/13_formula_registry.yaml:TOTAL_HEAT_V1.gates)
// L3: 국면별 동적 임계값으로 대체 — calcDynamicHeatThresholds_() 참조
var HEAT_HARD_BLOCK_PCT = 10.0; // fallback (regime unknown)
var HEAT_HALVE_PCT = 7.0; // fallback (regime unknown)
/**
* L3: DYNAMIC_HEAT_GATE_V1
* 국면에 따라 Heat Gate 임계값을 동적으로 반환한다.
* spec/13b_harness_formulas.yaml:DYNAMIC_HEAT_GATE_V1
* @param {string} regime — marketRegime string
* @return {{hardBlock: number, halve: number}}
*/
var calcHeatThresholdsByRegime_ = function(regime) {
var r = String(regime || '').toUpperCase();
if (r.indexOf('EVENT_SHOCK') >= 0) return { hardBlock: 5.0, halve: 3.5 };
if (r.indexOf('RISK_OFF') >= 0) return { hardBlock: 7.0, halve: 5.0 };
if (r.indexOf('SECULAR_LEADER') >= 0 && r.indexOf('RISK_ON') >= 0) return { hardBlock: 13.0, halve: 9.0 };
if (r.indexOf('RISK_ON') >= 0) return { hardBlock: 12.0, halve: 8.5 };
// NEUTRAL or unknown
return { hardBlock: 10.0, halve: 7.0 };
}
// cash_floor MRS 구간 (spec/risk/portfolio_exposure.yaml:cash_floor.regime_numbers)
var CASH_FLOOR_BY_MRS = [
{ maxMrs: 3, minPct: 7, label: 'normal' },
{ maxMrs: 7, minPct: 10, label: 'overheated_or_event_week' },
{ maxMrs: 10, minPct: 15, label: 'risk_off' }
];
// KRX 호가단위 테이블 (spec/13_formula_registry.yaml:TICK_NORMALIZER_V1)
var TICK_TABLE = [
{ maxPrice: 2000, tick: 1 },
{ maxPrice: 5000, tick: 5 },
{ maxPrice: 20000, tick: 10 },
{ maxPrice: 50000, tick: 50 },
{ maxPrice: 200000, tick: 100 },
{ maxPrice: 500000, tick: 500 }
// >= 500000: tick = 1000
];
// Sell_Priority_Score 컴포넌트 가중치
// spec/risk/portfolio_exposure.yaml:candidate_scoring.components
var SP = {
HARD_STOP_BREACH: 50,
CASH_FLOOR_TRIM: 40,
DUPLICATE_ETF: 30,
HOLDING_TRIM_ROTATE: 20,
TAKE_PROFIT_BASE: 10,
DUP_SAME_SECTOR: 20,
CASH_RELIEF_GE3: 15,
CASH_RELIEF_1_3: 10,
CASH_RELIEF_LT1: 3,
RW_GE4: 20,
RW_3: 15,
RW_2: 8,
FLOW_NEGATIVE: 8,
BELOW_MA20: 8,
OVERWEIGHT_5P: 12,
OVERWEIGHT_2P: 6,
LIQUIDITY_OK: 5,
LIQUIDITY_LOW: -10,
TAX_UNKNOWN: 3,
CORE_LEADER: 20,
A_GRADE_CORE: 12
};
// POSITION_SIZE_V1 기본 위험예산 (spec/13_formula_registry.yaml:RISK_BUDGET_CASCADE_V1)
var BASE_RISK_BUDGET = 0.007; // 총자산의 0.7%
// ── 메인 함수 ────────────────────────────────────────────────────────────────
/**
* buildHarnessContext_
* GAS 확정값을 harness_context 시트에 기록한다.
* runEventRisk() 완료 후 runHarnessRefresh_()가 호출한다.
*/
function buildHarnessContext_() {
var ss = getSpreadsheet_();
var now = new Date();
logHarnessSub_('[HARNESS_LAYER] L0: prepareHarnessContextInputs_');
var harnessInputs = prepareHarnessContextInputs_(ss) || {};
var settings = harnessInputs.settings;
var performance = harnessInputs.performance;
var totalAsset = harnessInputs.totalAsset;
var mrsScore = harnessInputs.mrsScore;
var dfMap = harnessInputs.dfMap;
var asResult = harnessInputs.asResult || {};
asResult.holdings = Array.isArray(asResult.holdings) ? asResult.holdings : [];
var kospiRet5d = harnessInputs.kospiRet5d;
var kospiRet20d = harnessInputs.kospiRet20d || 0;
var sectorFlowRadar = harnessInputs.sectorFlowRadar;
var harnessState = harnessInputs.harnessState || {};
var intradayLock = harnessState.intradayLock;
var capturedAtIso = harnessState.capturedAtIso;
var snapshotFreshness = harnessState.snapshotFreshness;
var snapshotGate = harnessState.snapshotGate;
var cashFloorInfo = harnessState.cashFloorInfo;
var cashShortfallInfo = harnessState.cashShortfallInfo;
var drawdownGuard = harnessState.drawdownGuard;
var winLossStreakGuard = harnessState.winLossStreakGuard;
var marketRegime = harnessState.marketRegime;
var regimeTrimGuidance = harnessState.regimeTrimGuidance;
var regimeTransitionAlert = harnessState.regimeTransitionAlert;
var regimeSizeScale = harnessState.regimeSizeScale;
var regimeCashMinPct = harnessState.regimeCashMinPct;
var heatThresholds = harnessState.heatThresholds;
var heatGate = harnessState.heatGate;
var actions = harnessState.actions;
var h1 = harnessState.h1;
h1.kospiRet20d = kospiRet20d;
var settlementCashPct = harnessState.settlementCashPct;
var totalHeatPct = harnessState.totalHeatPct;
var buyPowerKrw = harnessState.buyPowerKrw;
try {
if (cashFloorInfo && cashFloorInfo.status === 'HARD_BLOCK') {
writeSettingValue_(ss, 'cash_floor_hard_block_warning',
'[CASH_FLOOR_HARD_BLOCK] 신규 매수 차단 상태 — 현금 회복(TRIM) 우선');
} else {
writeSettingValue_(ss, 'cash_floor_hard_block_warning', '');
}
} catch (e) {
Logger.log('[WARN] cash_floor_hard_block_warning 기록 실패: ' + e.message);
}
logHarnessSub_('[HARNESS_LAYER] L1: assembleHarnessCoreLayers_ holdings=' + (asResult.holdings || []).length);
var coreLayers = assembleHarnessCoreLayers_(
ss, now, settings, asResult, dfMap, performance, totalAsset, mrsScore, buyPowerKrw,
settlementCashPct, totalHeatPct, intradayLock, snapshotFreshness, snapshotGate,
cashFloorInfo, cashShortfallInfo, capturedAtIso, drawdownGuard, winLossStreakGuard, marketRegime,
regimeTrimGuidance, regimeTransitionAlert, regimeSizeScale, regimeCashMinPct,
heatThresholds, heatGate, actions, h1, kospiRet5d, sectorFlowRadar
);
coreLayers = coreLayers || {};
var h2 = coreLayers.h2 || {};
var h3 = coreLayers.h3 || {};
var h4 = coreLayers.h4 || {};
var h5 = coreLayers.h5 || {};
var orderBlueprint = Array.isArray(coreLayers.orderBlueprint) ? coreLayers.orderBlueprint : [];
logHarnessSub_('[HARNESS_LAYER] L1 done: h2.candidates=' + ((h2 && h2.candidates) ? h2.candidates.length : 0)
+ ' h3.sellQty=' + ((h3 && h3.sellQty) ? h3.sellQty.length : 0)
+ ' h4.prices=' + ((h4 && h4.prices) ? h4.prices.length : 0)
+ ' h5.decisions=' + ((h5 && h5["decisions"]) ? h5["decisions"].length : 0)
+ ' blueprint=' + (orderBlueprint ? orderBlueprint.length : 0));
logHarnessSub_('[HARNESS_LAYER] L2: assembleHarnessRiskLayers_');
var riskLayers = assembleHarnessRiskLayers_(
ss, settings, asResult, dfMap, totalAsset, marketRegime, kospiRet5d, sectorFlowRadar, h4
);
riskLayers = riskLayers || {};
var portfolioBetaGate = riskLayers.portfolioBetaGate;
var eventRiskRows = riskLayers.eventRiskRows;
var sectorConcentration = riskLayers.sectorConcentration;
var tpLadderRows = riskLayers.tpLadderRows;
var stopAdequacyRows = riskLayers.stopAdequacyRows;
var staleRows = riskLayers.staleRows;
var singlePositionWeightCap = riskLayers.singlePositionWeightCap;
var semiconductorClusterGate = riskLayers.semiconductorClusterGate;
var portfolioDrawdownGate = riskLayers.portfolioDrawdownGate;
var positionCountLimit = riskLayers.positionCountLimit;
var stopBreachAlert = riskLayers.stopBreachAlert;
var relativeStopSignal = riskLayers.relativeStopSignal;
var tpTriggerAlert = riskLayers.tpTriggerAlert;
var heatConcentrationAlert = riskLayers.heatConcentrationAlert;
var sectorMomentumRows = riskLayers.sectorMomentumRows;
logHarnessSub_('[HARNESS_LAYER] L2 done');
logHarnessSub_('[HARNESS_LAYER] L3: assembleHarnessAlphaLayers_');
var alphaLayers = assembleHarnessAlphaLayers_(
ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar, h2, h3, h4, h5,
orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, drawdownGuard, snapshotGate,
cashFloorInfo, portfolioBetaGate, sectorConcentration, portfolioDrawdownGate,
winLossStreakGuard, positionCountLimit, singlePositionWeightCap, semiconductorClusterGate,
stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert,
heatGate
);
alphaLayers = alphaLayers || {};
var hAlpha = alphaLayers.hAlpha;
var hApex = alphaLayers.hApex;
var backdataRows = alphaLayers.backdataRows;
var dfgResult = alphaLayers.dfgResult;
var claExitJson = alphaLayers.claExitJson;
var slgRows = alphaLayers.slgRows;
var pcgResult = alphaLayers.pcgResult;
var portfolioHealthScore = alphaLayers.portfolioHealthScore;
hAlpha = hAlpha || {};
hApex = hApex || {};
// [PROPOSAL51-FIX] P2-B: portfolio_health_score 숫자형 보장 (Export Gate CHECK_7 연동)
// portfolioHealthScore 객체를 hApex에 숫자 필드로 주입 (기존엔 hApex에 미등록 → Boolean/undefined)
if (portfolioHealthScore) {
var phsVal = portfolioHealthScore.score;
hApex.portfolio_health_score = (typeof phsVal === 'number' && !isNaN(phsVal)) ? phsVal : 50;
hApex.portfolio_health_label = portfolioHealthScore.label || 'CAUTION';
hApex.portfolio_health_json = portfolioHealthScore;
}
if (relativeStopSignal) hApex.relative_stop_signal = relativeStopSignal;
logHarnessSub_('[HARNESS_LAYER] L3 done');
logHarnessSub_('[HARNESS_LAYER] L4: finalizeHarnessContextRows_');
finalizeHarnessContextRows_(
ss, now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo,
heatGate, heatThresholds, mrsScore, asResult, dfMap, settlementCashPct, totalHeatPct,
buyPowerKrw, totalAsset, actions, performance, h2, h3, h4, h5, orderBlueprint, hAlpha,
regimeTrimGuidance, cashShortfallInfo, hApex, sectorMomentumRows, drawdownGuard,
portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows, regimeSizeScale,
regimeCashMinPct, stopAdequacyRows, staleRows, singlePositionWeightCap,
semiconductorClusterGate, portfolioDrawdownGate, winLossStreakGuard, positionCountLimit,
stopBreachAlert, tpTriggerAlert, heatConcentrationAlert, regimeTransitionAlert,
portfolioHealthScore
);
logHarnessSub_('[HARNESS_LAYER] L4 done');
var sellCandidatesCount = ((h2 && h2.candidates) ? h2.candidates.length : 0);
var routeCount = ((h5 && h5["decisions"]) ? h5["decisions"].length : 0);
Logger.log('[HARNESS H2] 완료'
+ ' | intraday=' + intradayLock
+ ' | cash=' + settlementCashPct + '%'
+ ' | heat=' + totalHeatPct + '%'
+ ' | cashFloor=' + cashFloorInfo.status
+ ' | heatGate=' + heatGate
+ ' | perf=' + performance.bayesian_label + '×' + performance.bayesian_multiplier
+ ' | sellCandidates=' + sellCandidatesCount
+ ' | decisions=' + routeCount
+ ' | alphaShield_critical=' + (hAlpha.critical_alert_count || 0)
+ ' | apex_buy_blocks=' + (hApex.apex_block_count || 0));
if (routeCount > 0 && sellCandidatesCount === 0) {
Logger.log('[LOG_METRIC_MISMATCH_WARN] decisions>0 이지만 sellCandidates=0 (정책/입력 상태 점검 필요)');
}
}