Files
QuantEngineByItz/gas_data_feed.gs
T
kjh2064 6d4ee39e04 WBS-7.3 F12/F13: distribution_risk 두 공식 역할 분리 확정(KEEP_BOTH)
GAS calcDistributionRiskRow_의 "THIN_ADAPTER: delegated to Python" 주석이
틀린 주석이었음을 발견 — GAS(DISTRIBUTION_RISK_SCORE_V1, 점수식 BUY 차단
게이트)와 Python calc_distribution_detector_per_ticker(DISTRIBUTION_SELL_DETECTOR_V1,
6신호 카운트, PRE_DISTRIBUTION_EARLY_WARNING 정밀도 보완)는 이미 spec에
서로 다른 고유 formula_id로 등록된 독립 공식이었다. "GAS가 Python의 중복"
이라는 ledger 전제가 거짓이었을 뿐, 코드는 원래부터 올바르게 분리돼 있었다.

사용자 결정(둘 다 유지, 역할 분리)에 따라:
- GAS 소스의 잘못된 주석 정정(gdf_03_portfolio_gates.gs) + 번들 재생성
- 양쪽 formula_registry에 상호 related_formula 참조 추가(향후 혼동 방지)
- governance/gas_logic_migration_ledger_v1.yaml: migration_action을
  DELETE_DISTRIBUTION_RISK_GAS → KEEP_BOTH_SEPARATE_ROLES로 변경, DONE
2026-06-22 02:29:50 +09:00

11144 lines
490 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =========================================================================
// GENERATED BUNDLE - DO NOT EDIT THIS FILE MANUALLY
// Generated At: 2026-06-22 02:21:03 KST
// Source Files: src/gas_adapter_parts/gdf_01_price_metrics.gs, src/gas_adapter_parts/gdf_02_harness_assembly.gs, src/gas_adapter_parts/gdf_03_portfolio_gates.gs, src/gas_adapter_parts/gdf_04_execution_quality.gs, src/gas_adapter_parts/gdf_05_alpha_engines.gs, src/gas_adapter_parts/gdf_06_rebalance.gs
// Source Hash: c050e37c26b87f72eb5b325726163b0cd8570e3823bf058f5464d37cc8200e31
// =========================================================================
// --- Source: src/gas_adapter_parts/gdf_01_price_metrics.gs ---
/**
* 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: 분리된 섹터×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: "463250", name: "TIGER K방산&우주" },
{ code: "064350", name: "현대로템" },
{ code: "012450", name: "한화에어로스페이스" },
{ code: "117700", name: "KODEX 건설" },
{ code: "028050", name: "삼성E&A" },
{ code: "454320", name: "HANARO CAPEX설비투자iSelect" },
{ code: "010120", name: "LS ELECTRIC" },
{ code: "0117V0", name: "TIGER AI전력기기" },
{ code: "491820", name: "HANARO 전력설비투자" },
{ code: "494670", name: "TIGER 조선TOP10" },
{ code: "471990", name: "KODEX AI반도체핵심장비" },
{ code: "434730", name: "HANARO 원자력iSelect" },
{ code: "0111J0", name: "HANARO 증권고배당TOP3플러스" },
{ code: "307520", name: "TIGER 지주회사" },
{ code: "0190C0", name: "RISE 현대차고정피지컬AI" },
{ code: "011070", name: "LG이노텍" },
{ code: "010620", name: "현대미포" },
{ code: "121600", name: "나노신소재" },
];
// 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": "건설","006360": "건설",
"005380": "자동차", "000270": "자동차", "012330": "자동차",
"105560": "은행","055550": "은행","086790": "은행","316140": "은행","024110": "은행",
"071050": "증권","006800": "증권","005940": "증권","016360": "증권","039490": "증권",
"180640": "지주회사","267250": "지주회사","034730": "지주회사","000150": "지주회사","005490": "지주회사",
"003550": "지주회사","006260": "지주회사","078930": "지주회사","001040": "지주회사","010060": "지주회사",
"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": "은행",
"0111J0": "증권", "307520": "지주회사",
"305720": "2차전지","139220": "소비재",
"463250": "방산", "434730": "원전", "454320": "플랜트/EPC",
"491820": "전력설비", "117700": "건설", "0190C0": "로보틱스",
"011070": "로보틱스", "010620": "로보틱스", "121600": "로보틱스",
};
// 섹터 → 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_1",
"자동차": "Tier_2",
"2차전지": "Tier_2",
"바이오": "Tier_2",
"원전": "Tier_2",
"건설": "Tier_3",
"플랜트/EPC": "Tier_3",
"로보틱스": "Tier_2",
"은행":"Tier_3",
"증권":"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) {
// THIN_ADAPTER: [sizing/normalize] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_position_size
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 (정책/입력 상태 점검 필요)');
}
}
// FORMULA_STUB: MARKET_RISK_SCORE_V1 — 시장리스크 점수 (calcMarketRiskScore_) GAS 미구현, Python pipeline 산출
// FORMULA_STUB: PORTFOLIO_BETA_V1 — 포트폴리오 베타 (calcPortfolioBeta_) GAS 미구현, Python pipeline 산출
// --- Source: src/gas_adapter_parts/gdf_02_harness_assembly.gs ---
function shouldEmitHarnessVerboseLogs_() {
try {
var props = PropertiesService.getScriptProperties();
var profile = String(props.getProperty('HARNESS_LOG_PROFILE') || '').toUpperCase();
if (profile === 'DEBUG') return true;
if (profile === 'NORMAL') return false;
// Backward compatibility
var v = props.getProperty('HARNESS_VERBOSE_LOG');
return String(v || '').toLowerCase() === 'true';
} catch (e) {
return false;
}
}
function logHarnessSub_(msg) {
if (shouldEmitHarnessVerboseLogs_()) Logger.log(msg);
}
function setHarnessLogProfile_(profile) {
var p = String(profile || '').toUpperCase();
if (p !== 'NORMAL' && p !== 'DEBUG') {
throw new Error("setHarnessLogProfile_: profile must be 'NORMAL' or 'DEBUG'");
}
var props = PropertiesService.getScriptProperties();
props.setProperty('HARNESS_LOG_PROFILE', p);
if (p === 'DEBUG') props.setProperty('HARNESS_VERBOSE_LOG', 'true');
if (p === 'NORMAL') props.deleteProperty('HARNESS_VERBOSE_LOG');
Logger.log('[HARNESS_LOG_PROFILE] set to ' + p);
return { profile: p, formula_id: 'HARNESS_LOG_PROFILE_V1' };
}
function setHarnessLogProfileNormal_() {
return setHarnessLogProfile_('NORMAL');
}
function setHarnessLogProfileDebug_() {
return setHarnessLogProfile_('DEBUG');
}
function getHarnessLogProfile_() {
var profile = 'NORMAL';
var verboseFallback = false;
try {
var props = PropertiesService.getScriptProperties();
var p = String(props.getProperty('HARNESS_LOG_PROFILE') || '').toUpperCase();
if (p === 'NORMAL' || p === 'DEBUG') profile = p;
verboseFallback = String(props.getProperty('HARNESS_VERBOSE_LOG') || '').toLowerCase() === 'true';
} catch (e) {}
return {
profile: profile,
verbose_fallback: verboseFallback,
formula_id: 'HARNESS_LOG_PROFILE_V1'
};
}
function 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
) {
// THIN_ADAPTER: [sizing] delegated to Python — src/quant_engine/inject_computed_harness.py:main
var h2 = calcSellPriority_(asResult.holdings, dfMap, h1);
var h3 = calcQuantities_(asResult.holdings, dfMap, totalAsset, buyPowerKrw, h1);
var h4 = calcPrices_(asResult.holdings, dfMap, marketRegime);
var h5 = runRouteFlow_(asResult.holdings, dfMap, h1);
var orderBlueprint = buildOrderBlueprint_(asResult.holdings, dfMap, {
intradayLock: intradayLock,
heatGate: heatGate,
cashFloorStatus: cashFloorInfo.status,
blockedActions: actions.blocked
}, h3, h4, h5);
return {
h2: h2,
h3: h3,
h4: h4,
h5: h5,
orderBlueprint: orderBlueprint
};
}
function assembleHarnessRiskLayers_(
ss, settings, asResult, dfMap, totalAsset, marketRegime, kospiRet5d, sectorFlowRadar, h4
) {
var portfolioBetaGate = calcPortfolioBetaGate_(asResult.holdings, dfMap, kospiRet5d, marketRegime);
var eventRiskRows = calcEventRiskHoldGate_(asResult.holdings, dfMap);
var sectorConcentration = calcSectorConcentrationGate_(asResult.holdings, marketRegime);
var tpLadderRows = calcTpQuantityLadder_(asResult.holdings, h4);
var stopAdequacyRows = calcStopAdequacyRows_(asResult.holdings, dfMap);
var staleRows = calcHoldingStaleReview_(asResult.holdings);
// KOSPI 반도체 시총 비중 — settings 시트에서만 입력. 미설정 시 0 (DATA_MISSING 처리)
// 주의: 하드코딩 기본값 금지. 실제 비중은 KRX/FnGuide 시총 데이터에서 수동 입력.
var kospiSemiWt = toNumber_(settings['kospi_semi_weight_pct']) || 0;
var kospiSamsungWt = toNumber_(settings['kospi_samsung_weight_pct']) || 0;
var kospiHynixWt = toNumber_(settings['kospi_hynix_weight_pct']) || 0;
var singlePositionWeightCap = calcSinglePositionWeightCap_(asResult.holdings, marketRegime, kospiSamsungWt, kospiHynixWt);
var semiconductorClusterGate = calcSemiconductorClusterGate_(asResult.holdings, marketRegime, kospiSemiWt);
var portfolioDrawdownGate = calcPortfolioDrawdownGate_(totalAsset, ss, settings);
var positionCountLimit = calcPositionCountLimit_(asResult.holdings, marketRegime);
var stopBreachAlert = calcStopBreachAlert_(asResult.holdings, dfMap);
var kospiRet20d_ = readKospiRet20d_(ss);
var relativeStopSignal = calcRelativeStopSignal_(asResult.holdings, dfMap, kospiRet20d_);
var tpTriggerAlert = calcTpTriggerAlert_(asResult.holdings, dfMap, h4, tpLadderRows);
var heatConcentrationAlert = calcHeatConcentrationAlert_(asResult.holdings, asResult.totalHeatKrw);
var sectorMomentumRows = calcSectorRotationMomentum_(sectorFlowRadar);
return {
portfolioBetaGate: portfolioBetaGate,
eventRiskRows: eventRiskRows,
sectorConcentration: sectorConcentration,
tpLadderRows: tpLadderRows,
stopAdequacyRows: stopAdequacyRows,
staleRows: staleRows,
singlePositionWeightCap: singlePositionWeightCap,
semiconductorClusterGate: semiconductorClusterGate,
portfolioDrawdownGate: portfolioDrawdownGate,
positionCountLimit: positionCountLimit,
stopBreachAlert: stopBreachAlert,
relativeStopSignal: relativeStopSignal,
tpTriggerAlert: tpTriggerAlert,
heatConcentrationAlert: heatConcentrationAlert,
sectorMomentumRows: sectorMomentumRows
};
}
function 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
) {
logHarnessSub_('[HARNESS_SUB] L3-A: assembleHarnessAlphaRadar_');
var alphaLayer = assembleHarnessAlphaRadar_(asResult, dfMap, kospiRet5d, sectorFlowRadar);
logHarnessSub_('[HARNESS_SUB] L3-B: assembleHarnessApexLayer_');
var apexLayer = assembleHarnessApexLayer_(
ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar,
h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, cashFloorInfo
);
logHarnessSub_('[HARNESS_SUB] L3-C: syncBackdataFeatureBank_');
var hAlphaResult = alphaLayer.hAlpha;
var hApexResult = apexLayer.hApex;
var backdataRows = syncBackdataFeatureBank_(now, asResult.holdings, dfMap, hAlphaResult, hApexResult);
hApexResult.backdata_feature_bank_json = backdataRows;
hApexResult.backdata_learning_lock = true;
var dfgResult = apexLayer.dfgResult;
var claExitJson = apexLayer.claExitJson;
var slgRows = apexLayer.slgRows;
var pcgResult = apexLayer.pcgResult;
logHarnessSub_('[HARNESS_SUB] L3-D: calcHarnessPortfolioHealthScore_');
var portfolioHealthScore = calcHarnessPortfolioHealthScore_({
heat_gate: heatGate,
cash_floor_status: cashFloorInfo.status,
drawdown_guard_state: drawdownGuard.state,
snapshot_execution_gate: snapshotGate.status,
portfolio_beta_gate: (portfolioBetaGate || {}).gate_status,
sector_concentration: (sectorConcentration || {}).gate_status,
portfolio_drawdown_gate: (portfolioDrawdownGate || {}).gate,
win_loss_streak_state: winLossStreakGuard.state,
position_count_gate: (positionCountLimit || {}).gate_status,
single_position_weight: (singlePositionWeightCap || {}).gate_status,
semiconductor_cluster: (semiconductorClusterGate || {}).gate_status,
stop_breach_gate: (stopBreachAlert || {}).gate,
tp_trigger_gate: (tpTriggerAlert || {}).gate,
heat_concentration_gate: (heatConcentrationAlert || {}).gate,
regime_transition_type: (regimeTransitionAlert || {}).transition_type
});
// [PROPOSAL50] P1-C: M5 V1.1 — 반도체 클러스터 한도 2배 초과 시 4주 의무 감축 계획
var mandatoryReduction = calcMandatoryReductionPlan_(
semiconductorClusterGate, asResult.holdings, dfMap, h3, totalAsset
);
hApexResult.mandatory_reduction_json = mandatoryReduction;
if (mandatoryReduction.is_mandatory) {
Logger.log('[M5_V1.1] MANDATORY_REDUCTION: ' + mandatoryReduction.current_excess_pct
+ '%p 초과 → 주당 ' + mandatoryReduction.weekly_reduction_target_krw + '원 감축 필요');
}
return {
hAlpha: hAlphaResult,
hApex: hApexResult,
backdataRows: backdataRows,
dfgResult: dfgResult,
claExitJson: claExitJson,
slgRows: slgRows,
pcgResult: pcgResult,
portfolioHealthScore: portfolioHealthScore
};
}
function assembleHarnessAlphaRadar_(asResult, dfMap, kospiRet5d, sectorFlowRadar) {
var hAlpha = calcAlphaShield_(asResult.holdings, dfMap, kospiRet5d, sectorFlowRadar);
return { hAlpha: hAlpha };
}
function assembleHarnessApexLayer_(
ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar,
h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso, cashFloorInfo
) {
logHarnessSub_('[HARNESS_SUB] L3-B1: assembleHarnessApexCore_');
var apexCore = assembleHarnessApexCore_(
ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar,
h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso
);
logHarnessSub_('[HARNESS_SUB] L3-B2: applyApexProposal46Suite_');
var hApex = applyApexProposal46Suite_(
ss, asResult.holdings, dfMap, h2, h3, cashShortfallInfo, asResult, cashFloorInfo, capturedAtIso, now, apexCore.hApex
);
hApex = hApex || {};
orderBlueprint = Array.isArray(orderBlueprint) ? orderBlueprint : [];
logHarnessSub_('[HARNESS_SUB] L3-B3: buildRoutingTrace_');
// [PROPOSAL50] P0-2: ROUTING_TRACE_V1 — export_gate 완료 후 라우팅 trace 확정
var routingTrace = buildRoutingTrace_(
(h1 && h1.intradayLock) || false, cashFloorInfo, hApex, capturedAtIso
);
hApex.routing_trace_json = routingTrace;
hApex.routing_serving_trace_v2_json = buildRoutingServingTraceV2_(routingTrace, hApex);
logHarnessSub_('[HARNESS_SUB] L3-B4: buildWatchLedger_');
// [PROPOSAL50] P0-3: WATCH_LEDGER_V1 — validation_status != PASS 행 물리 분리
hApex.watch_ledger_json = buildWatchLedger_(orderBlueprint, h4);
logHarnessSub_('[HARNESS_SUB] L3-B5: buildShadowLedger_');
// [PROPOSAL50] H10: SHADOW_LEDGER_V1 — BLOCKED 블루프린트 투명 원장
hApex.shadow_ledger_json = buildShadowLedger_(orderBlueprint, dfMap);
logHarnessSub_('[HARNESS_SUB] L3-B6: calcTrimPlanMinCash_');
// [PROPOSAL50] G2: TRIM_PLAN_MIN_CASH_V1 — 최소 현금 TRIM 계획 결정론적 산출
// h2는 sell_priority 결과 객체; sell_priority_table 배열만 전달
hApex.trim_plan_to_min_cash_json = calcTrimPlanMinCash_(
asResult.holdings, dfMap, cashShortfallInfo,
(h2 && h2.sell_priority_table) ? h2.sell_priority_table : (Array.isArray(h2) ? h2 : [])
);
// [PROPOSAL51] P1-C: CRDL-V1 — 현금회복 3분리 표시 잠금 (trim_plan 확정 후)
hApex.cash_recovery_display_json = calcCashRecoveryDisplayLock_(
hApex.scrs_v2_json || {},
hApex.trim_plan_to_min_cash_json || [],
cashShortfallInfo || {}
);
logHarnessSub_('[HARNESS_SUB] L3-B7: calcLlmServingConstraint_');
// [PROPOSAL50] D2: LLM_SERVING_CONSTRAINT_V1 — 12가지 금지행동 체크 (보고서 조립 직전)
hApex.llm_serving_constraint_json = calcLlmServingConstraint_(hApex);
logHarnessSub_('[HARNESS_SUB] L3-B8: calcDeterministicServingLock_');
// [PROPOSAL50] P2-1: DSLE-V1 — 서빙 잠금 (파이프라인 최종 단계)
var servingLock = calcDeterministicServingLock_(hApex, capturedAtIso, now);
hApex.serving_lock_json = servingLock;
return {
hApex: hApex,
dfgResult: apexCore.dfgResult,
claExitJson: apexCore.claExitJson,
slgRows: apexCore.slgRows,
pcgResult: apexCore.pcgResult
};
}
function assembleHarnessApexCore_(
ss, now, asResult, dfMap, totalAsset, kospiRet5d, sectorFlowRadar,
h2, h3, h4, h5, orderBlueprint, cashShortfallInfo, marketRegime, h1, capturedAtIso
) {
logHarnessSub_('[HARNESS_SUB] L3-B1a: calcApexExecutionHarness_');
var hApex = calcApexExecutionHarness_(
asResult.holdings, dfMap, sectorFlowRadar, kospiRet5d,
h1, h2, h3, h4, orderBlueprint, cashShortfallInfo, marketRegime
);
logHarnessSub_('[HARNESS_SUB] L3-B1b: applyApexPostProcessing_');
var apexPost = applyApexPostProcessing_(
ss, now, capturedAtIso, asResult.holdings, dfMap, totalAsset, kospiRet5d, marketRegime, hApex
);
return {
hApex: apexPost.hApex,
dfgResult: apexPost.dfgResult,
claExitJson: apexPost.claExitJson,
slgRows: apexPost.slgRows,
pcgResult: apexPost.pcgResult
};
}
function prepareHarnessContextInputs_(ss) {
// 공통 데이터 읽기와 H1 사전 게이트를 분리해 buildHarnessContext_()를 얇게 유지한다.
var settings = readSettings_(ss);
var performance = readPerformanceSheet_();
var totalAsset = toNumber_(settings['total_asset_krw']);
var mrsScore = toNumber_(settings['mrs_score'] || settings['MRS'] || 5);
var dfMap = buildDataFeedMap_(ss);
var asResult = parseAccountSnapshot_(ss, totalAsset, dfMap);
var kospiRet5d = readKospiRet5d_(ss);
var kospiRet20d = readKospiRet20d_(ss);
var sectorFlowRadar = readSectorFlowForRadar_(ss);
if (totalAsset <= 0) totalAsset = asResult.derivedTotalAsset;
var harnessState = calcHarnessPortfolioGuardState_(
ss, asResult, settings, performance, totalAsset, mrsScore
);
return {
settings: settings,
performance: performance,
totalAsset: totalAsset,
mrsScore: mrsScore,
dfMap: dfMap,
asResult: asResult,
kospiRet5d: kospiRet5d,
kospiRet20d: kospiRet20d,
sectorFlowRadar: sectorFlowRadar,
harnessState: harnessState
};
}
function 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
) {
var rows = buildHarnessRows_(
now, capturedAtIso, intradayLock, snapshotFreshness, snapshotGate, cashFloorInfo, heatGate, heatThresholds, mrsScore,
asResult, dfMap, settlementCashPct, totalHeatPct, buyPowerKrw, totalAsset, actions,
performance, h2, h3, h4, h5, orderBlueprint, hAlpha, regimeTrimGuidance,
cashShortfallInfo, hApex, sectorMomentumRows,
drawdownGuard, portfolioBetaGate, eventRiskRows, sectorConcentration, tpLadderRows,
regimeSizeScale, regimeCashMinPct, stopAdequacyRows, staleRows,
singlePositionWeightCap, semiconductorClusterGate, portfolioDrawdownGate,
winLossStreakGuard, positionCountLimit,
stopBreachAlert, tpTriggerAlert, heatConcentrationAlert,
regimeTransitionAlert, portfolioHealthScore
);
assertHarnessRowsComplete_(rows);
writeHarnessSheet_(ss, rows, now);
return rows;
}
function calcHarnessPortfolioHealthScore_(gateMap) {
return calcPortfolioHealthScore_(gateMap);
}
function calcHarnessPortfolioGuardState_(ss, asResult, settings, performance, totalAsset, mrsScore) {
var settlementCashPct = totalAsset > 0
? round2_(asResult.settlementCashD2Krw / totalAsset * 100) : 0;
var totalHeatPct = totalAsset > 0
? round2_(asResult.totalHeatKrw / totalAsset * 100) : 0;
var buyPowerKrw = asResult.settlementCashD2Krw - asResult.openOrderAmountKrw;
var intradayLock = calcIntradayLock_(asResult.capturedAt);
var capturedAtIso = asResult.capturedAt ? formatIso_(asResult.capturedAt) : '';
var snapshotFreshness = checkAccountSnapshotFreshness_();
var snapshotGate = snapshotExecutionGate_(snapshotFreshness);
var cashFloorInfo = calcCashFloor_(mrsScore, settlementCashPct);
var cashShortfallInfo = calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore);
var drawdownGuard = calcDrawdownGuard_(performance);
var winLossStreakGuard = calcWinLossStreakGuard_(performance);
var marketRegime = readMacroRegime_(ss);
var regimeTrimGuidance = calcRegimeTrimGuidance_(marketRegime);
var regimeTransitionAlert = calcRegimeTransitionAlert_(marketRegime, ss, settings);
var regimeSizeScale = calcRegimeSizeScale_(marketRegime);
var regimeCashMinPct = calcRegimeCashUplift_(marketRegime, cashFloorInfo.minPct);
if (regimeCashMinPct > cashFloorInfo.minPct) {
cashFloorInfo.minPct = regimeCashMinPct;
cashFloorInfo.status = settlementCashPct >= regimeCashMinPct ? 'OK' : 'BELOW_FLOOR';
cashShortfallInfo = calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore);
}
var heatThresholds = calcHeatThresholdsByRegime_(marketRegime);
var heatGate = totalHeatPct >= heatThresholds.hardBlock ? 'BLOCK_NEW_BUY'
: totalHeatPct >= heatThresholds.halve ? 'HALVE_NEW_BUY_QUANTITY'
: 'ALLOW_CONTINUE';
var actions = calcActions_(intradayLock, heatGate, cashFloorInfo.status);
var h1 = {
intradayLock: intradayLock,
snapshotExecutionGate: snapshotGate.status,
snapshotExecutionReason: snapshotGate.reason,
accountSnapshotFreshness: snapshotFreshness,
heatGate: heatGate,
heatGateThresholdPct: heatThresholds.hardBlock,
drawdownBuyScale: drawdownGuard.buy_scale,
drawdownGuardState: drawdownGuard.state,
regimeSizeScale: regimeSizeScale.scale,
winLossStreakBuyScale: winLossStreakGuard.buy_scale,
winLossStreakState: winLossStreakGuard.state,
cashFloorStatus: cashFloorInfo.status,
cashFloorMinPct: cashFloorInfo.minPct,
totalAsset: totalAsset,
buyPowerKrw: buyPowerKrw,
performanceMultiplier: performance.bayesian_multiplier,
performanceLabel: performance.bayesian_label,
performanceBuyBias: calcPerformanceBuyBias_(performance),
};
return {
settlementCashPct: settlementCashPct,
totalHeatPct: totalHeatPct,
buyPowerKrw: buyPowerKrw,
intradayLock: intradayLock,
capturedAtIso: capturedAtIso,
snapshotFreshness: snapshotFreshness,
snapshotGate: snapshotGate,
cashFloorInfo: cashFloorInfo,
cashShortfallInfo: cashShortfallInfo,
drawdownGuard: drawdownGuard,
winLossStreakGuard: winLossStreakGuard,
marketRegime: marketRegime,
regimeTrimGuidance: regimeTrimGuidance,
regimeTransitionAlert: regimeTransitionAlert,
regimeSizeScale: regimeSizeScale,
regimeCashMinPct: regimeCashMinPct,
heatThresholds: heatThresholds,
heatGate: heatGate,
actions: actions,
h1: h1,
};
}
function applyApexPostProcessing_(ss, now, capturedAtIso, holdings, dfMap, totalAsset, kospiRet5d, marketRegime, hApex) {
// ── [2026-05-21_SPRINT_B] Sprint B 게이트 산출 ───────────────────────────────
logHarnessSub_('[HARNESS_SUB] L3-B1b-1: calcHarnessDataFreshnessGate_');
var dfgResult = calcHarnessDataFreshnessGate_(capturedAtIso, now);
logHarnessSub_('[HARNESS_SUB] L3-B1b-2: calcClaRegimeExitCondition_');
var claExitJson = calcClaRegimeExitCondition_(dfMap, marketRegime);
logHarnessSub_('[HARNESS_SUB] L3-B1b-3: calcSatelliteLifecycleGate_');
var slgRows = calcSatelliteLifecycleGate_(
holdings, dfMap, hApex.alpha_evaluation_window_json || []
);
logHarnessSub_('[HARNESS_SUB] L3-B1b-4: calcPortfolioCorrelationGate_');
var pcgResult = calcPortfolioCorrelationGate_(
holdings, dfMap, totalAsset, kospiRet5d
);
// Direction DFG: STALE_WARN/STALE_BLOCK → SAQG ELIGIBLE 하향
if (dfgResult.data_freshness_status === 'STALE_WARN'
|| dfgResult.data_freshness_status === 'STALE_BLOCK') {
(hApex.saqg_json || []).forEach(function(r) {
if (r.saqg_v1 === 'ELIGIBLE') {
r.saqg_v1 = 'WATCHLIST_ONLY';
r.hts_allowed = false;
r.saqg_downgraded_by = 'DFG_' + dfgResult.data_freshness_status;
}
});
}
hApex.data_freshness_json = dfgResult;
hApex.cla_regime_exit_json = claExitJson;
hApex.satellite_lifecycle_gate_json = slgRows;
hApex.portfolio_correlation_gate_json = pcgResult;
// [C-1] AFL: alpha history upsert (T+20/T+60 graduated holdings)
try {
appendAlphaHistory_(ss, hApex.alpha_evaluation_window_json || [], holdings, dfMap, marketRegime);
} catch(e) {
Logger.log("[AFL] appendAlphaHistory_ error: " + e.message);
}
try {
hApex.alpha_feedback_json = runAlphaFeedbackLoop_();
} catch(e) {
Logger.log("[AFL] runAlphaFeedbackLoop_ error: " + e.message);
hApex.alpha_feedback_json = getAlphaFeedbackJson_();
}
// Direction PCG: CORRELATION_BLOCK → 약한 위성 BUY 추가 차단
if (pcgResult.correlation_gate_status === 'CORRELATION_BLOCK') {
(hApex.buy_permission_json || []).forEach(function(bp) {
var h = holdings.find(function(x) { return x.ticker === bp.ticker; });
if (!h || h.position_type === 'core') return;
var df = dfMap[bp.ticker] || {};
var slg = slgRows.find(function(r) { return r.ticker === bp.ticker; });
var weakSignal = df.rs_verdict === 'LAGGARD' || df.brt_verdict === 'BROKEN'
|| (slg && (slg.lifecycle_stage === 'REVIEW' || slg.lifecycle_stage === 'EXIT'));
if (weakSignal && bp.buy_permission_state !== 'BLOCKED') {
bp.buy_permission_state = 'BLOCKED';
bp.blocked_reason_codes = (bp.blocked_reason_codes || [])
.concat(['CORRELATION_BLOCK_WEAK_SATELLITE']);
}
});
}
return {
hApex: hApex,
dfgResult: dfgResult,
claExitJson: claExitJson,
slgRows: slgRows,
pcgResult: pcgResult,
};
}
function applyApexProposal46Suite_(ss, holdings, dfMap, h2, h3, cashShortfallInfo, asResult, cashFloorInfo, capturedAtIso, now, hApex) {
logHarnessSub_('[HARNESS_SUB] L3-B2a: applyApexMacroAlphaSuite_');
hApex = applyApexMacroAlphaSuite_(holdings, dfMap, hApex);
logHarnessSub_('[HARNESS_SUB] L3-B2b: applyApexProtectionAndFeedbackSuite_');
hApex = applyApexProtectionAndFeedbackSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex);
logHarnessSub_('[HARNESS_SUB] L3-B2c: applyApexConsistencySuite_');
hApex = applyApexConsistencySuite_(hApex, asResult, dfMap, cashFloorInfo, capturedAtIso, now);
return hApex;
}
function applyApexMacroAlphaSuite_(holdings, dfMap, hApex) {
return applyApexMacroAlphaSuiteImpl_(holdings, dfMap, hApex);
}
function applyApexMacroEventSuite_(hApex) {
return applyApexMacroEventSuiteImpl_(hApex);
}
function applyApexPredictiveAlphaSuite_(holdings, dfMap, hApex) {
return applyApexPredictiveAlphaSuiteImpl_(holdings, dfMap, hApex);
}
function applyApexProtectionAndFeedbackSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex) {
logHarnessSub_('[HARNESS_SUB] L3-B2b-i: applyApexCashPreservationSuite_');
hApex = applyApexCashPreservationSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex);
logHarnessSub_('[HARNESS_SUB] L3-B2b-ii: applyApexFeedbackSignalSuite_');
hApex = applyApexFeedbackSignalSuite_(holdings, dfMap, hApex);
return hApex;
}
function applyApexCashPreservationSuite_(holdings, dfMap, h2, h3, cashShortfallInfo, hApex) {
// THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/inject_computed_harness.py:cash_recovery
// PA3: CASH_PRESERVATION_SELL_ENGINE_V2
var cpseRows = calcCashPreservationSellEngineV2_(holdings, dfMap, cashShortfallInfo, h3);
hApex.cash_preservation_sell_json = cpseRows;
// [PROPOSAL50] P1-2: SCRS-V2 — 주식가치 보호 + 반등 포착 통합 현금확보 엔진
var scrsResult = calcSmartCashRecoverySell_(holdings, dfMap, cashShortfallInfo, h2, hApex);
hApex.scrs_v2_json = scrsResult;
// [PROPOSAL51] P2-B: PROACTIVE_SELL_RADAR_V2 — 8신호 D-3일 사전 분배 감지
var ppMap = {};
((hApex.profit_preservation_json) || []).forEach(function(pp) {
var tk = (pp.ticker || pp.ticker_code || '').toString();
if (tk) ppMap[tk] = pp;
});
hApex.proactive_sell_radar_json = calcProactiveSellRadarV2_(holdings, dfMap, ppMap);
return hApex;
}
function applyApexFeedbackSignalSuite_(holdings, dfMap, hApex) {
// THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_final_decision
// anti_late_entry_json set first — watch_breakout uses ALE grade to filter grade-F chasers
logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-0: anti_late_entry_json');
hApex.anti_late_entry_json = calcAntiLateEntryGateV2_(holdings, dfMap);
logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-1: applyApexWatchBreakoutSuite_');
hApex = applyApexWatchBreakoutSuite_(holdings, dfMap, hApex);
logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-2: applyApexAntiWhipsawSuite_');
hApex = applyApexAntiWhipsawSuite_(holdings, dfMap, hApex);
logHarnessSub_('[HARNESS_SUB] L3-B2b-ii-3: applyApexAlphaHistorySuite_');
hApex = applyApexAlphaHistorySuite_(hApex);
return hApex;
}
function applyApexWatchBreakoutSuite_(holdings, dfMap, hApex) {
return applyApexWatchBreakoutSuiteImpl_(holdings, dfMap, hApex);
}
function applyApexAntiWhipsawSuite_(holdings, dfMap, hApex) {
// [PROPOSAL48_A3] ANTI_WHIPSAW_REENTRY_GATE_V1
var awrRows = calcAntiWhipsawReentryGateV1_(
hApex.sell_candidates_json || [], dfMap, holdings
);
hApex.anti_whipsaw_reentry_json = awrRows;
return hApex;
}
function applyApexAlphaHistorySuite_(hApex) {
// [PROPOSAL48_C7] alpha_history T20/T60 통계 집계 — T+5 피드백 루프 가시화
hApex.alpha_history_summary_json = getAlphaHistorySummary_();
return hApex;
}
function applyApexConsistencySuite_(hApex, asResult, dfMap, cashFloorInfo, capturedAtIso, now) {
// PA5: CONSISTENCY_VALIDATOR_V2
var cvResult = calcConsistencyValidatorV2_(hApex, asResult, cashFloorInfo, capturedAtIso, now);
hApex.consistency_report_json = cvResult;
hApex.consistency_score = cvResult.consistency_score;
hApex.cv_verdict = cvResult.cv_verdict;
// [PROPOSAL51] P0-B: SPSV2 — 매도 주문 3중 가격 검증 (Export Gate 전에 실행)
hApex.order_blueprint_json = calcSellPriceSanityV2_(
hApex.order_blueprint_json || [],
hApex.profit_preservation_json || []
);
// [PROPOSAL51] P2-D: SEQG-V1 — 매도 실행 품질 채점 (SPSV2 후, Export Gate 전)
hApex.sell_execution_quality_json = calcSellExecutionQualityGate_(
hApex.order_blueprint_json || [],
[], // holdings은 hApex에 직접 포함되지 않아 PSR 데이터만으로 채점
hApex.proactive_sell_radar_json || []
);
// [PROPOSAL51] P0-C: SEMICONDUCTOR_CLUSTER_SYNC_V1 — cluster gate ↔ mandatory_reduction 정합성
hApex.cluster_sync_result_json = syncSemiconductorCluster_(hApex);
// [PROPOSAL51] P0-D: PHL-V1 — 5계층 가격 단일화 잠금 (SPSV2 통과 후)
hApex.price_hierarchy_json = applyPriceHierarchyLockAll_(hApex);
// [PROPOSAL51] P1-B: DQG-V2 — 필드 충족률 데이터 완성도 게이트
hApex.data_quality_gate_v2_json = calcDataQualityGateV2_(hApex);
// [PROPOSAL53] P0-A: FUNDAMENTAL_QUALITY_GATE_V1
hApex.fundamental_quality_json = calcFundamentalQualityGateV1_(asResult.holdings || [], dfMap || {});
// [PROPOSAL53] P0-B: HORIZON_ALLOCATION_LOCK_V1
hApex.horizon_allocation_json = calcHorizonAllocationLockV1_(asResult.holdings || [], hApex);
// [PROPOSAL53] P0-C: SMART_MONEY_LIQUIDITY_GATE_V1
hApex.smart_money_liquidity_json = calcSmartMoneyLiquidityGateV1_(asResult.holdings || [], hApex);
// [PROPOSAL54] P0.5 확장 하네스
hApex.fundamental_multifactor_json = calcFundamentalMultiFactorScoreV2_(asResult.holdings || [], dfMap || {});
hApex.earnings_growth_quality_json = calcEarningsGrowthQualityGateV1_(asResult.holdings || [], dfMap || {});
hApex.market_share_proxy_json = calcMarketShareMomentumProxyV1_(asResult.holdings || [], dfMap || {}, hApex);
hApex.cashflow_stability_json = calcCashflowStabilityGateV1_(asResult.holdings || [], dfMap || {});
hApex.routing_explain_json = calcRoutingExplainLockV1_(hApex);
hApex.gs_formula_mirror_json = buildGsFormulaMirrorV1_();
// [PROPOSAL54 P0.6] 신규 5개 하네스 실거래 BUY 차단 연동
hApex.order_blueprint_json = applyProposal54BuyBlockLocks_(hApex.order_blueprint_json || [], hApex);
// [PROPOSAL51-FIX-ORDER] calcExportGate_ 호출 전 portfolio_health_score 숫자형 보장.
// buildHarnessContext_()의 FIX(line 2272)보다 이 함수가 먼저 실행되므로
// 여기서 재확인하지 않으면 CHECK_7이 항상 undefined를 본다.
if (typeof hApex.portfolio_health_score !== 'number' || isNaN(hApex.portfolio_health_score)) {
var _phsJson = hApex.portfolio_health_json;
var _phsRaw = _phsJson && _phsJson.score;
hApex.portfolio_health_score = (typeof _phsRaw === 'number' && !isNaN(_phsRaw)) ? _phsRaw : 0;
}
// [PROPOSAL50] P0-1: EXPORT_GATE_V1 — PENDING_EXPORT 원인 자동 진단
var egResult = calcExportGate_(hApex, asResult, cashFloorInfo);
hApex.export_gate_json = egResult;
hApex.json_validation_status = egResult.json_validation_status;
hApex.hts_entry_allowed = egResult.hts_entry_allowed;
return hApex;
}
/**
* GS Formula Mirror V1
* Python 보조 도구로 생성되는 공식들을 GAS 하네스 계층에서도 명시적으로 추적한다.
* 목적: YAML↔GS 커버리지의 소스오브트루스를 GAS 쪽에 고정.
*/
function buildGsFormulaMirrorV1_() {
var formulaIds = [
'BLANK_CELL_AUDIT_V1',
'CASHFLOW_QUALITY_SIGNAL_V1',
'EARNINGS_QUALITY_SIGNAL_V1',
'EJCE_VIEW_RENDERER_V1',
'FUNDAMENTAL_MULTIFACTOR_V3',
'FUNDAMENTAL_RAW_INGEST_V1',
'GROWTH_RATE_SIGNAL_V1',
'HORIZON_CLASSIFICATION_V1',
'LIQUIDITY_FLOW_SIGNAL_V1',
'MARKET_SHARE_SIGNAL_V2',
'PORTFOLIO_ALPHA_CONFIDENCE_PER_TICKER_V1',
'RATCHET_TRAILING_GENERAL_V1',
'ROUTING_EXECUTION_LOG_TABLE_V1',
'SMART_CASH_RECOVERY_V3',
'SMART_MONEY_FLOW_SIGNAL_V2',
'VALUE_PRESERVATION_SCORER_V1'
];
var rows = [];
for (var i = 0; i < formulaIds.length; i++) {
rows.push({
formula_id: formulaIds[i],
implementation_layer: 'GAS_MIRROR',
mirror_state: 'DECLARED',
formula_id_source: 'GS_FORMULA_MIRROR_V1'
});
}
return {
formula_id: 'GS_FORMULA_MIRROR_V1',
rows: rows
};
}
function applyProposal54BuyBlockLocks_(blueprint, hApex) {
// THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/inject_computed_harness.py:main
blueprint = Array.isArray(blueprint) ? blueprint : [];
function toMap_(obj, key, condFn) {
var m = {};
var rows = (obj && obj.rows) || [];
if (!Array.isArray(rows)) return m;
rows.forEach(function(r) {
var tk = String((r || {})[key] || '');
if (!tk) return;
m[tk] = condFn(r || {});
});
return m;
}
var fm = (hApex && hApex.fundamental_multifactor_json) || {};
var egq = (hApex && hApex.earnings_growth_quality_json) || {};
var msp = (hApex && hApex.market_share_proxy_json) || {};
var cfs = (hApex && hApex.cashflow_stability_json) || {};
var fmMap = toMap_(fm, 'ticker', function(r){ return Number(r.score_0_100 || 0) < 60; });
var egqMap = toMap_(egq, 'ticker', function(r){ return String(r.gate || '') === 'BLOCK_BUY'; });
var mspMap = toMap_(msp, 'ticker', function(r){ return String(r.proxy_state || '') === 'LOSING'; });
var cfsMap = toMap_(cfs, 'ticker', function(r){ return String(r.gate || '') === 'BLOCK_BUY'; });
return blueprint.map(function(row) {
var r = Object.assign({}, row);
var orderType = String(r.order_type || '').toUpperCase();
var isBuy = orderType === 'BUY' || orderType === 'ADD_ON' || orderType === 'STAGED_BUY';
if (!isBuy) return r;
var tk = String(r.ticker || '');
var blocks = [];
// 충돌 우선순위: Cashflow/Fundamental 계열 > Share/Earnings
if (cfsMap[tk]) blocks.push('CASHFLOW_STABILITY_GATE_V1');
if (fmMap[tk]) blocks.push('FUNDAMENTAL_MULTI_FACTOR_SCORE_V2');
if (mspMap[tk]) blocks.push('MARKET_SHARE_MOMENTUM_PROXY_V1');
if (egqMap[tk]) blocks.push('EARNINGS_GROWTH_QUALITY_GATE_V1');
if (blocks.length > 0) {
r.validation_status = 'BLOCKED';
r.blocked_by_gate = blocks.join('|');
r.rationale_code = (r.rationale_code ? String(r.rationale_code) + '|' : '') + 'P054_BUY_BLOCK:' + r.blocked_by_gate;
if (typeof r.quantity === 'number' && r.quantity > 0) r.quantity = 0;
}
return r;
});
}
function calcFundamentalMultiFactorScoreV2_(holdings, dfMap) {
holdings = Array.isArray(holdings) ? holdings : [];
dfMap = dfMap || {};
var rows = holdings.map(function(h) {
var tk = String(h.ticker || '');
var df = dfMap[tk] || {};
var m = {
roe: toNumber_(df.roe_pct),
opm: toNumber_(df.opm_pct),
rev: toNumber_(df.revenue_growth_pct),
opg: toNumber_(df.op_income_growth_pct),
share: toNumber_(df.market_share_proxy_pct),
ocf: toNumber_(df.operating_cf_krw),
fcf: toNumber_(df.free_cf_krw),
debt: toNumber_(df.debt_ratio_pct)
};
var score = 0;
var fail = [];
if (m.roe !== null && m.roe >= 8) score += 15; else fail.push('ROE');
if (m.opm !== null && m.opm >= 8) score += 15; else fail.push('OPM');
if (m.rev !== null && m.rev >= 0) score += 15; else fail.push('REV_GROWTH');
if (m.opg !== null && m.opg >= 0) score += 15; else fail.push('OP_GROWTH');
if (m.share !== null && m.share >= 0) score += 10; else fail.push('SHARE_PROXY');
if (m.ocf !== null && m.ocf > 0) score += 15; else fail.push('OCF');
if (m.fcf !== null && m.fcf > 0) score += 10; else fail.push('FCF');
if (m.debt !== null && m.debt <= 200) score += 5; else fail.push('DEBT');
var grade = score >= 80 ? 'A' : score >= 65 ? 'B' : score >= 50 ? 'C' : 'D';
return {
ticker: tk,
name: h.name || '',
score_0_100: score,
grade: grade,
buy_allowed: score >= 60 && fail.length <= 4,
fail_reasons: fail
};
});
return { formula_id: 'FUNDAMENTAL_MULTI_FACTOR_SCORE_V2', rows: rows };
}
function calcEarningsGrowthQualityGateV1_(holdings, dfMap) {
holdings = Array.isArray(holdings) ? holdings : [];
dfMap = dfMap || {};
var rows = holdings.map(function(h) {
var tk = String(h.ticker || '');
var df = dfMap[tk] || {};
var q1 = toNumber_(df.eps_growth_qoq_pct);
var y1 = toNumber_(df.eps_growth_yoy_pct);
var trend = (q1 !== null && y1 !== null && q1 >= 0 && y1 >= 0) ? 'ACCELERATING' :
(q1 !== null && y1 !== null && q1 < 0 && y1 < 0) ? 'DECELERATING' : 'MIXED';
var gate = trend === 'DECELERATING' ? 'BLOCK_BUY' : 'PASS_OR_WATCH';
return { ticker: tk, name: h.name || '', trend: trend, consistency: (trend === 'MIXED' ? 'LOW' : 'HIGH'), gate: gate };
});
return { formula_id: 'EARNINGS_GROWTH_QUALITY_GATE_V1', rows: rows };
}
function calcMarketShareMomentumProxyV1_(holdings, dfMap, hApex) {
holdings = Array.isArray(holdings) ? holdings : [];
dfMap = dfMap || {};
var alphaMap = {};
((hApex && hApex.alpha_lead_json) || []).forEach(function(r){ alphaMap[String(r.ticker || '')] = r; });
var rows = holdings.map(function(h) {
var tk = String(h.ticker || '');
var df = dfMap[tk] || {};
var alpha = alphaMap[tk] || {};
var rev = toNumber_(df.revenue_growth_pct);
var rs = toNumber_(alpha.alpha_lead_score);
var state = (rev !== null && rev < 0) || (rs !== null && rs < 50) ? 'LOSING' :
(rev !== null && rev > 5 && rs !== null && rs >= 70) ? 'GAINING' : 'NEUTRAL';
return { ticker: tk, name: h.name || '', proxy_state: state, confidence_band: state === 'NEUTRAL' ? 'MEDIUM' : 'HIGH' };
});
return { formula_id: 'MARKET_SHARE_MOMENTUM_PROXY_V1', rows: rows };
}
function calcCashflowStabilityGateV1_(holdings, dfMap) {
holdings = Array.isArray(holdings) ? holdings : [];
dfMap = dfMap || {};
var rows = holdings.map(function(h) {
var tk = String(h.ticker || '');
var df = dfMap[tk] || {};
var ocf = toNumber_(df.operating_cf_krw);
var fcf = toNumber_(df.free_cf_krw);
var accrual = toNumber_(df.accrual_ratio_pct);
var unstable = (ocf !== null && ocf <= 0) || (fcf !== null && fcf <= 0);
var accrRisk = (accrual !== null && accrual > 10);
return {
ticker: tk,
name: h.name || '',
stability_state: unstable ? 'UNSTABLE' : 'STABLE',
accrual_risk_flag: !!accrRisk,
gate: (unstable && accrRisk) ? 'BLOCK_BUY' : 'PASS_OR_WATCH'
};
});
return { formula_id: 'CASHFLOW_STABILITY_GATE_V1', rows: rows };
}
function calcRoutingExplainLockV1_(hApex) {
var eg = (hApex && hApex.export_gate_json) || {};
return {
formula_id: 'ROUTING_DECISION_EXPLAIN_LOCK_V1',
gate_path: ['FUNDAMENTAL_MULTI_FACTOR_SCORE_V2','EARNINGS_GROWTH_QUALITY_GATE_V1','MARKET_SHARE_MOMENTUM_PROXY_V1','CASHFLOW_STABILITY_GATE_V1','EXPORT_GATE_V2'],
blocked_by: eg.hts_entry_allowed ? null : String(eg.json_validation_status || 'REVIEW_ONLY'),
override_allowed: false
};
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL53] 신규 P0 하네스 4종
// ═══════════════════════════════════════════════════════════════════════
function calcFundamentalQualityGateV1_(holdings, dfMap) {
holdings = Array.isArray(holdings) ? holdings : [];
dfMap = dfMap || {};
var rows = holdings.map(function(h) {
var tk = String(h.ticker || '');
var df = dfMap[tk] || {};
var roe = toNumber_(df.roe_pct);
var opGrowth = toNumber_(df.op_income_growth_pct);
var debt = toNumber_(df.debt_ratio_pct);
var ocf = toNumber_(df.operating_cf_krw);
var pe = toNumber_(df.pe_ttm);
var pass = 0;
var fail = [];
if (roe !== null && roe >= 8) pass++; else fail.push('ROE_WEAK_OR_MISSING');
if (opGrowth !== null && opGrowth >= 0) pass++; else fail.push('OP_GROWTH_WEAK_OR_MISSING');
if (debt !== null && debt <= 200) pass++; else fail.push('DEBT_RATIO_HIGH_OR_MISSING');
if (ocf !== null && ocf > 0) pass++; else fail.push('OCF_WEAK_OR_MISSING');
if (pe !== null && pe > 0 && pe <= 35) pass++; else fail.push('PE_BAND_OUT_OR_MISSING');
var grade = pass >= 4 ? 'A' : pass >= 3 ? 'B' : pass >= 2 ? 'C' : 'D';
return {
ticker: tk,
name: h.name || '',
grade: grade,
score: pass,
buy_allowed: pass >= 3,
fail_reasons: fail,
formula_id: 'FUNDAMENTAL_QUALITY_GATE_V1'
};
});
return {
formula_id: 'FUNDAMENTAL_QUALITY_GATE_V1',
rows: rows
};
}
function calcHorizonAllocationLockV1_(holdings, hApex) {
holdings = Array.isArray(holdings) ? holdings : [];
var totalAsset = toNumber_((hApex && hApex.total_asset_krw) || 0) || 0;
var cap = { SHORT: 25, MID: 45, LONG: 70, UNKNOWN: 0 };
var bucketSum = { SHORT: 0, MID: 0, LONG: 0, UNKNOWN: 0 };
var rows = holdings.map(function(h) {
var bucket = String(h.invest_horizon || h.horizon_bucket || 'UNKNOWN').toUpperCase();
if (!cap.hasOwnProperty(bucket)) bucket = 'UNKNOWN';
var v = toNumber_(h.marketValue) || toNumber_(h.market_value_krw) || toNumber_(h.close) * (toNumber_(h.holdingQty) || 0) || 0;
bucketSum[bucket] += v;
return { ticker: String(h.ticker || ''), name: h.name || '', bucket: bucket, market_value_krw: v };
});
var byBucket = Object.keys(bucketSum).map(function(k) {
var pct = totalAsset > 0 ? (bucketSum[k] / totalAsset * 100) : 0;
return {
bucket: k,
cap_pct: cap[k],
current_pct: Math.round(pct * 100) / 100,
violation: pct > cap[k]
};
});
return {
formula_id: 'HORIZON_ALLOCATION_LOCK_V1',
bucket_summary: byBucket,
rows: rows
};
}
function calcSmartMoneyLiquidityGateV1_(holdings, hApex) {
holdings = Array.isArray(holdings) ? holdings : [];
var radarMap = {};
((hApex && hApex.proactive_sell_radar_json) || []).forEach(function(r) {
radarMap[String(r.ticker || '')] = r;
});
var rows = holdings.map(function(h) {
var tk = String(h.ticker || '');
var r = radarMap[tk] || {};
var flowState = Number(r.score || 0) >= 6 ? 'OUTFLOW_RISK' : 'NEUTRAL';
var liqState = Number(r.liquidity_5d_bn || 0) > 0 && Number(r.liquidity_5d_bn) < 80 ? 'LOW' : 'NORMAL';
var mode = (flowState === 'OUTFLOW_RISK' && liqState === 'LOW') ? 'SELL_SPLIT_ONLY' : 'NORMAL';
return {
ticker: tk,
name: h.name || '',
flow_state: flowState,
liquidity_state: liqState,
execution_mode: mode,
buy_allowed: mode === 'NORMAL',
formula_id: 'SMART_MONEY_LIQUIDITY_GATE_V1'
};
});
return {
formula_id: 'SMART_MONEY_LIQUIDITY_GATE_V1',
rows: rows
};
}
function buildRoutingServingTraceV2_(routingTrace, hApex) {
var rt = routingTrace || {};
var eg = (hApex && hApex.export_gate_json) || {};
return {
trace_version: 'V2',
llm_serving_budget: 0,
request_route: rt.request_route || 'PIPELINE_EOD_BATCH',
bundle_selected: rt.bundle_selected || 'retirement_portfolio_compact',
prompt_entrypoint: rt.prompt_entrypoint || 'prompts/analysis_prompt.md',
gate_path: ['DATA_QUALITY_GATE_V2', 'SELL_PRICE_SANITY_V2', 'EXPORT_GATE_V2'],
final_block_reason: eg.hts_entry_allowed ? null : String(eg.json_validation_status || 'REVIEW_ONLY'),
json_validation_status: eg.json_validation_status || rt.json_validation_status || 'PENDING_EXPORT',
formula_id: 'ROUTING_SERVING_DECISION_TRACE_V2'
};
}
// ── H1 헬퍼 ──────────────────────────────────────────────────────────────────
/**
* readMacroRegime_
* macro 시트의 REGIME_PRELIM 행에서 시장 국면 값 읽기
* buildHarnessContext_()에서 국면별 감축 가이던스 산출에 사용
*/
function readMacroRegime_(ss) {
try {
var sh = ss.getSheetByName('macro');
if (!sh) return 'UNKNOWN';
var data = sh.getDataRange().getValues();
for (var i = 0; i < data.length; i++) {
if (String(data[i][0] || '') === 'REGIME_PRELIM'
|| String(data[i][1] || '') === 'Market_Regime_Prelim') {
return String(data[i][3] || 'UNKNOWN');
}
}
return 'UNKNOWN';
} catch(e) {
Logger.log('[HARNESS] readMacroRegime_ error: ' + e.message);
return 'UNKNOWN';
}
}
/**
* calcRegimeTrimGuidance_
* REGIME_TRIM_WEIGHT_V1: 시장 국면별 위성/주도주 감축 비율 결정론적 산출
* LLM이 "조정기엔 5~10%" 같은 주관적 판단을 내리는 것을 하네스에서 선점
* spec/13_formula_registry.yaml:REGIME_TRIM_WEIGHT_V1 참조
*/
function calcRegimeTrimGuidance_(regime) {
switch (regime) {
case 'SECULAR_LEADER_RISK_ON':
case 'RISK_ON':
return {
phase: 'ADVANCE',
satellite_trim_pct_min: 0,
satellite_trim_pct_max: 5,
leader_trim_pct_min: 0,
leader_trim_pct_max: 0,
priority_order: 'HOLD_ALL > 약한위성_5%이하 > 중복ETF',
new_buy_gate: 'ALLOWED_IF_HEAT_PASS',
description: '상승기: 주도주 보유 극대화. 감축 최소화.'
};
case 'LEADER_CONCENTRATION':
case 'NEUTRAL':
return {
phase: 'PULLBACK_IN_UPTREND',
satellite_trim_pct_min: 5,
satellite_trim_pct_max: 10,
leader_trim_pct_min: 0,
leader_trim_pct_max: 5,
priority_order: '약한위성 > 중복ETF > 주도주_소량헤지',
new_buy_gate: 'BLOCKED',
description: '조정/횡보기: 위성 부분 감축. 주도주 소량 헤지 가능.'
};
case 'RISK_OFF_CANDIDATE':
return {
phase: 'DISTRIBUTION',
satellite_trim_pct_min: 10,
satellite_trim_pct_max: 25,
leader_trim_pct_min: 5,
leader_trim_pct_max: 10,
priority_order: '중복ETF > 약한위성 > 주도주_이익잠금',
new_buy_gate: 'BLOCKED',
description: '분배장 경고: 위성 우선 감축. 현금 목표 12% 이상.'
};
case 'RISK_OFF':
case 'EVENT_SHOCK':
return {
phase: 'BREAKDOWN',
satellite_trim_pct_min: 25,
satellite_trim_pct_max: 50,
leader_trim_pct_min: 10,
leader_trim_pct_max: 25,
priority_order: '코어보호해제 > 전종목감축검토',
new_buy_gate: 'HARD_BLOCKED',
description: '추세붕괴/이벤트쇼크: 전면 감축. 코어 예외 없음.'
};
default:
return {
phase: 'UNKNOWN',
satellite_trim_pct_min: 0,
satellite_trim_pct_max: 0,
leader_trim_pct_min: 0,
leader_trim_pct_max: 0,
priority_order: 'DATA_MISSING_REGIME — 국면 미확인',
new_buy_gate: 'BLOCKED',
description: '국면 미확인: 신규매수 보류. macro 재실행 후 재판정.'
};
}
}
function calcCashShortfallHarness_(asResult, totalAsset, cashFloorInfo, mrsScore) {
var targetCashPct = Math.max(5 + (mrsScore / 10) * 15, cashFloorInfo.minPct);
var d2Krw = asResult.settlementCashD2Krw || 0;
var asset = Number.isFinite(totalAsset) ? totalAsset : 0;
return {
cash_current_pct_d2: asset > 0 ? Math.round(d2Krw / asset * 10000) / 100 : 0,
cash_target_pct: targetCashPct,
cash_shortfall_min_krw: Math.max(0, Math.round(asset * cashFloorInfo.minPct / 100 - d2Krw)),
cash_shortfall_target_krw: Math.max(0, Math.round(asset * targetCashPct / 100 - d2Krw))
};
}
/**
* SECULAR_LEADER_REGIME_GATE_V1
* 삼성전자(005930)·SK하이닉스(000660) secular_leader_profit_lock 결정론적 발동 게이트.
* spec/exit/take_profit.yaml:secular_leader_profit_lock.activation_required_all 완전 구현.
* 반환: { active, status, reasons }
*/
function calcSecularLeaderGate_(ticker, marketRegime, df, holdingQty) {
var SECULAR_TICKERS = ['005930', '000660'];
var reasons = [];
if (SECULAR_TICKERS.indexOf(ticker) < 0) {
return { active: false, status: 'NOT_APPLICABLE', reasons: ['not_secular_leader_ticker'] };
}
// ── 비활성 조건 검사 (any one → 즉시 비활성) ────────────────────────────
var close = df.close || 0;
var ma20 = df.ma20 || 0;
var frg5d = typeof df.frg5d === 'number' ? df.frg5d : null;
var inst5d = typeof df.inst5d === 'number' ? df.inst5d : null;
var acTotal = typeof df.acTotal === 'number' ? df.acTotal : 0;
var deactivationReasons = [];
if (marketRegime !== 'SECULAR_LEADER_RISK_ON') {
deactivationReasons.push('regime_not_secular(' + marketRegime + ')');
}
if (close > 0 && ma20 > 0 && close <= ma20) {
deactivationReasons.push('close(' + close + ')<=MA20(' + ma20 + ')');
}
if (acTotal >= 3) {
deactivationReasons.push('anti_climax_gate>=' + acTotal);
}
if (frg5d !== null && inst5d !== null && frg5d < 0 && inst5d < 0) {
deactivationReasons.push('dual_outflow:frg5d(' + frg5d + ')_inst5d(' + inst5d + ')');
}
if (deactivationReasons.length > 0) {
return {
active: false,
status: 'DEACTIVATED',
reasons: deactivationReasons
};
}
// ── 활성화 조건 검사 (all must pass) ────────────────────────────────────
var activationFails = [];
if (!(holdingQty > 0)) {
activationFails.push('no_holding_quantity');
}
if (close <= 0 || ma20 <= 0) {
activationFails.push('close_or_ma20_missing');
} else if (close <= ma20) {
activationFails.push('close_below_ma20');
}
var flowOk = df.flowOk === 'Y';
var flowPos = (frg5d !== null && frg5d > 0) || (inst5d !== null && inst5d > 0);
if (!flowOk || !flowPos) {
activationFails.push('flow_condition_fail(flowOk=' + df.flowOk + ',frg5d=' + frg5d + ',inst5d=' + inst5d + ')');
}
if (activationFails.length > 0) {
return {
active: false,
status: 'ACTIVATION_FAIL',
reasons: activationFails
};
}
return {
active: true,
status: 'ACTIVE',
reasons: ['regime=SECULAR_LEADER_RISK_ON', 'close>MA20', 'flow_ok', 'holding_confirmed']
};
}
function calcIntradayLock_(capturedAt) {
if (!capturedAt) return false;
var d = capturedAt instanceof Date ? capturedAt : new Date(capturedAt);
if (isNaN(d.getTime())) return false;
var kstMin = ((d.getUTCHours() + 9) % 24) * 60 + d.getUTCMinutes();
return kstMin < INTRADAY_CUTOFF_MINUTES;
}
/**
* N1: POSITION_SIZE_REGIME_SCALE_V1
* 국면에 따라 atrQty 기반 매수 수량의 스케일 배수를 반환한다.
* M1(DrawdownGuard) 이후에 추가로 적용되는 독립적 국면 방어층.
* @param {string} regime
* @return {{ scale, regime_applied }}
*/
function calcRegimeSizeScale_(regime) {
var r = String(regime || '').toUpperCase();
if (r.indexOf('EVENT_SHOCK') >= 0) return { scale: 0.25, regime_applied: regime };
if (r.indexOf('RISK_OFF') >= 0) return { scale: 0.50, regime_applied: regime };
if (r.indexOf('SECULAR_LEADER') >= 0 && r.indexOf('RISK_ON') >= 0) return { scale: 1.2, regime_applied: regime };
if (r.indexOf('RISK_ON') >= 0) return { scale: 1.1, regime_applied: regime };
return { scale: 1.0, regime_applied: regime }; // NEUTRAL
}
/**
* N5: REGIME_CASH_UPLIFT_V1
* 국면에 따라 현금 최소 비율을 상향하는 오버라이드를 반환한다.
* MRS 기반 calcCashFloor_ 결과보다 높을 때만 적용된다.
* @param {string} regime
* @param {number} mrsCashMinPct — 현재 MRS 기반 최소 현금 %
* @return {number} effectiveMinPct
*/
function calcRegimeCashUplift_(regime, mrsCashMinPct) {
var r = String(regime || '').toUpperCase();
var regimeMin = 0;
if (r.indexOf('EVENT_SHOCK') >= 0) regimeMin = 20;
else if (r.indexOf('RISK_OFF') >= 0) regimeMin = 15;
else if (r.indexOf('RISK_ON') >= 0) regimeMin = 5; // 완화
// NEUTRAL: regimeMin=0 → MRS값 그대로
return Math.max(mrsCashMinPct, regimeMin);
}
/**
* N3: STOP_PRICE_ADEQUACY_V1
* 보유 종목의 수동 손절가가 ATR 기반 권고 손절가 대비 적정한지 검증한다.
* manual_stop < recommended_stop × 0.85 → STOP_WIDE (너무 넓어 Heat 과소 반영)
* manual_stop < recommended_stop × 0.60 → STOP_CRITICAL (손절 의지 없음 수준)
* @param {Array} holdings
* @param {Object} dfMap
* @return {Array} stop_adequacy rows
*/
function calcStopAdequacyRows_(holdings, dfMap) {
return holdings.map(function(h) {
var df = dfMap[h.ticker] || {};
var atr20 = typeof df.atr20 === 'number' && df.atr20 > 0 ? df.atr20 : null;
var close = df.close || h.close || 0;
var avgCost = h.avgCost || 0;
var recommendedStop = null;
if (atr20 && close > 0 && avgCost > 0) {
var atrMul = (atr20 / avgCost * 100 >= 8) ? 2.0 : 1.5;
recommendedStop = Math.max(avgCost * 0.92, avgCost - atr20 * atrMul);
recommendedStop = tickNormalize_(recommendedStop);
}
var status = 'PASS';
var stopGap = null;
if (recommendedStop !== null && h.stopPrice > 0) {
stopGap = round2_((recommendedStop - h.stopPrice) / recommendedStop * 100);
if (h.stopPrice < recommendedStop * 0.60) status = 'STOP_CRITICAL';
else if (h.stopPrice < recommendedStop * 0.85) status = 'STOP_WIDE';
} else if (!atr20) {
status = 'INSUFFICIENT_DATA';
}
return {
ticker: h.ticker,
name: h.name || '',
manual_stop: h.stopPrice || null,
recommended_stop: recommendedStop,
stop_gap_pct: stopGap,
adequacy_status: status,
stop_price_src: h.stopPriceSrc || 'UNKNOWN',
formula_id: 'STOP_PRICE_ADEQUACY_V1'
};
});
}
/**
* N4: HOLDING_STALE_REVIEW_V1
* 보유 기간이 60일을 초과한 종목에 STALE_POSITION 플래그를 표시한다.
* account_snapshot의 entry_date 컬럼 기반. 없으면 ENTRY_DATE_MISSING.
* @param {Array} holdings — entryDate 필드 포함
* @return {Array} holding_stale rows
*/
function calcHoldingStaleReview_(holdings) {
var nowMs = Date.now();
var STALE_DAYS = 60;
var REVIEW_DAYS = 30;
return holdings.map(function(h) {
var entryDateStr = h.entryDate || null;
var holdingDays = null;
var status = 'ENTRY_DATE_MISSING';
if (entryDateStr) {
var entryMs = new Date(entryDateStr).getTime();
if (Number.isFinite(entryMs) && entryMs > 0) {
holdingDays = Math.floor((nowMs - entryMs) / 86400000);
if (holdingDays > STALE_DAYS) status = 'STALE_POSITION';
else if (holdingDays > REVIEW_DAYS) status = 'REVIEW_SOON';
else status = 'FRESH';
}
}
return {
ticker: h.ticker,
name: h.name || '',
entry_date: entryDateStr,
holding_days: holdingDays,
stale_status: status,
formula_id: 'HOLDING_STALE_REVIEW_V1'
};
});
}
/**
* P1: STOP_BREACH_ALERT_V1
* 보유 종목 중 close <= stop_price인 종목을 즉시 경보한다.
* close <= stop_price → BREACH_IMMEDIATE_EXIT
* close <= stop_price × 1.03 → STOP_APPROACHING
* @param {Array} holdings
* @param {Object} dfMap
* @return {{ gate, alerts }}
*/
function calcStopBreachAlert_(holdings, dfMap) {
// THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/inject_computed_harness.py:calc_stop_breach_alerts
var gate = 'PASS';
var alerts = holdings.map(function(h) {
var df = dfMap[h.ticker] || {};
var close = h.close || df.close || 0;
var stopPrc = h.stopPrice || 0;
var status = 'PASS';
var gapPct = null;
if (close > 0 && stopPrc > 0) {
gapPct = round2_((close - stopPrc) / stopPrc * 100);
if (close <= stopPrc) {
status = 'BREACH_IMMEDIATE_EXIT';
gate = 'BREACH';
} else if (close <= stopPrc * 1.03) {
status = 'STOP_APPROACHING';
if (gate === 'PASS') gate = 'APPROACHING';
}
} else {
status = 'INSUFFICIENT_DATA';
}
return { ticker: h.ticker, name: h.name || '', close: close, stop_price: stopPrc, stop_src: h.stopPriceSrc || 'UNKNOWN', gap_pct: gapPct, status: status, formula_id: 'STOP_BREACH_ALERT_V1' };
});
return { gate: gate, alerts: alerts };
}
/**
* P1-BIS: RELATIVE_STOP_SIGNAL_V1
* 시장 베타 보정 후 초과수익(20D) 기반 상대 손절 신호.
* k=2.0 → threshold = -k × σ_proxy; ABS_FLOOR=-20%; TIME_STOP=60일+음수 초과수익
* @param {Array} holdings
* @param {Object} dfMap
* @param {number} kospiRet20d — KOSPI 20D 수익률 (%)
* @return {{ gate, signals }}
*/
function calcRelativeStopSignal_(holdings, dfMap, kospiRet20d) {
var K = 2.0;
var ABS_FLOOR = -20.0;
var gate = 'PASS';
var signals = holdings.map(function(h) {
var df = dfMap[h.ticker] || {};
var ret20d = typeof df.ret20d === 'number' ? df.ret20d : parseFloat(df.ret20d);
var atr20 = typeof df.atr20 === 'number' ? df.atr20 : parseFloat(df.atr20);
var close = h.close || df.close || 0;
var profitPct = typeof h.profitPct === 'number' ? h.profitPct : parseFloat(h.profitPct);
var holdDays = typeof h.holdingDays === 'number' ? h.holdingDays : parseInt(h.holdingDays) || 0;
if (!Number.isFinite(ret20d) || !Number.isFinite(atr20) || close <= 0) {
return { ticker: h.ticker, name: h.name || '', signal: false,
signal_type: 'INSUFFICIENT_DATA', details: {}, formula_id: 'RELATIVE_STOP_SIGNAL_V1' };
}
var betaProxy = 1.0;
if (typeof kospiRet20d === 'number' && Math.abs(kospiRet20d) >= 0.5) {
betaProxy = Math.min(3.0, Math.max(0.3, ret20d / kospiRet20d));
}
var excessRet = ret20d - betaProxy * kospiRet20d;
var sigmaProxy = (atr20 / close * 100) * Math.sqrt(20);
var threshold = -K * sigmaProxy;
var relBreach = excessRet < threshold;
var absBreach = Number.isFinite(profitPct) && profitPct < ABS_FLOOR;
var timeBreach = holdDays >= 60 && excessRet < 0;
var triggered = relBreach || absBreach || timeBreach;
var signalType = absBreach ? 'ABS_FLOOR' : (relBreach ? 'REL_EXCESS' : (timeBreach ? 'TIME_STOP' : 'PASS'));
if (triggered && gate === 'PASS') gate = 'TRIGGERED';
return {
ticker: h.ticker,
name: h.name || '',
signal: triggered,
signal_type: signalType,
details: {
beta_proxy: round2_(betaProxy),
excess_ret20d: round2_(excessRet),
sigma_proxy: round2_(sigmaProxy),
threshold: round2_(threshold),
profit_pct: Number.isFinite(profitPct) ? round2_(profitPct) : null,
hold_days: holdDays
},
formula_id: 'RELATIVE_STOP_SIGNAL_V1'
};
});
return { gate: gate, signals: signals };
}
/**
* P3: ABSOLUTE_RISK_STOP_V1
* stop adequacy rows를 절대 리스크 손절 taxonomy에 맞춰 표준화한다.
* @param {Array} holdings
* @param {Object} dfMap
* @return {{ gate, rows }}
*/
function calcAbsoluteRiskStopV1_(holdings, dfMap) {
// THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_stop_price_core
var rows = calcStopAdequacyRows_(holdings, dfMap).map(function(r) {
var stopPrice = Number.isFinite(r.manual_stop) && r.manual_stop > 0
? r.manual_stop
: r.recommended_stop;
return {
ticker: r.ticker,
name: r.name || '',
stop_price: Number.isFinite(stopPrice) ? round2_(stopPrice) : null,
stop_quantity: null,
adequacy_status: r.adequacy_status,
stop_gap_pct: r.stop_gap_pct,
formula_id: 'ABSOLUTE_RISK_STOP_V1'
};
});
var gate = rows.some(function(r) { return r.adequacy_status === 'STOP_CRITICAL'; }) ? 'BLOCK' : 'PASS';
return { gate: gate, rows: rows };
}
/**
* P3: RELATIVE_UNDERPERF_ALERT_V1
* 상대약세 경보를 표준 taxonomy로 감싼다.
* @param {Array} holdings
* @param {Object} dfMap
* @param {number} kospiRet20d
* @return {{ gate, rows }}
*/
function calcRelativeUnderperfAlertV1_(holdings, dfMap, kospiRet20d) {
var result = calcRelativeStopSignal_(holdings, dfMap, kospiRet20d);
return {
gate: result.gate,
rows: result.signals.map(function(r) {
return {
ticker: r.ticker,
name: r.name || '',
signal: !!r.signal,
signal_type: r.signal_type,
details: r.details || {},
formula_id: 'RELATIVE_UNDERPERF_ALERT_V1'
};
})
};
}
/**
* P3: STOP_ACTION_LADDER_V1
* exit sell action 결과를 손절/익절/시간손절 taxonomy로 표준화한다.
* @param {Object} ctx
* @return {{ formula_id, action, ratio_pct, limit_price, price_basis, reason, validation }}
*/
var calcStopActionLadderV1_ = function(ctx) {
var d = calcExitSellAction_(ctx || {});
return {
formula_id: 'STOP_ACTION_LADDER_V1',
action: d.action,
ratio_pct: d.ratio_pct,
limit_price: d.limit_price,
price_basis: d.price_basis,
reason: d.reason,
validation: d.validation,
order_type: d.order_type,
price_source: d.price_source
};
}
/**
* P2: TP_TRIGGER_ALERT_V1
* close >= tp1_price / tp2_price인 종목을 감지하고 tp_quantity_ladder_json과 연계한다.
* 익절 가격 도달 시 즉각 수량을 확정론적으로 제공한다.
* @param {Array} holdings
* @param {Object} dfMap
* @param {Object} h4 — calcPrices_() 반환값 (h4.prices 배열)
* @param {Array} tpLadderRows — calcTpQuantityLadder_() 반환값
* @return {{ gate, triggered }}
*/
function calcTpTriggerAlert_(holdings, dfMap, h4, tpLadderRows) {
// THIN_ADAPTER: [take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_tp_validity
var priceMap = {};
(h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; });
var ladderMap = {};
(tpLadderRows || []).forEach(function(r) { ladderMap[r.ticker] = r; });
var gate = 'PASS';
var triggered = [];
holdings.forEach(function(h) {
var df = dfMap[h.ticker] || {};
var close = h.close || df.close || 0;
var pr = priceMap[h.ticker] || {};
var lr = ladderMap[h.ticker] || {};
var tp1 = typeof pr.tp1_price === 'number' ? pr.tp1_price : null;
var tp2 = typeof pr.tp2_price === 'number' ? pr.tp2_price : null;
var tp1Hit = tp1 !== null && close > 0 && close >= tp1;
var tp2Hit = tp2 !== null && close > 0 && close >= tp2;
if (!tp1Hit && !tp2Hit) return;
if (gate === 'PASS') gate = 'TRIGGERED';
triggered.push({
ticker: h.ticker,
name: h.name || '',
close: close,
tp1_price: tp1,
tp2_price: tp2,
tp1_triggered: tp1Hit,
tp2_triggered: tp2Hit,
tp1_qty: lr.tp1_qty !== undefined ? lr.tp1_qty : null,
tp2_qty: lr.tp2_qty !== undefined ? lr.tp2_qty : null,
qty_source: lr.qty_source || 'NO_LADDER',
formula_id: 'TP_TRIGGER_ALERT_V1'
});
});
return { gate: gate, triggered: triggered };
}
/**
* P3: HEAT_CONCENTRATION_ALERT_V1
* 단일 종목이 전체 Total Heat의 50% 이상을 차지하면 HEAT_CONCENTRATED 경보.
* 해당 종목 급락 시 total_heat_pct가 급변해 게이트가 무력화되는 리스크 차단.
* @param {Array} holdings — avgCost, stopPrice, holdingQty 포함
* @param {number} totalHeatKrw
* @return {{ gate, by_holding }}
*/
function calcHeatConcentrationAlert_(holdings, totalHeatKrw) {
if (!totalHeatKrw || totalHeatKrw <= 0) {
return { gate: 'INSUFFICIENT_DATA', by_holding: [], formula_id: 'HEAT_CONCENTRATION_ALERT_V1' };
}
var gate = 'PASS';
var rows = holdings.map(function(h) {
var heatI = (h.avgCost > 0 && h.stopPrice > 0 && h.holdingQty > 0)
? (h.avgCost - h.stopPrice) * h.holdingQty : 0;
var sharePct = round2_(heatI / totalHeatKrw * 100);
var status = sharePct >= 50 ? 'HEAT_CONCENTRATED' : 'PASS';
if (status === 'HEAT_CONCENTRATED') gate = 'HEAT_CONCENTRATED';
return { ticker: h.ticker, name: h.name || '', heat_krw: Math.round(heatI), heat_share_pct: sharePct, status: status, formula_id: 'HEAT_CONCENTRATION_ALERT_V1' };
});
return { gate: gate, by_holding: rows };
}
/**
* P4: REGIME_TRANSITION_ALERT_V1
* settings.prev_market_regime와 현재 국면을 비교해 전환 유형을 산출한다.
* UPGRADE(완화) / DOWNGRADE(긴축) / LATERAL_SHIFT / NO_CHANGE
* 실행 후 current regime을 settings에 자동 기록.
* @param {string} marketRegime
* @param {Object} ss
* @param {Object} settings
* @return {{ transition_type, prev_regime, current_regime, affected_gates }}
*/
function calcRegimeTransitionAlert_(marketRegime, ss, settings) {
var prevRegime = String(settings['prev_market_regime'] || '').trim();
var curr = String(marketRegime || '').toUpperCase();
var prev = prevRegime.toUpperCase();
writeSettingValue_(ss, 'prev_market_regime', marketRegime);
if (!prevRegime || prev === curr) {
return { transition_type: 'NO_CHANGE', prev_regime: prevRegime || null, current_regime: marketRegime, affected_gates: [], formula_id: 'REGIME_TRANSITION_ALERT_V1' };
}
var RANK = { 'EVENT_SHOCK': 0, 'RISK_OFF': 1, 'NEUTRAL': 2, 'RISK_ON': 3, 'SECULAR_LEADER': 4 };
var getRank = function(r) {
if (r.indexOf('SECULAR_LEADER') >= 0) return 4;
if (r.indexOf('RISK_ON') >= 0) return 3;
if (r.indexOf('NEUTRAL') >= 0) return 2;
if (r.indexOf('RISK_OFF') >= 0) return 1;
if (r.indexOf('EVENT_SHOCK') >= 0) return 0;
return 2;
};
var transitionType = getRank(curr) > getRank(prev) ? 'UPGRADE'
: getRank(curr) < getRank(prev) ? 'DOWNGRADE'
: 'LATERAL_SHIFT';
var AFFECTED = [
'DYNAMIC_HEAT_GATE_V1', 'POSITION_SIZE_REGIME_SCALE_V1', 'REGIME_CASH_UPLIFT_V1',
'PORTFOLIO_BETA_GATE_V1', 'SECTOR_CONCENTRATION_LIMIT_V1',
'SEMICONDUCTOR_CLUSTER_GATE_V1', 'SINGLE_POSITION_WEIGHT_CAP_V1', 'POSITION_COUNT_LIMIT_V1'
];
return { transition_type: transitionType, prev_regime: prevRegime, current_regime: marketRegime, affected_gates: AFFECTED, formula_id: 'REGIME_TRANSITION_ALERT_V1' };
}
/**
* P5: PORTFOLIO_HEALTH_SCORE_V1
* 모든 게이트 상태를 집계해 HEALTHY/CAUTION/CRITICAL 단일 레이블을 산출한다.
* CRITICAL 게이트 1개 이상, 또는 CAUTION 게이트 3개 이상 → CRITICAL
* CAUTION 게이트 1~2개 → CAUTION, 0개 → HEALTHY
* score = max(0, 100 - critical×30 - caution×10)
* @param {Object} gateMap — { gate_id: gate_status_string }
* @return {{ label, score, critical_count, caution_count, blocked_gates }}
*/
function calcPortfolioHealthScore_(gateMap) {
var CRITICAL = ['BLOCK_NEW_BUY', 'HARD_BLOCK', 'NO_BUY', 'DRAWDOWN_FORCE_RISK_OFF',
'POSITION_COUNT_BLOCK', 'CLUSTER_BLOCK', 'BREACH',
'OVER_BETA', 'BLOCK_SECTOR', 'STOP_CRITICAL'];
var CAUTION = ['HALVE_NEW_BUY_QUANTITY', 'TRIM_REQUIRED', 'REDUCE_BUY', 'CAUTION_BUY',
'DRAWDOWN_CAUTION', 'WARN_BETA', 'WARN_TOP2', 'OVERWEIGHT_TRIM',
'EDGE_DEGRADED', 'EDGE_WEAK', 'EDGE_CRITICAL', 'APPROACHING',
'TRIGGERED', 'HEAT_CONCENTRATED', 'DOWNGRADE'];
var critCount = 0, warnCount = 0, blocked = [];
Object.keys(gateMap).forEach(function(name) {
var val = String(gateMap[name] || '').trim();
if (CRITICAL.indexOf(val) >= 0) {
critCount++;
blocked.push({ gate: name, status: val, severity: 'CRITICAL' });
} else if (CAUTION.indexOf(val) >= 0) {
warnCount++;
blocked.push({ gate: name, status: val, severity: 'CAUTION' });
}
});
var label = (critCount >= 1 || warnCount >= 3) ? 'CRITICAL'
: warnCount >= 1 ? 'CAUTION'
: 'HEALTHY';
return {
label: label,
score: Math.max(0, 100 - critCount * 30 - warnCount * 10),
critical_count: critCount,
caution_count: warnCount,
blocked_gates: blocked,
gate_input_count: Object.keys(gateMap).length,
formula_id: 'PORTFOLIO_HEALTH_SCORE_V1'
};
}
/**
* O1: SINGLE_POSITION_WEIGHT_CAP_V1
* 개별 종목 비중이 국면별 상한(NEUTRAL:20%, RISK_OFF:15%)을 초과하면 OVERWEIGHT_TRIM.
* M5(섹터 편중)와 독립적인 종목 단위 비중 하드 캡.
* @param {Array} holdings — weightPct 포함
* @param {string} marketRegime
* @return {{ gate_status, cap_pct, by_position }}
*/
/**
* LEADER_POSITION_WEIGHT_CAP_V1
* 삼성전자(005930), SK하이닉스(000660)에 대해 KOSPI 시총 비중 기반 차등 한도 적용.
* spec/strategy/semiconductor_concentration_policy.yaml 기준.
*
* 배경: 삼성전자 KOSPI 비중 ~23%. 기존 고정 20% 한도는 시장 비중보다 낮아
* 주도주를 사실상 과소보유 강제. 국면별로 시장 비중 × 배수를 허용한다.
*
* @param {Array} holdings
* @param {string} marketRegime
* @param {number} kospiSamsungWeightPct — settings.kospi_samsung_weight_pct (기본 23)
* @param {number} kospiHynixWeightPct — settings.kospi_hynix_weight_pct (기본 12)
*/
function calcSinglePositionWeightCap_(holdings, marketRegime, kospiSamsungWeightPct, kospiHynixWeightPct) {
var r = String(marketRegime || '').toUpperCase();
var isEventShock = r.indexOf('EVENT_SHOCK') >= 0;
var isRiskOff = isEventShock || r.indexOf('RISK_OFF') >= 0;
var isRiskOn = r.indexOf('RISK_ON') >= 0 && !isRiskOff;
var isSecularLeader = r.indexOf('SECULAR_LEADER') >= 0;
// settings에서 KOSPI 개별 종목 비중 읽기 (KRX/FnGuide 시총 데이터 기반 수동 입력)
// 미입력(0) 시 mktWtProvided=false → 정책 기반 고정 한도만 적용
var smWt = (Number.isFinite(kospiSamsungWeightPct) && kospiSamsungWeightPct > 0)
? kospiSamsungWeightPct : 0;
var hxWt = (Number.isFinite(kospiHynixWeightPct) && kospiHynixWeightPct > 0)
? kospiHynixWeightPct : 0;
var smWtProvided = smWt > 0;
var hxWtProvided = hxWt > 0;
// 일반 종목 한도 (기존 유지)
var defaultCap = isRiskOff ? 15.0 : (isRiskOn ? 22.0 : 20.0);
var gate = 'PASS';
var rows = holdings.map(function(h) {
var wPct = typeof h.weightPct === 'number' ? h.weightPct : 0;
var tickerCap;
if (h.ticker === '005930') {
// 삼성전자 — 국면별 정책 한도 (EXPERT_PRIOR, calibration_registry 등록)
// KOSPI 비중 제공 시: 비중×배수 vs 정책 한도 중 큰 값
// KOSPI 비중 미제공 시: 정책 한도만 (추측값 삽입 금지)
if (isEventShock)
tickerCap = 15.0;
else if (isRiskOff)
tickerCap = 18.0;
else if (isSecularLeader)
tickerCap = smWtProvided ? Math.max(50.0, smWt * 2.20) : 50.0;
else if (isRiskOn)
tickerCap = smWtProvided ? Math.max(40.0, smWt * 1.70) : 40.0;
else // NEUTRAL
tickerCap = smWtProvided ? Math.max(28.0, smWt * 1.20) : 28.0;
} else if (h.ticker === '000660') {
// SK하이닉스 — 국면별 정책 한도
if (isEventShock)
tickerCap = 10.0;
else if (isRiskOff)
tickerCap = 12.0;
else if (isSecularLeader)
tickerCap = hxWtProvided ? Math.max(28.0, hxWt * 2.50) : 28.0;
else if (isRiskOn)
tickerCap = hxWtProvided ? Math.max(22.0, hxWt * 1.80) : 22.0;
else // NEUTRAL
tickerCap = hxWtProvided ? Math.max(15.0, hxWt * 1.20) : 15.0;
} else {
tickerCap = defaultCap;
}
tickerCap = round2_(tickerCap);
var status = wPct > tickerCap ? 'OVERWEIGHT_TRIM' : 'PASS';
if (status === 'OVERWEIGHT_TRIM') gate = 'OVERWEIGHT_TRIM';
return {
ticker: h.ticker,
name: h.name || '',
weight_pct: wPct,
cap_pct: tickerCap,
status: status,
is_leader: (h.ticker === '005930' || h.ticker === '000660'),
formula_id: 'LEADER_POSITION_WEIGHT_CAP_V1'
};
});
return {
gate_status: gate,
cap_pct: defaultCap,
kospi_samsung_weight: smWtProvided ? round2_(smWt) : 'DATA_MISSING_SET_IN_SETTINGS',
kospi_hynix_weight: hxWtProvided ? round2_(hxWt) : 'DATA_MISSING_SET_IN_SETTINGS',
by_position: rows,
formula_id: 'LEADER_POSITION_WEIGHT_CAP_V1'
};
}
/**
* O2: SEMICONDUCTOR_CLUSTER_GATE_V1
* 005930(삼성전자) + 000660(SK하이닉스) 합산 비중이 상한을 초과하면 CLUSTER_BLOCK.
* 두 종목이 같은 사이클에서 동반 하락하는 상관 리스크 통제.
* @param {Array} holdings
* @param {string} marketRegime
* @return {{ gate_status, combined_pct, cap_pct, holdings }}
*/
/**
* MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1
* 반도체 클러스터 한도를 KOSPI 시총 비중 기반으로 동적 산출한다.
* spec/strategy/semiconductor_concentration_policy.yaml 기준.
*
* 배경: 삼성+하이닉스 KOSPI 비중 ~35%. 기존 고정 25% 한도는 주도장에서
* 시장 대비 필연적 언더퍼폼을 강제. 시장 비중은 최소 허용해야 한다.
*
* @param {Array} holdings
* @param {string} marketRegime
* @param {number} kospiSemiWeightPct — settings.kospi_semi_weight_pct (기본 35)
*/
function calcSemiconductorClusterGate_(holdings, marketRegime, kospiSemiWeightPct) {
var r = String(marketRegime || '').toUpperCase();
var isEventShock = r.indexOf('EVENT_SHOCK') >= 0;
var isRiskOff = isEventShock || r.indexOf('RISK_OFF') >= 0;
var isRiskOn = r.indexOf('RISK_ON') >= 0 && !isRiskOff;
var isSecularLeader = r.indexOf('SECULAR_LEADER') >= 0;
var isCLA = r.indexOf('CONCENTRATED_LEADER_ADVANCE') >= 0 || r === 'CLA';
// settings에서 KOSPI 반도체 시총 비중 읽기 (사용자가 KRX 데이터 기반으로 직접 입력)
// 0 또는 미입력이면 DATA_MISSING — 아래 정책 기반 한도만 적용
var mktWt = (Number.isFinite(kospiSemiWeightPct) && kospiSemiWeightPct > 0)
? kospiSemiWeightPct : 0;
var mktWtProvided = mktWt > 0;
// 국면별 정책 한도 (EXPERT_PRIOR — calibration_registry.yaml 등록값)
// 주의: KOSPI 비중은 KRX/FnGuide 시총 데이터 기준으로 settings에서만 입력.
// 하드코딩 추정치 사용 금지. settings 미입력 시 정책 한도만 적용.
var capPct, gateMode;
if (isEventShock) {
capPct = mktWtProvided ? Math.max(20.0, mktWt * 0.60) : 20.0;
gateMode = 'DEFENSIVE_STRICT';
} else if (isRiskOff) {
capPct = mktWtProvided ? Math.max(25.0, mktWt * 0.80) : 25.0;
gateMode = 'DEFENSIVE';
} else if (isSecularLeader || isCLA) {
capPct = 65.0;
gateMode = 'SECULAR_LEADER';
} else if (isRiskOn) {
capPct = mktWtProvided ? Math.max(45.0, mktWt * 1.30) : 45.0;
gateMode = 'RISK_ON_OVERWEIGHT';
} else {
capPct = mktWtProvided ? Math.max(35.0, mktWt * 1.00) : 35.0;
gateMode = 'MARKET_NEUTRAL';
}
// CLA 상태에서는 KODEX 반도체(229200)도 클러스터에 포함
var SEMI_BASE = ['005930', '000660'];
var SEMI_CLA = ['005930', '000660', '229200'];
var clusterTickers = isCLA ? SEMI_CLA : SEMI_BASE;
var total = 0;
var clusterRows = [];
holdings.forEach(function(h) {
if (clusterTickers.indexOf(h.ticker) >= 0) {
var wPct = typeof h.weightPct === 'number' ? h.weightPct : 0;
total += wPct;
clusterRows.push({ ticker: h.ticker, name: h.name || '', weight_pct: wPct });
}
});
// 게이트 판정
// WARN 경계: mktWt 제공 시 mktWt × 0.90, 미제공 시 capPct × 0.80
var warnThreshold = mktWtProvided ? mktWt * 0.90 : capPct * 0.80;
var gate, clusterState;
if (total >= capPct) {
if (isRiskOff) {
gate = 'CLUSTER_BLOCK';
clusterState = 'CLUSTER_HOLD_ONLY';
} else {
gate = 'CLUSTER_OVERWEIGHT_TRIM';
clusterState = 'CLUSTER_HOLD_ONLY';
}
} else if (total >= warnThreshold) {
if (isSecularLeader || isCLA) {
gate = 'CLUSTER_HOLD_ONLY';
clusterState = 'CLUSTER_HOLD_ONLY';
} else {
gate = 'CLUSTER_OVERWEIGHT_WARN';
clusterState = 'CLUSTER_OPEN';
}
} else {
gate = 'PASS';
clusterState = 'CLUSTER_OPEN';
}
return {
gate_status: gate,
cluster_state: clusterState,
cluster_id: 'SEMICONDUCTOR_KR',
cluster_tickers: clusterTickers,
combined_pct: round2_(total),
cap_pct: round2_(capPct),
kospi_semi_weight: mktWtProvided ? round2_(mktWt) : 'DATA_MISSING_SET_IN_SETTINGS',
kospi_weight_provided: mktWtProvided,
gate_mode: gateMode,
holdings: clusterRows,
formula_id: 'MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1'
};
}
/**
* SATELLITE_FAILURE_GATE_V1
* 위성 집단 실패 추적 — spec/13_formula_registry.yaml:SATELLITE_FAILURE_GATE_V1
* @param {Array} satelliteRows — { composite_verdict, rs_verdict, ret20d, excess_ret_10d }
* @return {{ sfg_v1, sfg_reason, sfg_broken_count, sfg_failure_rate }}
*/
function calcSatelliteFailureGate_(satelliteRows) {
if (!satelliteRows || satelliteRows.length === 0) {
return { sfg_v1: 'CLEAR', sfg_reason: 'no_satellite_data',
sfg_broken_count: 0, sfg_failure_rate: 0,
formula_id: 'SATELLITE_FAILURE_GATE_V1' };
}
var brokenCount = 0, failureCount = 0;
var totalRet20d = 0, totalExcess = 0, retCount = 0;
satelliteRows.forEach(function(row) {
var cv = row.composite_verdict || '';
var rv = row.rs_verdict || '';
if (cv === 'CLOSE_POSITION' || rv === 'BROKEN') brokenCount++;
if (cv === 'REDUCE_CANDIDATE' || cv === 'EXIT_REVIEW' || cv === 'CLOSE_POSITION') failureCount++;
if (typeof row.ret20d === 'number') { totalRet20d += row.ret20d; retCount++; }
if (typeof row.excess_ret_10d === 'number') totalExcess += row.excess_ret_10d;
});
var n = satelliteRows.length;
var failureRate = n > 0 ? failureCount / n : 0;
var avgRet20d = retCount > 0 ? totalRet20d / retCount : 0;
var avgExcess = n > 0 ? totalExcess / n : 0;
var condA = brokenCount >= 3;
var condB = failureRate >= 0.60;
var condC = avgRet20d <= -10 && avgExcess <= -8; // ret20d는 % 단위 (e.g. -10.5)
var triggered = condA || condB || condC;
return {
sfg_v1: triggered ? 'TRIGGERED' : 'CLEAR',
sfg_reason: condA ? ('broken_count_' + brokenCount) :
condB ? ('failure_rate_' + Math.round(failureRate * 100) + 'pct') :
condC ? 'avg_excess_drawdown_breach' : 'clear',
sfg_broken_count: brokenCount,
sfg_failure_rate: parseFloat(failureRate.toFixed(3)),
formula_id: 'SATELLITE_FAILURE_GATE_V1'
};
}
/**
* SATELLITE_AGGREGATE_PNL_GATE_V1
* 위성 합산 손익이 코어 수익을 얼마나 잠식하는지 결정론적으로 산출한다.
*/
function calcSatelliteAggregatePnlGate_(holdings) {
var corePnl = 0, satellitePnl = 0, coreCount = 0, satelliteCount = 0;
(holdings || []).forEach(function(h) {
var pnl = typeof h.profit_loss === 'number' ? h.profit_loss
: typeof h.unrealizedPnl === 'number' ? h.unrealizedPnl
: typeof h.unrealized_pnl_krw === 'number' ? h.unrealized_pnl_krw : 0;
if (h.position_type === 'core') {
corePnl += pnl; coreCount++;
} else {
satellitePnl += pnl; satelliteCount++;
}
});
var ratio = corePnl > 0 ? Math.abs(Math.min(0, satellitePnl)) / corePnl : null;
var status = ratio === null ? 'INSUFFICIENT_DATA'
: ratio >= 0.50 ? 'SAPG_CRITICAL'
: ratio >= 0.25 ? 'SAPG_ALERT'
: 'PASS';
return {
sapg_status: status,
core_total_pnl_krw: Math.round(corePnl),
satellite_total_pnl_krw: Math.round(satellitePnl),
satellite_loss_to_core_gain_ratio: ratio === null ? null : round2_(ratio),
core_count: coreCount,
satellite_count: satelliteCount,
formula_id: 'SATELLITE_AGGREGATE_PNL_GATE_V1'
};
}
function calcCashCreationPurposeLockRow_(h, df, sfgResult) {
var cv = df.composite_verdict || null;
var rv = df.rs_verdict || null;
var brt = df.brt_verdict || null;
var excessDrawdown = typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null;
var rec20 = typeof df.recovery_ratio_20d === 'number' ? df.recovery_ratio_20d : null;
var valid = false;
var reasons = [];
if (['REDUCE_CANDIDATE', 'EXIT_REVIEW', 'CLOSE_POSITION'].includes(cv)) { valid = true; reasons.push('composite_verdict_' + cv); }
if (rv === 'BROKEN' || brt === 'BROKEN') { valid = true; reasons.push('relative_broken'); }
if (excessDrawdown !== null && excessDrawdown >= 10 && rec20 !== null && rec20 < 0.50) { valid = true; reasons.push('excess_drawdown_no_recovery'); }
if (sfgResult && sfgResult.sfg_v1 === 'TRIGGERED' && h.position_type !== 'core') { valid = true; reasons.push('sfg_v1_TRIGGERED'); }
return {
ticker: h.ticker,
name: h.name || df.name || '',
position_type: h.position_type || 'unknown',
sell_reason_validity: valid ? 'VALID_SELL_REASON' : 'INVALID_SELL_REASON',
valid_reason_codes: reasons,
reinvestment_allowed: false,
formula_id: 'CASH_CREATION_PURPOSE_LOCK_V1'
};
}
// ── [2026-05-21_AEW_V1] ALPHA_EVALUATION_WINDOW_V1 ──────────────────────────
// 위성 보유 종목의 진입 이후 경과 영업일을 판단하여 T+20/T+60 알파 게이트를 산출한다.
// 벤치마크: 삼성전자(005930) + SK하이닉스(000660) 평균 ret20D/ret60D (프록시).
// position_type=core 종목은 EXEMPT 처리하여 게이트 판정에서 제외한다.
function calcAlphaEvaluationWindow_(holdings, dfMap) {
var samsung = dfMap['005930'] || {};
var hynix = dfMap['000660'] || {};
// 코어 벤치마크 수익률 프록시
var coreRet20Vals = [];
if (Number.isFinite(samsung.ret20D)) coreRet20Vals.push(samsung.ret20D);
if (Number.isFinite(hynix.ret20D)) coreRet20Vals.push(hynix.ret20D);
var coreRet20d = coreRet20Vals.length > 0
? coreRet20Vals.reduce(function(s,v){return s+v;},0) / coreRet20Vals.length : null;
var coreRet60Vals = [];
if (Number.isFinite(samsung.ret60D)) coreRet60Vals.push(samsung.ret60D);
if (Number.isFinite(hynix.ret60D)) coreRet60Vals.push(hynix.ret60D);
var coreRet60d = coreRet60Vals.length > 0
? coreRet60Vals.reduce(function(s,v){return s+v;},0) / coreRet60Vals.length : null;
var aewRows = [];
holdings.forEach(function(h) {
if (!h.ticker) return;
// core 종목 — 알파 게이트 평가 대상 아님
if (h.position_type === 'core') {
aewRows.push({
ticker: h.ticker,
name: h.name || '',
position_type: 'core',
entry_date: h.entry_date || '',
days_since_entry: null,
satellite_return_pct: null,
core_benchmark_ret20d: coreRet20d,
core_benchmark_ret60d: coreRet60d,
t20_reached: false,
t20_vs_core_pctp: null,
t20_alpha_gate: 'EXEMPT',
t60_reached: false,
t60_vs_core_pctp: null,
t60_alpha_gate: 'EXEMPT',
evaluation_method: 'EXEMPT_CORE',
formula_id: 'ALPHA_EVALUATION_WINDOW_V1'
});
return;
}
var daysSinceEntry = h.entry_date ? calcKrxBizDaysDiff_(h.entry_date) : null;
var satRetPct = typeof h.return_pct === 'number' && Number.isFinite(h.return_pct)
? h.return_pct : null;
// entry_date 없거나 미래 날짜 — 데이터 누락
var validEntry = daysSinceEntry !== null && daysSinceEntry >= 0;
var t20Reached = validEntry && daysSinceEntry >= 20;
var t60Reached = validEntry && daysSinceEntry >= 60;
var t20VsCorePctp = null;
var t20AlphaGate = validEntry ? (t20Reached ? 'DATA_MISSING' : 'NOT_YET') : 'DATA_MISSING';
var t60VsCorePctp = null;
var t60AlphaGate = validEntry ? (t60Reached ? 'DATA_MISSING' : 'NOT_YET') : 'DATA_MISSING';
// T+20 평가 — 위성 총수익률 vs 코어 20D 수익률 (프록시)
if (t20Reached && satRetPct !== null && coreRet20d !== null) {
t20VsCorePctp = round2_(satRetPct - coreRet20d);
t20AlphaGate = t20VsCorePctp < -3 ? 'T20_ALPHA_FAIL'
: t20VsCorePctp >= 0 ? 'PASS'
: 'NEUTRAL';
}
// T+60 평가 — 위성 총수익률 vs 코어 60D 수익률 (프록시)
if (t60Reached && satRetPct !== null && coreRet60d !== null) {
t60VsCorePctp = round2_(satRetPct - coreRet60d);
t60AlphaGate = t60VsCorePctp < -5 ? 'T60_ALPHA_FAIL'
: t60VsCorePctp >= 0 ? 'PASS'
: 'NEUTRAL';
}
aewRows.push({
ticker: h.ticker,
name: h.name || '',
position_type: h.position_type || 'satellite',
entry_date: h.entry_date || '',
days_since_entry: daysSinceEntry,
satellite_return_pct: satRetPct,
core_benchmark_ret20d: coreRet20d,
core_benchmark_ret60d: coreRet60d,
t20_reached: t20Reached,
t20_vs_core_pctp: t20VsCorePctp,
t20_alpha_gate: t20AlphaGate,
t60_reached: t60Reached,
t60_vs_core_pctp: t60VsCorePctp,
t60_alpha_gate: t60AlphaGate,
// PROXY 경고: satRetPct는 진입~현재 총수익률; 코어 벤치마크는 20D/60D rolling
// 동일 기간 비교가 아니므로 진입 시점이 20~60일 이내인 경우 오차 있음
evaluation_method: 'PROXY_FROM_RETURN_PCT_VS_CORE_ROLLING',
formula_id: 'ALPHA_EVALUATION_WINDOW_V1'
});
});
return aewRows;
}
// ─────────────────────────────────────────────────────────────────────────────
// [2026-05-21_SPRINT_B] Sprint B — 4개 하네스 게이트
// ─────────────────────────────────────────────────────────────────────────────
// ── B-1: HARNESS_DATA_FRESHNESS_GATE_V1 ─────────────────────────────────────
// account_snapshot capturedAt 기준으로 영업일 신선도를 판정한다.
// STALE_BLOCK(5일+) → 주문표 생성 차단. STALE_WARN(3-4일) → SAQG ELIGIBLE 하향.
function calcHarnessDataFreshnessGate_(capturedAtIso, now) {
// capturedAtIso: "yyyy-MM-dd HH:mm:ss" or "yyyy-MM-dd" — 날짜만 추출
var marketDateStr = capturedAtIso ? String(capturedAtIso).substring(0, 10) : null;
if (!marketDateStr || !/^\d{4}-\d{2}-\d{2}$/.test(marketDateStr)) {
return {
data_freshness_status: 'UNKNOWN',
data_age_business_days: null,
market_date: null,
freshness_degraded_gates: ['ALL_GATES_UNCERTAIN'],
formula_id: 'HARNESS_DATA_FRESHNESS_GATE_V1'
};
}
var ageDays = calcKrxBizDaysDiff_(marketDateStr);
var status = ageDays <= 1 ? 'FRESH'
: ageDays === 2 ? 'STALE_1D'
: ageDays <= 4 ? 'STALE_WARN'
: 'STALE_BLOCK';
var degraded = [];
if (status === 'STALE_WARN') degraded = ['BRT_RELIABILITY_LOW', 'SAQG_ELIGIBLE_DOWNGRADE'];
if (status === 'STALE_BLOCK') degraded = ['BRT_BLOCKED', 'SAQG_BLOCKED', 'ORDER_GENERATION_BLOCKED'];
return {
data_freshness_status: status,
data_age_business_days: ageDays,
market_date: marketDateStr,
freshness_degraded_gates: degraded,
formula_id: 'HARNESS_DATA_FRESHNESS_GATE_V1'
};
}
// ── B-2: SATELLITE_LIFECYCLE_GATE_V1 ────────────────────────────────────────
// 위성 종목에 WATCH/PILOT/CONFIRMED/REVIEW/EXIT 5단계 라이프사이클을 부여한다.
// brt_verdict, composite_verdict, excess_drawdown_pctp, AEW t20_alpha_gate를 조합해
// 현재 상태에서 가장 적절한 단계를 결정론적으로 산출한다.
function calcSatelliteLifecycleGate_(holdings, dfMap, aewRows) {
var aewMap = {};
(aewRows || []).forEach(function(r) { if (r.ticker) aewMap[r.ticker] = r; });
return holdings.map(function(h) {
if (h.position_type === 'core') {
return {
ticker: h.ticker,
name: h.name || '',
position_type: 'core',
lifecycle_stage: 'CORE_EXEMPT',
lifecycle_transition_reason: 'core_position',
lifecycle_days_in_stage: null,
review_warning: null,
formula_id: 'SATELLITE_LIFECYCLE_GATE_V1'
};
}
var df = dfMap[h.ticker] || {};
var aew = aewMap[h.ticker] || {};
var cv = df.composite_verdict || 'UNKNOWN';
var brt = df.brt_verdict || 'UNKNOWN';
var exDd = typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null;
var t20g = aew.t20_alpha_gate || 'NOT_YET';
var t20v = typeof aew.t20_vs_core_pctp === 'number' ? aew.t20_vs_core_pctp : null;
var daysEntry = h.entry_date ? calcKrxBizDaysDiff_(h.entry_date) : null;
var stage = 'PILOT';
var reason = 'default_pilot';
// ── EXIT 조건 (최우선) ─────────────────────────────────────────────────
if (brt === 'BROKEN') {
stage = 'EXIT'; reason = 'brt_BROKEN';
} else if (cv === 'CLOSE_POSITION') {
stage = 'EXIT'; reason = 'composite_CLOSE_POSITION';
} else if (exDd !== null && exDd >= 15) {
stage = 'EXIT'; reason = 'excess_drawdown_15pct';
} else if (t20g === 'T20_ALPHA_FAIL' && t20v !== null && t20v < -10) {
stage = 'EXIT'; reason = 'T20_ALPHA_FAIL_severe';
// ── REVIEW 조건 ──────────────────────────────────────────────────────
} else if (brt === 'LAGGARD') {
stage = 'REVIEW'; reason = 'brt_LAGGARD';
} else if (cv === 'REDUCE_CANDIDATE') {
stage = 'REVIEW'; reason = 'composite_REDUCE';
} else if (t20g === 'T20_ALPHA_FAIL') {
stage = 'REVIEW'; reason = 'T20_ALPHA_FAIL';
} else if (exDd !== null && exDd >= 8) {
stage = 'REVIEW'; reason = 'excess_drawdown_8pct';
// ── CONFIRMED 조건 ─────────────────────────────────────────────────
} else if (daysEntry !== null && daysEntry >= 20
&& t20g === 'PASS'
&& (cv === 'PRIME_CANDIDATE' || cv === 'WATCH_CANDIDATE')
&& (brt === 'LEADER' || brt === 'MARKET')) {
stage = 'CONFIRMED'; reason = 't20_pass_market_or_leader';
// ── PILOT 조건 (기본) ───────────────────────────────────────────────
} else if (daysEntry !== null && daysEntry < 20) {
stage = 'PILOT'; reason = 'within_20d_of_entry';
} else {
stage = 'PILOT'; reason = 'pending_t20_evaluation';
}
// 4주 REVIEW 경보 (Direction SLG)
var reviewWarn = (stage === 'REVIEW' && daysEntry !== null && daysEntry >= 20)
? '4주_REVIEW_비중50%_감축검토' : null;
return {
ticker: h.ticker,
name: h.name || df.name || '',
position_type: h.position_type || 'satellite',
lifecycle_stage: stage,
lifecycle_transition_reason: reason,
lifecycle_days_in_stage: daysEntry,
review_warning: reviewWarn,
composite_verdict: cv,
brt_verdict: brt,
excess_drawdown_pctp: exDd,
t20_alpha_gate: t20g,
formula_id: 'SATELLITE_LIFECYCLE_GATE_V1'
};
});
}
// ── B-3: CLA_REGIME_EXIT_CONDITION_V1 ───────────────────────────────────────
// CONCENTRATED_LEADER_ADVANCE 국면의 종료 조건을 탐지한다.
// 삼성전자(005930) + SK하이닉스(000660)를 대상으로 5개 신호를 평가하고
// 가중치 합산으로 CLA_ACTIVE / CLA_EXIT_WARNING / CLA_EXIT_CONFIRMED를 결정한다.
/**
* SECULAR_LEADER_AUTO_DETECT_V1
* spec/strategy/semiconductor_concentration_policy.yaml 조건 기반
* 반도체 주도주 자동 감지 → SECULAR_LEADER_RISK_ON 국면 진입 권고.
*
* 감지 조건 (가중치 합산 ≥ 6 → is_secular_leader=true):
* SL1 (w=3): 삼성 또는 하이닉스 RS_Ratio ≥ 1.5 (5일 연속)
* SL2 (w=2): 외인+기관 동반순매수 3일 이상
* SL3 (w=2): 반도체 섹터 5일 수익률 KOSPI 대비 +5%p 이상 초과
* SL4 (w=1): 반도체 섹터 5D 거래대금 > 20D 거래대금 × 1.3
*
* @param {Object} dfMap — buildDataFeedMap_() 반환값
* @param {string} marketRegime
* @param {number} kospiRet5d — KOSPI 5일 수익률
* @return {{ is_secular_leader, score, signals, recommendation, formula_id }}
*/
function calcSecularLeaderAutoDetect_(dfMap, marketRegime, kospiRet5d) {
var SECULAR_TICKERS = ['005930', '000660'];
var THRESHOLD = 6;
var score = 0;
var signals = [];
var kospiRet = typeof kospiRet5d === 'number' ? kospiRet5d : 0;
// SL1: RS_Ratio ≥ 1.5 — 삼성 또는 하이닉스
var sl1Hit = SECULAR_TICKERS.some(function(tk) {
var df = dfMap[tk] || {};
var rsRatio = typeof df.rsRatio === 'number' ? df.rsRatio
: (typeof df.rs_ratio === 'number' ? df.rs_ratio : null);
return rsRatio !== null && rsRatio >= 1.5;
});
if (sl1Hit) { score += 3; signals.push('SL1_RS_RATIO_GTE_1.5(w=3)'); }
// SL2: 외인+기관 동반순매수 3일 이상 — 양 종목 중 하나
var sl2Hit = SECULAR_TICKERS.some(function(tk) {
var df = dfMap[tk] || {};
var frg = typeof df.frg5d === 'number' ? df.frg5d : -1;
var ins = typeof df.inst5d === 'number' ? df.inst5d : -1;
return frg > 0 && ins > 0; // 5일 누적 동반순매수 = 3일 이상 추정
});
if (sl2Hit) { score += 2; signals.push('SL2_FRG_INST_CO_BUY(w=2)'); }
// SL3: 반도체 섹터 5일 수익률 KOSPI 대비 +5%p 초과 (대표 종목 프록시)
var semiRet5d = null;
SECULAR_TICKERS.forEach(function(tk) {
var df = dfMap[tk] || {};
if (typeof df.ret5d === 'number' && (semiRet5d === null || df.ret5d > semiRet5d)) {
semiRet5d = df.ret5d;
}
});
if (semiRet5d !== null && semiRet5d - kospiRet >= 5.0) {
score += 2;
signals.push('SL3_SECTOR_OUTPERFORM_5PCT(w=2)');
}
// SL4: 반도체 섹터 거래대금 급증 (대표 종목 avgTradeVal5d/20d 프록시)
var sl4Hit = SECULAR_TICKERS.some(function(tk) {
var df = dfMap[tk] || {};
var val5 = toNumber_(df.avg_trade_val_5d || df.avgTradeVal5d) || 0;
var val20 = toNumber_(df.avg_trade_val_20d || df.avgTradeVal20d) || 0;
return val5 > 0 && val20 > 0 && val5 > val20 * 1.3;
});
if (sl4Hit) { score += 1; signals.push('SL4_TRADE_VALUE_SURGE(w=1)'); }
var isSecularLeader = score >= THRESHOLD;
var currentRegime = String(marketRegime || '').toUpperCase();
var alreadyActive = currentRegime.indexOf('SECULAR_LEADER') >= 0;
// 종료 조건: RS_Ratio < 1.0 3일 or 외인+기관 동반순매도 5일
var exitSignals = [];
SECULAR_TICKERS.forEach(function(tk) {
var df = dfMap[tk] || {};
var rsRatio = typeof df.rsRatio === 'number' ? df.rsRatio
: (typeof df.rs_ratio === 'number' ? df.rs_ratio : null);
if (rsRatio !== null && rsRatio < 1.0) exitSignals.push(tk + '_RS_BELOW_1.0');
var frg = typeof df.frg5d === 'number' ? df.frg5d : 0;
var ins = typeof df.inst5d === 'number' ? df.inst5d : 0;
if (frg < 0 && ins < 0) exitSignals.push(tk + '_CO_SELL');
});
return {
is_secular_leader: isSecularLeader,
score: score,
threshold: THRESHOLD,
signals: signals,
exit_signals: exitSignals,
already_active: alreadyActive,
recommendation: isSecularLeader && !alreadyActive
? 'UPGRADE_TO_SECULAR_LEADER_RISK_ON'
: (alreadyActive && exitSignals.length >= 2 ? 'EXIT_SECULAR_LEADER' : 'MAINTAIN'),
formula_id: 'SECULAR_LEADER_AUTO_DETECT_V1'
};
}
// --- Source: src/gas_adapter_parts/gdf_03_portfolio_gates.gs ---
function calcClaRegimeExitCondition_(dfMap, marketRegime) {
var regime = String(marketRegime || '').toUpperCase();
if (regime.indexOf('CONCENTRATED_LEADER') < 0 && regime.indexOf('CLA') < 0) {
return {
cla_exit_status: 'NOT_APPLICABLE',
cla_exit_signals_triggered: [],
cla_exit_total_weight: 0,
note: 'marketRegime not CLA',
formula_id: 'CLA_REGIME_EXIT_CONDITION_V1'
};
}
var sam = dfMap['005930'] || {};
var hyn = dfMap['000660'] || {};
var signals = [];
var w = 0;
// S1: RS 약화 — 삼성 또는 하이닉스 rs_verdict = LAGGARD (weight 3)
if (sam.rs_verdict === 'LAGGARD' || sam.rs_verdict === 'BROKEN'
|| hyn.rs_verdict === 'LAGGARD' || hyn.rs_verdict === 'BROKEN') {
signals.push('S1_rs_degradation'); w += 3;
}
// S2: KOSPI 기여도 하락 프록시 — 두 종목 모두 LEADER 아님 (weight 2)
if (sam.brt_verdict !== 'LEADER' && hyn.brt_verdict !== 'LEADER'
&& sam.brt_verdict !== 'UNKNOWN' && hyn.brt_verdict !== 'UNKNOWN') {
signals.push('S2_kospi_contribution_drop_proxy'); w += 2;
}
// S3: 외국인 동반 순매도 — frg5d < 0 두 종목 (weight 2)
var samFrgNeg = Number.isFinite(sam.frg5d) && sam.frg5d < 0;
var hynFrgNeg = Number.isFinite(hyn.frg5d) && hyn.frg5d < 0;
if (samFrgNeg && hynFrgNeg) {
signals.push('S3_foreign_flow_reversal'); w += 2;
}
// S4: 거래 에너지 소진 — volume < avgVolume5d*0.6 두 종목 (weight 1)
var samVolLow = Number.isFinite(sam.volume) && Number.isFinite(sam.avgVolume5d)
&& sam.avgVolume5d > 0 && sam.volume < sam.avgVolume5d * 0.6;
var hynVolLow = Number.isFinite(hyn.volume) && Number.isFinite(hyn.avgVolume5d)
&& hyn.avgVolume5d > 0 && hyn.volume < hyn.avgVolume5d * 0.6;
if (samVolLow && hynVolLow) {
signals.push('S4_volume_exhaustion'); w += 1;
}
// S5: BRT 약화 — 두 종목 모두 brt_verdict = MARKET (LEADER에서 하락) (weight 2)
if (sam.brt_verdict === 'MARKET' && hyn.brt_verdict === 'MARKET') {
signals.push('S5_brt_degradation_from_leader'); w += 2;
}
var status = w >= 5 ? 'CLA_EXIT_CONFIRMED'
: w >= 3 ? 'CLA_EXIT_WARNING'
: 'CLA_ACTIVE';
return {
cla_exit_status: status,
cla_exit_signals_triggered: signals,
cla_exit_total_weight: w,
samsung_rs: sam.rs_verdict || 'UNKNOWN',
samsung_brt: sam.brt_verdict || 'UNKNOWN',
hynix_rs: hyn.rs_verdict || 'UNKNOWN',
hynix_brt: hyn.brt_verdict || 'UNKNOWN',
formula_id: 'CLA_REGIME_EXIT_CONDITION_V1'
};
}
// ── B-4: PORTFOLIO_CORRELATION_GATE_V1 ──────────────────────────────────────
// 위성 포지션 간 ret20d 기반 프록시 상관관계를 산출하고,
// 상관관계 조정 실질 포트폴리오 베타(satellite_cluster_beta)를 계산한다.
// 20일 수익률 배열이 없으므로 방향 일치도로 상관관계를 추정(PROXY).
function calcPortfolioCorrelationGate_(holdings, dfMap, totalAsset, kospiRet5d) {
var satHoldings = holdings.filter(function(h) { return h.position_type !== 'core'; });
if (satHoldings.length === 0) {
return {
satellite_cluster_beta: 0,
effective_portfolio_beta: 0,
high_corr_pairs: [],
correlation_gate_status: 'CORRELATION_PASS',
note: 'no_satellite_holdings',
formula_id: 'PORTFOLIO_CORRELATION_GATE_V1'
};
}
// 각 위성의 beta_proxy 및 weight_pct 계산
var satItems = satHoldings.map(function(h) {
var df = dfMap[h.ticker] || {};
var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null;
var ret20d = typeof df.ret20d === 'number' ? df.ret20d : null;
// beta_proxy: ret5d / kospiRet5d if both available, else 1.0
var beta = 1.0;
if (ret5d !== null && typeof kospiRet5d === 'number' && Math.abs(kospiRet5d) > 0.3) {
beta = Math.max(0, Math.min(3.0, ret5d / kospiRet5d));
}
// weight_pct: from h.weightPct (set by calcPortfolioBetaGate pipeline) or derived
var mv = typeof h.market_value === 'number' ? h.market_value : 0;
var wPct = (totalAsset > 0 && mv > 0) ? mv / totalAsset * 100 : 0;
if (typeof h.weightPct === 'number' && h.weightPct > 0) wPct = h.weightPct;
return {
ticker: h.ticker,
name: h.name || df.name || '',
beta: round2_(beta),
wPct: round2_(wPct),
w: wPct / 100, // fraction
ret20d: ret20d,
rs: df.rs_verdict || 'UNKNOWN',
brt: df.brt_verdict || 'UNKNOWN'
};
});
// 프록시 상관관계: ret20d 방향 일치 + BRT 동방향 기반
function proxyCorrPair(a, b) {
if (a.ret20d !== null && b.ret20d !== null) {
var sameDir = (a.ret20d >= 0) === (b.ret20d >= 0);
var bothNeg = a.ret20d < 0 && b.ret20d < 0;
if (bothNeg) return 0.80; // 동반 하락 — 가장 강한 동조 신호
if (sameDir) return 0.65; // 같은 방향 수익
return 0.15; // 반대 방향 — 분산 효과
}
// 데이터 없으면 동업종 같은 BRT 상태이면 보수적으로 중간값
if (a.brt === b.brt && a.brt !== 'UNKNOWN') return 0.60;
return 0.35;
}
var highCorrPairs = [];
var totalSatW = satItems.reduce(function(s, x) { return s + x.w; }, 0);
if (totalSatW <= 0) totalSatW = 1;
// 정규화된 위성 비중 (위성 합산=1)
var satNorm = satItems.map(function(x) {
return Object.assign({}, x, { wn: x.w / totalSatW });
});
// 상관관계 행렬 및 satellite_cluster_beta (quadratic form → sqrt)
var quadForm = 0;
for (var i = 0; i < satNorm.length; i++) {
for (var j = 0; j < satNorm.length; j++) {
var corr = i === j ? 1.0 : proxyCorrPair(satNorm[i], satNorm[j]);
quadForm += satNorm[i].wn * satNorm[j].wn * satNorm[i].beta * satNorm[j].beta * corr;
if (i < j && corr >= 0.70) {
highCorrPairs.push({
ticker1: satNorm[i].ticker,
ticker2: satNorm[j].ticker,
corr_proxy: round2_(corr),
both_negative: satNorm[i].ret20d !== null && satNorm[j].ret20d !== null
&& satNorm[i].ret20d < 0 && satNorm[j].ret20d < 0
});
}
}
}
var satClusterBeta = round2_(Math.sqrt(Math.max(0, quadForm)));
// 코어 단순 가중 베타
var coreHoldings = holdings.filter(function(h) { return h.position_type === 'core'; });
var coreWBetaSum = 0, coreWSum = 0;
coreHoldings.forEach(function(h) {
var df = dfMap[h.ticker] || {};
var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null;
var beta = 1.0;
if (ret5d !== null && typeof kospiRet5d === 'number' && Math.abs(kospiRet5d) > 0.3) {
beta = Math.max(0, Math.min(3.0, ret5d / kospiRet5d));
}
var mv = typeof h.market_value === 'number' ? h.market_value : 0;
var w = (totalAsset > 0 && mv > 0) ? mv / totalAsset : 0;
if (typeof h.weightPct === 'number') w = h.weightPct / 100;
coreWBetaSum += w * beta;
coreWSum += w;
});
var coreBeta = coreWSum > 0 ? round2_(coreWBetaSum / coreWSum * (coreWSum / 1.0)) : 0;
// effective = core_weighted_contribution + satellite_cluster_beta * sat_weight_fraction
var effectiveBeta = round2_(coreBeta + satClusterBeta * totalSatW);
// 게이트 판정
var gateStatus = (satClusterBeta > 1.5 && highCorrPairs.length >= 2) ? 'CORRELATION_BLOCK'
: (satClusterBeta > 1.2 || highCorrPairs.length >= 1) ? 'CORRELATION_WARN'
: 'CORRELATION_PASS';
return {
satellite_cluster_beta: satClusterBeta,
effective_portfolio_beta: effectiveBeta,
high_corr_pairs: highCorrPairs,
correlation_gate_status: gateStatus,
satellite_count: satHoldings.length,
evaluation_method: 'PROXY_FROM_RET20D_DIRECTION',
formula_id: 'PORTFOLIO_CORRELATION_GATE_V1'
};
}
function pickReferenceBenchmarkRet5d_(df, fallbackKospiRet5d) {
var keys = [
['nasdaq_ret5d', 'NASDAQ'],
['nasdaqRet5d', 'NASDAQ'],
['kosdaq_ret5d', 'KOSDAQ'],
['kosdaqRet5d', 'KOSDAQ'],
['benchmark_ret5d', 'BENCHMARK'],
['benchmarkRet5d', 'BENCHMARK'],
['kospi_ret5d', 'KOSPI'],
['kospiRet5d', 'KOSPI']
];
for (var i = 0; i < keys.length; i++) {
var key = keys[i][0];
if (typeof (df || {})[key] === 'number') {
return { benchmark_ret5d: df[key], benchmark_used: keys[i][1] };
}
}
if (typeof fallbackKospiRet5d === 'number') {
return { benchmark_ret5d: fallbackKospiRet5d, benchmark_used: 'KOSPI' };
}
return { benchmark_ret5d: null, benchmark_used: 'UNKNOWN' };
}
function calcIndexRelativeHealthGate_(h, df, kospiRet5d) {
var stockRet5d = typeof df.ret5d === 'number' ? df.ret5d : null;
var bench = pickReferenceBenchmarkRet5d_(df, kospiRet5d);
var benchmarkRet5d = bench.benchmark_ret5d;
var benchmarkUsed = bench.benchmark_used;
var reasons = [];
var state = 'INSUFFICIENT_DATA';
var directionMatch = null;
var retGapPctp = null;
var magnitudeExcessPctp = null;
if (stockRet5d !== null && benchmarkRet5d !== null) {
directionMatch = (stockRet5d >= 0) === (benchmarkRet5d >= 0);
retGapPctp = round2_(stockRet5d - benchmarkRet5d);
magnitudeExcessPctp = round2_(Math.max(0, Math.abs(stockRet5d) - Math.abs(benchmarkRet5d) - 2));
var benchmarkAbs = Math.abs(benchmarkRet5d);
var stockAbs = Math.abs(stockRet5d);
if (!directionMatch && benchmarkAbs >= 1) {
state = 'DECOUPLED';
reasons.push('direction_mismatch');
} else if (stockRet5d < benchmarkRet5d - 3) {
state = 'UNDERPERFORMING';
reasons.push('underperform_vs_benchmark');
} else if (magnitudeExcessPctp >= 3 || (stockAbs >= benchmarkAbs + 4 && benchmarkAbs >= 1)) {
state = 'OVER_EXTENDED';
reasons.push('magnitude_excess');
} else {
state = 'HEALTHY';
}
} else {
reasons.push('insufficient_benchmark_data');
}
return {
ticker: h.ticker,
name: h.name || df.name || '',
benchmark_used: benchmarkUsed,
stock_ret5d: stockRet5d,
benchmark_ret5d: benchmarkRet5d,
ret_gap_pctp: retGapPctp,
magnitude_excess_pctp: magnitudeExcessPctp,
direction_match: directionMatch,
relative_health_state: state,
reason_codes: reasons,
formula_id: 'INDEX_RELATIVE_HEALTH_GATE_V1'
};
}
/**
* O3: PORTFOLIO_DRAWDOWN_GATE_V1
* 총자산 역대 고점(settings.portfolio_peak_krw) 대비 낙폭을 산출한다.
* -15% → DRAWDOWN_CAUTION, -20% → DRAWDOWN_FORCE_RISK_OFF.
* 현재 자산이 고점 초과 시 settings에 새 고점을 자동 기록.
* @param {number} totalAsset
* @param {Object} ss — Spreadsheet
* @param {Object} settings — readSettings_() 반환값
* @return {{ gate, drawdown_pct, peak_krw, current_krw }}
*/
function calcPortfolioDrawdownGate_(totalAsset, ss, settings) {
var peakKrw = toNumber_(settings['portfolio_peak_krw'] || 0);
if (totalAsset > 0 && totalAsset > peakKrw) {
peakKrw = totalAsset;
writeSettingValue_(ss, 'portfolio_peak_krw', totalAsset);
}
if (peakKrw <= 0 || totalAsset <= 0) {
return { gate: 'INSUFFICIENT_DATA', drawdown_pct: null, peak_krw: peakKrw || null, current_krw: Math.round(totalAsset || 0), formula_id: 'PORTFOLIO_DRAWDOWN_GATE_V1' };
}
var drawdownPct = round2_((peakKrw - totalAsset) / peakKrw * 100);
drawdownPct = Math.max(0, drawdownPct);
var gate = drawdownPct >= 20 ? 'DRAWDOWN_FORCE_RISK_OFF'
: drawdownPct >= 15 ? 'DRAWDOWN_CAUTION'
: 'PASS';
return { gate: gate, drawdown_pct: drawdownPct, peak_krw: Math.round(peakKrw), current_krw: Math.round(totalAsset), formula_id: 'PORTFOLIO_DRAWDOWN_GATE_V1' };
}
/**
* O4: WIN_LOSS_STREAK_GUARD_V1
* 최근 30거래 승률이 임계값 이하로 하락하면 신규 매수 비중을 축소한다.
* M1(연속 손절 횟수)과 독립적인 전체 승률 축 방어층.
* EDGE_CRITICAL(<30%): scale=0.25, EDGE_DEGRADED(<40%): scale=0.50,
* EDGE_WEAK(<45%): scale=0.75, EDGE_OK(>=45%): scale=1.0
* @param {Object} performance — readPerformanceSheet_() 반환값
* @return {{ state, win_rate_pct, trades_used, buy_scale }}
*/
function calcWinLossStreakGuard_(performance) {
var winRate = (performance && Number.isFinite(performance.win_rate_30)) ? performance.win_rate_30 : null;
var tradesUsed = (performance && Number.isFinite(performance.trades_used)) ? performance.trades_used : 0;
if (winRate === null || tradesUsed < 10) {
return { state: 'INSUFFICIENT_HISTORY', win_rate_pct: winRate !== null ? round2_(winRate * 100) : null, trades_used: tradesUsed, buy_scale: 1.0, formula_id: 'WIN_LOSS_STREAK_GUARD_V1' };
}
var state, scale;
if (winRate < 0.30) { state = 'EDGE_CRITICAL'; scale = 0.25; }
else if (winRate < 0.40) { state = 'EDGE_DEGRADED'; scale = 0.50; }
else if (winRate < 0.45) { state = 'EDGE_WEAK'; scale = 0.75; }
else { state = 'EDGE_OK'; scale = 1.0; }
return { state: state, win_rate_pct: round2_(winRate * 100), trades_used: tradesUsed, buy_scale: scale, formula_id: 'WIN_LOSS_STREAK_GUARD_V1' };
}
/**
* O5: POSITION_COUNT_LIMIT_V1
* 동시 보유 종목 수가 국면별 상한(NEUTRAL:8, RISK_OFF:6)을 초과하면 POSITION_COUNT_BLOCK.
* 과다 분산으로 인한 집중 모니터링 불가 및 Total Heat 과소 추정 방지.
* @param {Array} holdings
* @param {string} marketRegime
* @return {{ gate_status, position_count, max_count, excess_count }}
*/
function calcPositionCountLimit_(holdings, marketRegime) {
var r = String(marketRegime || '').toUpperCase();
var isRiskOff = r.indexOf('EVENT_SHOCK') >= 0 || r.indexOf('RISK_OFF') >= 0;
var maxCount = isRiskOff ? 6 : 8;
var count = holdings.length;
return {
gate_status: count > maxCount ? 'POSITION_COUNT_BLOCK' : 'PASS',
position_count: count,
max_count: maxCount,
excess_count: Math.max(0, count - maxCount),
formula_id: 'POSITION_COUNT_LIMIT_V1'
};
}
/**
* M1: DRAWDOWN_GUARD_V1
* 연속 손절 횟수에 따라 신규 매수 비중을 자동 축소한다.
* bayesian_multiplier=0(>=5회 연속 손실) 위에 추가 방어층으로 작동.
* @param {Object} performance — readPerformanceSheet_() 반환값
* @return {{ state, buy_scale, consecutive_losses, reason }}
*/
function calcDrawdownGuard_(performance) {
var consLoss = (performance && Number.isFinite(performance.consecutive_losses))
? performance.consecutive_losses : 0;
var state, scale, reason;
if (consLoss >= 5) {
state = 'NO_BUY'; scale = 0.0; reason = 'consecutive_losses>=5_no_bet';
} else if (consLoss >= 3) {
state = 'REDUCE_BUY'; scale = 0.5; reason = 'consecutive_losses>=3_reduce_50pct';
} else if (consLoss >= 2) {
state = 'CAUTION_BUY'; scale = 0.75; reason = 'consecutive_losses>=2_reduce_25pct';
} else {
state = 'NORMAL'; scale = 1.0; reason = 'no_drawdown';
}
return { state: state, buy_scale: scale, consecutive_losses: consLoss, reason: reason };
}
/**
* M2: PORTFOLIO_BETA_GATE_V1
* 보유 종목 가중평균 베타를 산출하고 국면별 상한과 비교한다.
* beta_proxy = ret5d / kospiRet5d (단, kospiRet5d <= 0이면 1.0 사용)
* @param {Array} holdings — parseAccountSnapshot_ 반환 holdings 배열
* @param {Object} dfMap — buildDataFeedMap_() 반환값
* @param {number} kospiRet5d
* @param {string} marketRegime
* @return {{ portfolio_beta, gate_status, beta_limit, per_holding_betas }}
*/
function calcPortfolioBetaGate_(holdings, dfMap, kospiRet5d, marketRegime) {
var BETA_LIMITS = (function(r) {
var rU = String(r || '').toUpperCase();
if (rU.indexOf('EVENT_SHOCK') >= 0) return { over: 0.7, warn: 0.5 };
if (rU.indexOf('RISK_OFF') >= 0) return { over: 0.8, warn: 0.6 };
if (rU.indexOf('SECULAR_LEADER') >= 0 && rU.indexOf('RISK_ON') >= 0) return { over: 1.5, warn: 1.2 };
if (rU.indexOf('RISK_ON') >= 0) return { over: 1.3, warn: 1.0 };
return { over: 1.0, warn: 0.8 }; // NEUTRAL
})(marketRegime);
var totalWeight = 0;
var weightedBetaSum = 0;
var perHolding = [];
holdings.forEach(function(h) {
var df = dfMap[h.ticker] || {};
var w = (typeof h.weightPct === 'number' && h.weightPct > 0) ? h.weightPct : 0;
var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null;
var betaProxy = 1.0;
if (ret5d !== null && typeof kospiRet5d === 'number' && kospiRet5d > 0.5) {
betaProxy = Math.max(0, Math.min(3.0, ret5d / kospiRet5d));
} else if (ret5d !== null && typeof kospiRet5d === 'number' && kospiRet5d < -0.5) {
betaProxy = Math.max(0, Math.min(3.0, ret5d / kospiRet5d));
}
totalWeight += w;
weightedBetaSum += w * betaProxy;
perHolding.push({
ticker: h.ticker,
name: h.name || '',
weight_pct: w,
beta_proxy: round2_(betaProxy),
ret5d: ret5d
});
});
var portfolioBeta = totalWeight > 0 ? round2_(weightedBetaSum / totalWeight) : null;
var gateStatus = portfolioBeta === null ? 'INSUFFICIENT_DATA'
: portfolioBeta > BETA_LIMITS.over ? 'OVER_BETA'
: portfolioBeta > BETA_LIMITS.warn ? 'WARN_BETA'
: 'PASS';
return {
portfolio_beta: portfolioBeta,
gate_status: gateStatus,
beta_limit_over: BETA_LIMITS.over,
beta_limit_warn: BETA_LIMITS.warn,
regime_applied: marketRegime || 'UNKNOWN',
per_holding_betas: perHolding
};
}
/**
* M5: SECTOR_CONCENTRATION_LIMIT_V1
* 단일 섹터 ≥40% 시 BLOCK_SECTOR, 상위2 합산 ≥65% 시 HALVE_SECTOR.
* @param {Array} holdings
* @param {string} marketRegime
* @return {{ gate_status, by_sector, sector_concentration_json }}
*/
function calcSectorConcentrationGate_(holdings, marketRegime) {
var sectorWeight = {};
holdings.forEach(function(h) {
var sec = TICKER_SECTOR_MAP[h.ticker] || 'UNKNOWN';
var w = (typeof h.weightPct === 'number' && h.weightPct > 0) ? h.weightPct : 0;
sectorWeight[sec] = (sectorWeight[sec] || 0) + w;
});
var sectors = Object.keys(sectorWeight).map(function(s) {
return { sector: s, weight_pct: round2_(sectorWeight[s]) };
});
sectors.sort(function(a, b) { return b.weight_pct - a.weight_pct; });
// 임계값 — RISK_OFF/EVENT_SHOCK에서는 더 엄격
var rU = String(marketRegime || '').toUpperCase();
var blockThresh = (rU.indexOf('EVENT_SHOCK') >= 0 || rU.indexOf('RISK_OFF') >= 0) ? 35 : 40;
var halveThresh = (rU.indexOf('EVENT_SHOCK') >= 0 || rU.indexOf('RISK_OFF') >= 0) ? 55 : 65;
var top2Sum = sectors.slice(0, 2).reduce(function(s, r) { return s + r.weight_pct; }, 0);
var overallGate = 'PASS';
sectors.forEach(function(r) {
if (r.weight_pct >= blockThresh) r.gate = 'BLOCK_NEW_BUY_THIS_SECTOR';
else if (r.weight_pct >= halveThresh * 0.6) r.gate = 'WARN_CONCENTRATION';
else r.gate = 'PASS';
if (r.gate === 'BLOCK_NEW_BUY_THIS_SECTOR') overallGate = 'BLOCK_SECTOR';
});
if (overallGate === 'PASS' && top2Sum >= halveThresh) overallGate = 'WARN_TOP2';
return {
gate_status: overallGate,
top2_weight_sum: round2_(top2Sum),
block_threshold: blockThresh,
by_sector: sectors
};
}
/**
* M4: EVENT_RISK_HOLD_GATE_V1
* DART 리스크 및 이벤트 홀드 기간 중인 종목에 신규 매수 홀드 게이트 적용.
* df.eventHoldDays (Event_Hold_Days 컬럼) <= 5이면 EVENT_HOLD.
* 컬럼 없으면 df.dartRiskStatus !== 'OK' 를 대체 기준으로 사용.
* @param {Array} holdings
* @param {Object} dfMap
* @return {Array} event_risk rows
*/
function calcEventRiskHoldGate_(holdings, dfMap) {
return holdings.map(function(h) {
var df = dfMap[h.ticker] || {};
var holdDays = typeof df.eventHoldDays === 'number' ? df.eventHoldDays : null;
var dartRisk = (typeof df.dartRiskStatus === 'string' && df.dartRiskStatus !== 'OK')
|| String(df.dartRisk || '').toUpperCase() === 'Y';
var gateStatus, reason;
if (holdDays !== null && holdDays >= 0 && holdDays <= 5) {
gateStatus = 'EVENT_HOLD';
reason = 'event_hold_days_le5:' + holdDays;
} else if (dartRisk) {
gateStatus = 'EVENT_HOLD';
reason = 'dart_risk';
} else {
gateStatus = 'PASS';
reason = 'no_event_risk';
}
return {
ticker: h.ticker,
name: h.name || '',
event_hold_gate: gateStatus,
event_hold_days: holdDays,
dart_risk: dartRisk,
reason: reason
};
});
}
/**
* M3: TP_QUANTITY_LADDER_V1
* prices_json의 TP1/TP2/TP3 가격 유효성 기반으로 분할 익절 수량을 자동 산출.
* 계좌 snapshot에 수동 입력(tp1_qty>0)이 있으면 우선 사용.
* @param {Array} holdings
* @param {Object} h4 — calcPrices_() 반환값 (.prices 배열)
* @return {Array} tp_quantity_ladder rows
*/
function calcTpQuantityLadder_(holdings, h4) {
// THIN_ADAPTER: [sizing/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_position_size
var priceMap = {};
(h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; });
return holdings.map(function(h) {
var priceRow = priceMap[h.ticker] || {};
var qty = h.holdingQty || 0;
// 수동 입력 tp_qty 있으면 우선 사용
var tp1Manual = typeof priceRow.tp1_qty === 'number' && priceRow.tp1_qty > 0 ? priceRow.tp1_qty : 0;
var tp2Manual = typeof priceRow.tp2_qty === 'number' && priceRow.tp2_qty > 0 ? priceRow.tp2_qty : 0;
var tp3Manual = typeof priceRow.tp3_qty === 'number' && priceRow.tp3_qty > 0 ? priceRow.tp3_qty : 0;
var tp1Q, tp2Q, tp3Q, source;
if (tp1Manual > 0 && tp2Manual > 0) {
tp1Q = tp1Manual;
tp2Q = tp2Manual;
tp3Q = tp3Manual > 0 ? tp3Manual : Math.max(0, qty - tp1Q - tp2Q);
source = 'MANUAL';
} else if (qty > 0) {
tp1Q = Math.floor(qty * 0.33);
tp2Q = Math.floor(qty * 0.33);
tp3Q = Math.max(0, qty - tp1Q - tp2Q);
source = 'AUTO_33PCT';
} else {
tp1Q = tp2Q = tp3Q = 0;
source = 'NO_HOLDING';
}
return {
ticker: h.ticker,
name: h.name || '',
holding_qty: qty,
tp1_price: priceRow.tp1_price || null,
tp1_state: priceRow.tp1_state || null,
tp1_qty: tp1Q,
tp2_price: priceRow.tp2_price || null,
tp2_state: priceRow.tp2_state || null,
tp2_qty: tp2Q,
tp3_qty: tp3Q,
qty_source: source,
formula_id: 'TP_QUANTITY_LADDER_V1'
};
});
}
function calcCashFloor_(mrsScore, settlementCashPct) {
var minPct = 10;
var regime = 'overheated_or_event_week';
for (var k = 0; k < CASH_FLOOR_BY_MRS.length; k++) {
if (mrsScore <= CASH_FLOOR_BY_MRS[k].maxMrs) {
minPct = CASH_FLOOR_BY_MRS[k].minPct;
regime = CASH_FLOOR_BY_MRS[k].label;
break;
}
}
var status = settlementCashPct >= minPct ? 'PASS'
: settlementCashPct >= minPct * 0.7 ? 'TRIM_REQUIRED'
: 'HARD_BLOCK';
return { minPct: minPct, regime: regime, status: status };
}
function calcActions_(intradayLock, heatGate, cashFloorStatus) {
var blocked = [];
var allowed = ['TRIM_25', 'TRIM_33', 'TRIM_50', 'HOLD', 'WATCH'];
if (intradayLock) {
blocked.push('EXIT_100', 'SELL_FULL', 'EXIT_FULL', 'BUY', 'STAGED_BUY');
} else {
allowed.push('EXIT_100', 'SELL_FULL');
if (heatGate === 'BLOCK_NEW_BUY' || cashFloorStatus !== 'PASS') {
blocked.push('BUY', 'STAGED_BUY');
} else {
allowed.push('BUY', 'STAGED_BUY');
}
}
return { allowed: allowed, blocked: blocked };
}
// ── H2: 매도후보 순위 하네스 ─────────────────────────────────────────────────
/**
* calcSellPriority_
* 보유 종목별 Sell_Priority_Score(0~100 clamp) + tier 배정, tier ASC / score DESC 정렬
* spec/risk/portfolio_exposure.yaml:candidate_scoring
*/
function calcSellPriority_(holdings, dfMap, h1) {
var candidates = [];
holdings.forEach(function(h) {
var df = dfMap[h.ticker] || {};
var raw = scoreSellCandidate_(h, df, h1);
// 코어 주도주 tier=9 고정
var isCoreLeader = indexOfArr_(CORE_TICKERS, h.ticker) >= 0;
var tier = isCoreLeader ? 9 : raw.tier;
var clamped = Math.min(Math.max(raw.rawScore, 0), 100);
candidates.push({
rank: 0, // 정렬 후 부여
ticker: h.ticker,
name: h.name || df.name || '',
account: h.account || '',
tier: tier,
score: clamped,
raw_score: raw.rawScore,
rebound_holdback_score: raw.reboundHoldback || 0,
rebound_holdback_reason: raw.reboundReason || '',
cash_preserve_style: raw.cashPreserveStyle || '',
cash_preserve_ratio: raw.cashPreserveRatio || 0,
cash_preserve_reason: raw.cashPreserveReason || '',
trim_style: isCoreLeader && df.close > 0 && df.ma20 > 0 && df.close >= df.ma20
? 'CORE_LAST'
: (raw.reboundHoldback || 0) >= 18 ? 'STEP_25' : (raw.reboundHoldback || 0) >= 10 ? 'STEP_33' : 'STEP_50',
clamp_applied: raw.rawScore !== clamped,
clamp_label: raw.rawScore !== clamped
? ('[CLAMP 발동: raw=' + raw.rawScore + ' → ' + clamped + ']') : '',
reason: isCoreLeader ? '코어주도주보호(tier=9 고정)' : raw.reason,
stop_breach: h.stopBreach,
weight_pct: h.weightPct,
final_action: df.finalAction || ''
});
});
// tier ASC, score DESC 정렬
candidates.sort(function(a, b) {
if (a.tier !== b.tier) return a.tier - b.tier;
return b.score - a.score;
});
candidates.forEach(function(c, idx) { c.rank = idx + 1; });
return { candidates: candidates, lock: true };
}
/**
* scoreSellCandidate_
* 단일 종목 원시점수 + tier 계산
* spec/risk/portfolio_exposure.yaml:candidate_scoring.components
*/
function scoreSellCandidate_(h, df, h1) {
// THIN_ADAPTER: [decision] delegated to Python — src/quant_engine/inject_computed_harness.py:check_sanity
var pts = 0;
var reasons = [];
var tier = 7; // 기본: 단순 수익실현
var reboundHoldback = calcReboundHoldbackScore_({
close: h.close,
ma20: df.ma20,
ma60: df.ma60,
ma20Slope: df.ma20Slope,
rsi14: df.rsi14,
bbPosition: df.bbPosition,
flowCredit: df.flowCredit,
leaderTotal: df.leaderTotal,
leaderGate: df.leaderGate,
bandStatus: df.bandStatus,
profitPct: df.profitPct,
isCoreLeader: indexOfArr_(CORE_TICKERS, h.ticker) >= 0,
});
// ── 1. hard_precedence ────────────────────────────────────────────────────
if (h.stopBreach) {
pts += SP.HARD_STOP_BREACH;
tier = Math.min(tier, 2);
reasons.push('stop_breach(' + SP.HARD_STOP_BREACH + ')');
} else if (h1.cashFloorStatus === 'TRIM_REQUIRED' || h1.cashFloorStatus === 'HARD_BLOCK') {
pts += SP.CASH_FLOOR_TRIM;
tier = Math.min(tier, 3);
reasons.push('cash_floor_trim(' + SP.CASH_FLOOR_TRIM + ')');
} else if (df.isDuplicateEtf) {
pts += SP.DUPLICATE_ETF;
tier = Math.min(tier, 4);
reasons.push('duplicate_etf(' + SP.DUPLICATE_ETF + ')');
} else {
var fa = (df.finalAction || '').toUpperCase();
if (fa.indexOf('TRIM') >= 0 || fa.indexOf('ROTATE') >= 0
|| fa.indexOf('SELL') >= 0 || fa.indexOf('EXIT') >= 0) {
pts += SP.HOLDING_TRIM_ROTATE;
tier = Math.min(tier, 5);
reasons.push('holding_trim(' + SP.HOLDING_TRIM_ROTATE + ')');
} else {
var profitLockBase = SP["TAKE_PROFIT_BASE"];
pts += profitLockBase;
tier = Math.min(tier, 6);
reasons.push('profit_lock_base(' + profitLockBase + ')');
}
}
// ── 2. duplicate_exposure_points ─────────────────────────────────────────
if (df.isDuplicateEtf) {
pts += SP.DUP_SAME_SECTOR;
reasons.push('dup_sector_etf(' + SP.DUP_SAME_SECTOR + ')');
}
// ── 3. cash_relief_points ─────────────────────────────────────────────────
if (h1.totalAsset > 0 && h.marketValue > 0) {
var reliefPct = h.marketValue / h1.totalAsset * 100;
if (reliefPct >= 3) {
pts += SP.CASH_RELIEF_GE3;
reasons.push('cash_relief>=3%(' + SP.CASH_RELIEF_GE3 + ')');
} else if (reliefPct >= 1) {
pts += SP.CASH_RELIEF_1_3;
reasons.push('cash_relief1~3%(' + SP.CASH_RELIEF_1_3 + ')');
} else {
pts += SP.CASH_RELIEF_LT1;
reasons.push('cash_relief<1%(' + SP.CASH_RELIEF_LT1 + ')');
}
}
// ── 4. weakness_points ────────────────────────────────────────────────────
var rw = df.rwPartial || 0;
if (rw >= 4) { pts += SP.RW_GE4; reasons.push('RW>=' + rw + '(' + SP.RW_GE4 + ')'); }
else if (rw === 3) { pts += SP.RW_3; reasons.push('RW=3(' + SP.RW_3 + ')'); }
else if (rw === 2) { pts += SP.RW_2; reasons.push('RW=2(' + SP.RW_2 + ')'); }
var flowOk = (df.flowOk || '').toUpperCase();
var flowCr = df.flowCredit;
if ((typeof flowCr === 'number' && flowCr < 0.5)
|| flowOk === 'N' || flowOk === 'FALSE' || flowOk === '0') {
pts += SP.FLOW_NEGATIVE;
reasons.push('flow_neg(' + SP.FLOW_NEGATIVE + ')');
}
if (h.close > 0 && df.ma20 > 0 && h.close < df.ma20) {
pts += SP.BELOW_MA20;
reasons.push('below_MA20(' + SP.BELOW_MA20 + ')');
}
// ── 5. overweight_points ──────────────────────────────────────────────────
if (df.weightTargetPct > 0 && h.weightPct > 0) {
var overPct = h.weightPct - df.weightTargetPct;
if (overPct >= 5) { pts += SP.OVERWEIGHT_5P; reasons.push('overweight>5p(' + SP.OVERWEIGHT_5P + ')'); }
else if (overPct >= 2) { pts += SP.OVERWEIGHT_2P; reasons.push('overweight>2p(' + SP.OVERWEIGHT_2P + ')'); }
}
// ── 6. liquidity_points ───────────────────────────────────────────────────
var atv = df.avgTradeVal5d || 0;
if (atv >= 1000) { // 10억원 이상 (단위: 백만원)
pts += SP.LIQUIDITY_OK; reasons.push('liq_ok(' + SP.LIQUIDITY_OK + ')');
} else if (atv > 0 && atv < 100) { // 1억원 미만
pts += SP.LIQUIDITY_LOW; reasons.push('liq_low(' + SP.LIQUIDITY_LOW + ')');
}
// ── 7. tax_penalty_points (미확인 기본) ────────────────────────────────────
pts -= SP.TAX_UNKNOWN;
reasons.push('tax_unknown(-' + SP.TAX_UNKNOWN + ')');
// ── 8. core_quality_protection_points (감점) ──────────────────────────────
if (indexOfArr_(CORE_TICKERS, h.ticker) >= 0) {
pts -= SP.CORE_LEADER;
reasons.push('core_leader(-' + SP.CORE_LEADER + ')');
} else if ((df.grade || '').toUpperCase() === 'A') {
pts -= SP.A_GRADE_CORE;
reasons.push('A_grade(-' + SP.A_GRADE_CORE + ')');
}
if (reboundHoldback.score > 0) {
pts -= reboundHoldback.score;
reasons.push('rebound_holdback(-' + reboundHoldback.score + (reboundHoldback.reasons ? ' [' + reboundHoldback.reasons + ']' : '') + ')');
}
var cashPreservePlan = calcCashPreservationPlan_({
sellAction: h.finalAction || df.finalAction || '',
cashFloorStatus: h1.cashFloorStatus || '',
regime: h1.regime || df.regime || '',
isCoreLeader: indexOfArr_(CORE_TICKERS, h.ticker) >= 0,
isEtf: !!df.isDuplicateEtf,
liquidityStatus: String(df.liquidityStatus || df.Liquidity_Status || ''),
spreadStatus: String(df.spreadStatus || df.Spread_Status || ''),
accountType: String(h.account_type || h.accountType || ''),
profitPct: h.profitPct,
rwPartial: rw,
reboundHoldbackScore: reboundHoldback.score,
});
if (cashPreservePlan.protection_bonus > 0) {
pts -= cashPreservePlan.protection_bonus;
reasons.push('cash_preserve(-' + cashPreservePlan.protection_bonus + (cashPreservePlan.reasons ? ' [' + cashPreservePlan.reasons + ']' : '') + ')');
}
return {
rawScore: Math.round(Math.min(100, Math.max(0, pts))),
tier: tier,
reason: reasons.join(', '),
reboundHoldback: reboundHoldback.score,
reboundReason: reboundHoldback.reasons,
cashPreserveStyle: cashPreservePlan.style,
cashPreserveRatio: cashPreservePlan.recommended_ratio,
cashPreserveReason: cashPreservePlan.reasons,
};
}
// ── H3: 수량 하네스 ──────────────────────────────────────────────────────────
/**
* calcQuantities_
* Sell_Qty = floor(Sell_Ratio_Pct/100 × holding_quantity) — CAPTURE_READ_OK만
* Buy_Qty: POSITION_SIZE_V1 atr_qty·cash_limit_qty 중간값 산출
*/
function calcQuantities_(holdings, dfMap, totalAsset, buyPowerKrw, h1) {
var sellQty = [];
var buyQtyInputs = [];
var perfMult = Number.isFinite(h1.performanceMultiplier) ? h1.performanceMultiplier : 0.5;
var perfBias = h1.performanceBuyBias || calcPerformanceBuyBias_({ bayesian_multiplier: perfMult });
holdings.forEach(function(h) {
var df = dfMap[h.ticker] || {};
var sellRatio = df.sellRatioPct || 0;
var fa = (df.finalAction || '').toUpperCase();
var hasSellSignal = fa === 'SELL_READY' || fa === 'EXIT_100' || fa === 'EXIT_SIGNAL'
|| fa === 'EXIT_REVIEW' || fa.indexOf('TRIM') >= 0 || fa === 'TRAILING_STOP_BREACH';
var sellQtyValue;
// ── Sell_Qty (M3: 선행 계산된 Sell_Qty 컬럼 우선 사용) ───────────────
if (h.holdingQty > 0 && hasSellSignal) {
if (df.sellQty > 0) {
// 데이터 피드에 이미 계산된 정수 수량 사용 (CAPTURE_READ_OK 기반)
sellQtyValue = Math.floor(df.sellQty);
} else if (sellRatio > 0) {
sellQtyValue = Math.floor(sellRatio / 100 * h.holdingQty);
} else {
sellQtyValue = 'CAPTURE_REQUIRED'; // 매도신호 있으나 수량 산출 불가
}
} else if (h.holdingQty > 0) {
sellQtyValue = null; // 매도신호 없음 — CAPTURE_REQUIRED 오남용 방지
} else {
sellQtyValue = 'NO_HOLDING';
}
sellQty.push({
ticker: h.ticker,
account: h.account || '',
name: h.name || df.name || '',
holding_qty: h.holdingQty || 0,
sell_ratio_pct: sellRatio || 0,
sell_qty: sellQtyValue
});
// ── Buy_Qty (BUY 후보이고 gates 통과 시만 산출) ───────────────────────
var fa = (df.finalAction || '').toUpperCase();
var isBuyCandidate = fa.indexOf('BUY') >= 0 || fa.indexOf('STAGED') >= 0;
var buyBlocked = h1.heatGate === 'BLOCK_NEW_BUY'
|| h1.cashFloorStatus !== 'PASS'
|| h1.intradayLock
|| perfBias.entry_block;
if (!isBuyCandidate || buyBlocked) return;
var atr20 = df.atr20 || 0;
var close = h.close || df.close || 0;
if (atr20 > 0 && close > 0 && totalAsset > 0) {
var riskKrw = totalAsset * BASE_RISK_BUDGET * perfMult;
riskKrw = riskKrw * perfBias.quantity_multiplier;
var atrQty = Math.floor(riskKrw / (atr20 * 1.5));
var cashQty = Math.floor(buyPowerKrw / close);
var halve = h1.heatGate === 'HALVE_NEW_BUY_QUANTITY';
if (halve) atrQty = Math.floor(atrQty / 2);
// M1: DRAWDOWN_GUARD_V1 추가 축소
var dgScale = (h1.drawdownBuyScale !== undefined && h1.drawdownBuyScale < 1.0)
? h1.drawdownBuyScale : 1.0;
if (dgScale < 1.0) atrQty = Math.floor(atrQty * dgScale);
// N1: POSITION_SIZE_REGIME_SCALE_V1 국면 스케일
var rssScale = (typeof h1.regimeSizeScale === 'number') ? h1.regimeSizeScale : 1.0;
if (rssScale !== 1.0) atrQty = Math.floor(atrQty * rssScale);
// O4: WIN_LOSS_STREAK_GUARD_V1 승률 하락 시 추가 축소
var wlScale = (typeof h1.winLossStreakBuyScale === 'number') ? h1.winLossStreakBuyScale : 1.0;
if (wlScale < 1.0) atrQty = Math.floor(atrQty * wlScale);
buyQtyInputs.push({
ticker: h.ticker,
account: h.account || '',
name: h.name || df.name || '',
atr_qty: atrQty,
cash_limit_qty: cashQty,
final_qty: Math.min(atrQty, cashQty),
atr20: atr20,
close: close,
halve_applied: halve,
perf_bias_reason: perfBias.reason,
perf_bias_mult: perfBias.quantity_multiplier,
missing: []
});
} else {
var missing = [];
if (!atr20) missing.push('ATR20');
if (!close) missing.push('Close_Price');
if (!totalAsset) missing.push('total_asset');
buyQtyInputs.push({
ticker: h.ticker,
account: h.account || '',
name: h.name || df.name || '',
final_qty: 'NO_BUY_QUANTITY',
missing: missing
});
}
});
return { sellQty: sellQty, buyQtyInputs: buyQtyInputs };
}
// ── H4: 가격 하네스 ──────────────────────────────────────────────────────────
/**
* calcPrices_
* 보유 종목별:
* STOP_PRICE_CORE_V1 → TICK_NORMALIZER_V1
* TAKE_PROFIT_LADDER_V2 (tier1/tier2) → TICK_NORMALIZER_V1
*/
function calcPrices_(holdings, dfMap, marketRegime) {
// THIN_ADAPTER: [stop_loss/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:compute_stop_price_core
var prices = [];
holdings.forEach(function(h) {
var df = dfMap[h.ticker] || {};
var atr20 = df.atr20 || 0;
var close = h.close || df.close || 0;
var avgCost = h.avgCost || 0;
var qty = h.holdingQty || 0;
if (avgCost <= 0) {
prices.push({
ticker: h.ticker,
account: h.account || '',
name: h.name || df.name || '',
error: 'NO_AVG_COST'
});
return;
}
var posClass = (df.positionClass || '').toLowerCase();
var isCore = posClass === 'core' || posClass === 'core_leader'
|| indexOfArr_(CORE_TICKERS, h.ticker) >= 0;
// ── STOP_PRICE_CORE_V1 ────────────────────────────────────────────────
// max(avgCost * 0.92, avgCost - ATR20 * atr_multiplier)
// atr_multiplier = 2.0 if atr20/close*100 >= 8, else 1.5
var atrMul = 1.5;
var stopRaw;
if (atr20 > 0 && close > 0) {
atrMul = (atr20 / close * 100) >= 8 ? 2.0 : 1.5;
stopRaw = Math.max(avgCost * 0.92, avgCost - atr20 * atrMul);
} else {
stopRaw = avgCost * 0.92;
}
var stopTick = tickNormalize_(stopRaw);
// ── X4: ATR Ratchet (atr_early_ratchet + atr_trailing_universal) ─────────
// highest_price_since_entry 우선 사용 (account_snapshot 컬럼).
// 미입력 시 close 로 폴백 (일일 마감 기준 보수적 처리).
var maxPriceRef = (h.highestPriceSinceEntry && h.highestPriceSinceEntry > close)
? h.highestPriceSinceEntry : close;
var ratchetApplied = 'NONE';
var ratchetNote = '';
var ratchetSrc = h.highestPriceSinceEntry ? 'highest_price_since_entry' : 'close_fallback';
if (atr20 > 0 && maxPriceRef > 0 && avgCost > 0) {
var earlyTrigger = avgCost + atr20 * 1.0;
var trailingStopRaw = Math.max(maxPriceRef - atr20 * 2.0, 0);
var trailingStopTick = tickNormalize_(trailingStopRaw);
var breakevenTick = tickNormalize_(avgCost);
if (maxPriceRef >= earlyTrigger) {
// 조기 본절 발동: stop >= breakeven
var ratchetedStop = Math.max(stopTick, trailingStopTick, breakevenTick);
if (ratchetedStop > stopTick) {
ratchetNote = 'early_ratchet[' + ratchetSrc + ']: max(' + maxPriceRef + ')>=avgCost+ATR(' + Math.round(earlyTrigger)
+ ') → stop_floor=breakeven(' + breakevenTick + ')'
+ ' | trailing=' + trailingStopTick;
stopTick = ratchetedStop;
ratchetApplied = 'EARLY_RATCHET+TRAILING';
} else {
ratchetNote = 'early_ratchet_inactive: stop already>=' + stopTick;
ratchetApplied = 'EARLY_RATCHET_INACTIVE';
}
} else if (trailingStopTick > stopTick) {
// 조기 본절 미발동, 트레일링만 적용
ratchetNote = 'trailing_only[' + ratchetSrc + ']: max-ATR*2=' + trailingStopTick + '>stop_core=' + stopTick;
stopTick = trailingStopTick;
ratchetApplied = 'TRAILING_ONLY';
} else {
ratchetApplied = 'PASS (stop_core >= trailing)';
ratchetNote = 'trailing=' + trailingStopTick + ' <= stop_core=' + stopTick;
}
} else {
ratchetApplied = 'SKIP (atr/close/avgCost 부재)';
}
// ── TAKE_PROFIT_LADDER_V2 ─────────────────────────────────────────────
// tier_1: max(avgCost * pct1, avgCost + ATR20 * 1.5)
// tier_2: max(avgCost * pct2, avgCost + ATR20 * 3.0)
var pct1 = isCore ? 1.15 : 1.10;
var pct2 = isCore ? 1.25 : 1.20;
var tp1Raw, tp2Raw, ladderVer;
if (atr20 > 0) {
tp1Raw = Math.max(avgCost * pct1, avgCost + atr20 * 1.5);
tp2Raw = Math.max(avgCost * pct2, avgCost + atr20 * 3.0);
ladderVer = 'V2_ATR';
} else {
tp1Raw = avgCost * pct1;
tp2Raw = avgCost * pct2;
ladderVer = 'V1_FALLBACK';
}
var tp1Tick = tickNormalize_(tp1Raw);
var tp2Tick = tickNormalize_(tp2Raw);
// ── PROFIT_LOCK_STAGE_CLASSIFIER_V1 ──────────────────────────────────────
// spec/exit/take_profit.yaml:profit_lock_ratchet.ratchet_table 기준
// 수익률 구간별 단계 분류 — LLM이 임의 판정하는 것을 하네스에서 선점 (Direction Q 준수)
var profitPct = (close > 0 && avgCost > 0) ? (close - avgCost) / avgCost * 100 : 0;
// spec/13_formula_registry.yaml:PROFIT_LOCK_STAGE_V1 단계명 기준 (B06 정정 2026-05-30)
var profitLockStage, ratchetStopOverride, ratchetPartialQty;
if (profitPct >= 60) {
profitLockStage = 'APEX_SUPER';
ratchetStopOverride = tickNormalize_(
Math.max(avgCost * 1.40, atr20 > 0 ? close - atr20 * 1.2 : avgCost * 1.40)
);
ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.50) : 0;
} else if (profitPct >= 40) {
profitLockStage = 'APEX_TRAILING';
ratchetStopOverride = tickNormalize_(
Math.max(avgCost * 1.35, atr20 > 0 ? close - atr20 * 1.5 : avgCost * 1.35)
);
ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.40) : 0;
} else if (profitPct >= 30) {
profitLockStage = 'PROFIT_LOCK_30';
ratchetStopOverride = tickNormalize_(avgCost * 1.20);
ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.35) : 0;
} else if (profitPct >= 20) {
profitLockStage = 'PROFIT_LOCK_20';
ratchetStopOverride = tickNormalize_(avgCost * 1.10);
ratchetPartialQty = qty > 0 ? Math.floor(qty * 0.25) : 0;
} else if (profitPct >= 10) {
profitLockStage = 'PROFIT_LOCK_10';
ratchetStopOverride = tickNormalize_(avgCost * 1.00);
ratchetPartialQty = 0;
} else if (profitPct >= 0) {
profitLockStage = 'BREAKEVEN_RATCHET';
ratchetStopOverride = tickNormalize_(avgCost);
ratchetPartialQty = 0;
} else {
profitLockStage = 'NORMAL';
ratchetStopOverride = null;
ratchetPartialQty = 0;
}
// profit_lock_ratchet 손절선이 기존 손절선보다 높으면 적용 (PROFIT_LOCK_RATCHET_V1)
if (ratchetStopOverride && ratchetStopOverride > stopTick) {
stopTick = ratchetStopOverride;
if (ratchetApplied === 'NONE' || ratchetApplied === 'SKIP (atr/close/avgCost 부재)') {
ratchetApplied = 'PROFIT_LOCK_RATCHET';
ratchetNote = 'profit_lock_stage=' + profitLockStage + ' → stop→' + stopTick;
}
}
// ── TP_VALIDITY_CHECK_V1: 현재가 이하 TP는 무효화 (HS009) ─────────────────
// tp_price <= close 이면 INVALID_TP_STALE — LLM에 null 전달하여 오표기 원천 차단
var tp1State, tp2State;
if (close > 0) {
tp1State = tp1Tick > close ? 'PENDING' : 'TP1_ALREADY_TRIGGERED';
tp2State = tp2Tick > close ? 'PENDING' : 'TP2_ALREADY_TRIGGERED';
if (tp1State !== 'PENDING') tp1Tick = null;
if (tp2State !== 'PENDING') tp2Tick = null;
} else {
tp1State = 'UNKNOWN_NO_CLOSE';
tp2State = 'UNKNOWN_NO_CLOSE';
}
// ── SECULAR_LEADER_REGIME_GATE_V1 (H3) ───────────────────────────────────
// 삼성전자·SK하이닉스의 secular_leader_profit_lock 발동 여부를 결정론적 판정
var slGate = calcSecularLeaderGate_(h.ticker, marketRegime || 'UNKNOWN', df, qty);
// secular_leader_gate 활성 시 tp1 표시 조정 (profit_lock 구간별 차등)
if (slGate.active) {
if (profitLockStage === 'PROFIT_LOCK_10') {
// +10%: tier_1 부분익절 보류 — trailing_stop(본절) 상향만
tp1State = 'DEFERRED_SECULAR_LEADER';
tp1Tick = null;
} else if (profitLockStage === 'PROFIT_LOCK_20') {
// +20%: 과열신호 2개 미만이면 부분익절 보류
var overheatSignals = 0;
if (typeof df.acTotal === 'number' && df.acTotal >= 2) overheatSignals++;
if (typeof df.frg5d === 'number' && df.frg5d < 0 &&
typeof df.inst5d === 'number' && df.inst5d < 0) overheatSignals++;
if (typeof df.rsi14 === 'number' && df.rsi14 >= 80) overheatSignals++;
// H6: 거래대금 급증 과열신호 — AVG_TRADE_VALUE_SIGNAL_V1
var atvSig = calcAvgTradeValueSignal_(h.ticker, df);
if (atvSig.overheat_triggered) overheatSignals++;
df._avg_trade_val_signal = atvSig;
if (overheatSignals < 2) {
tp1State = 'DEFERRED_SECULAR_LEADER_OVERHEAT_PENDING';
tp1Tick = null;
}
} else if (profitLockStage === 'PROFIT_LOCK_30'
|| profitLockStage === 'APEX_TRAILING'
|| profitLockStage === 'APEX_SUPER') {
// +30%/APEX: trailing_stop 기반 관리로 전환 — 래칫 stop 우선 (TP는 참고용만)
tp1State = 'TRAILING_STOP_PRIORITY_SECULAR_LEADER';
// tp1Tick 유지 — 참고용 유지하되 HTS 주문 표기는 별도 주석으로 처리
}
}
// TP 무효화 시 수량도 0 (무효 TP에 수량 기재 금지)
var tp1Q = (tp1Tick && qty > 0) ? Math.floor(qty * (isCore ? 0.25 : 0.33)) : 0;
var tp2Q = (tp2Tick && qty > 0) ? Math.floor((qty - tp1Q) * (isCore ? 0.40 : 0.50)) : 0;
var tp3Q = qty - tp1Q - tp2Q;
prices.push({
ticker: h.ticker,
account: h.account || '',
name: h.name || df.name || '',
position_class: isCore ? 'core' : 'satellite',
atr_mul_used: atrMul,
tick_size: getTickSize_(stopRaw),
ladder_version: ladderVer,
stop_price_raw: Math.round(stopRaw),
stop_price: stopTick,
tp1_price_raw: Math.round(tp1Raw),
tp1_price: tp1Tick, // null = TP1 이미 통과 (TP_VALIDITY_CHECK_V1)
tp1_state: tp1State,
tp1_qty: tp1Q,
tp2_price_raw: Math.round(tp2Raw),
tp2_price: tp2Tick, // null = TP2 이미 통과
tp2_state: tp2State,
tp2_qty: tp2Q,
tp3_qty: tp3Q,
profit_pct: Math.round(profitPct * 10) / 10,
profit_lock_stage: profitLockStage,
ratchet_partial_qty: ratchetPartialQty,
atr20: atr20,
avg_cost: avgCost,
ratchet_applied: ratchetApplied,
ratchet_note: ratchetNote,
ratchet_price_src: ratchetSrc,
highest_price_since_entry: h.highestPriceSinceEntry || null,
secular_leader_gate_active: slGate.active,
secular_leader_gate_status: slGate.status,
secular_leader_gate_reasons: slGate.reasons
});
});
return { prices: prices };
}
// ── H5: 결정 상태머신 게이팅 ─────────────────────────────────────────────────
/**
* runRouteFlow_
* data_feed.Final_Action → H1 게이트 적용 → 확정 final_action + gate_trace
* 구현 게이트: STOP_BREACH → INTRADAY_LOCK → HEAT_GATE → CASH_FLOOR → EXIT_POLICY
* spec/09_decision_flow.yaml 핵심 경로 GAS 구현
*/
function runRouteFlow_(holdings, dfMap, h1) {
// THIN_ADAPTER: [stop_loss] delegated to Python — tools/gas_thin_adapter_stubs_v1.py:stub_run_route_flow
var routes = [];
var traces = [];
holdings.forEach(function(h) {
var df = dfMap[h.ticker] || {};
var baseFa = (df.finalAction || 'INSUFFICIENT_DATA').toUpperCase();
var trace = [];
var finalFa = baseFa;
// ── Gate 1a: Stop_Price Breach 감지 ──────────────────────────────────
if (h.stopBreach) {
if (h1.intradayLock) {
finalFa = 'TRIM_50'; // P4: 장중은 EXIT_100 금지 → TRIM_50 완화
trace.push({ gate: 'STOP_BREACH', result: 'DOWNGRADE_P4',
reason: '장중(P4): stop_breach→TRIM_50 완화' });
} else {
finalFa = 'EXIT_100';
trace.push({ gate: 'STOP_BREACH', result: 'FORCE_EXIT',
reason: 'close(' + h.close + ')<=stop(' + h.stopPrice + ')' });
}
} else {
trace.push({ gate: 'STOP_BREACH', result: 'PASS', reason: 'no_breach' });
}
// ── Gate 1a-bis: Relative Stop — 시장 베타 보정 손절 (TRIM_50) ─────────
if (finalFa !== 'EXIT_100') {
var rsDf = df;
var rsRet20d = typeof rsDf.ret20d === 'number' ? rsDf.ret20d : parseFloat(rsDf.ret20d);
var rsAtr20 = typeof rsDf.atr20 === 'number' ? rsDf.atr20 : parseFloat(rsDf.atr20);
var rsClose = h.close || rsDf.close || 0;
var rsPft = typeof h.profitPct === 'number' ? h.profitPct : parseFloat(h.profitPct);
var rsHdays = typeof h.holdingDays === 'number' ? h.holdingDays : parseInt(h.holdingDays) || 0;
var rsKospi = typeof h1.kospiRet20d === 'number' ? h1.kospiRet20d : 0;
if (Number.isFinite(rsRet20d) && Number.isFinite(rsAtr20) && rsClose > 0) {
var rsBeta = (Math.abs(rsKospi) >= 0.5) ? Math.min(3.0, Math.max(0.3, rsRet20d / rsKospi)) : 1.0;
var rsExcess = rsRet20d - rsBeta * rsKospi;
var rsSigma = (rsAtr20 / rsClose * 100) * Math.sqrt(20);
var rsThresh = -2.0 * rsSigma;
var rsAbsFl = Number.isFinite(rsPft) && rsPft < -20.0;
var rsTimeSt = rsHdays >= 60 && rsExcess < 0;
var rsRelBr = rsExcess < rsThresh;
if (rsAbsFl || rsRelBr || rsTimeSt) {
var rsType = rsAbsFl ? 'ABS_FLOOR' : (rsRelBr ? 'REL_EXCESS' : 'TIME_STOP');
trace.push({ gate: 'RELATIVE_STOP', result: 'TRIM_50',
reason: rsType + ': excess=' + round2_(rsExcess) + ' thr=' + round2_(rsThresh) });
if (finalFa === 'HOLD' || finalFa.indexOf('BUY') >= 0) finalFa = 'TRIM_50';
} else {
trace.push({ gate: 'RELATIVE_STOP', result: 'PASS',
reason: 'excess=' + round2_(rsExcess) + ' thr=' + round2_(rsThresh) });
}
} else {
trace.push({ gate: 'RELATIVE_STOP', result: 'SKIP', reason: 'insufficient_data' });
}
} else {
trace.push({ gate: 'RELATIVE_STOP', result: 'INACTIVE', reason: 'stop_breach_exit_100' });
}
// ── Gate 1b: Intraday_Lock — 차단목록 다운그레이드 + 허용목록 이중검증 ──
if (h1.intradayLock) {
// 1단계: 차단 키워드 다운그레이드
if (indexOfArr_(INTRADAY_BLOCKED_KEYWORDS, finalFa) >= 0) {
var downgraded = finalFa.indexOf('BUY') >= 0 ? 'WATCH' : 'TRIM_50';
trace.push({ gate: 'INTRADAY_LOCK', result: 'DOWNGRADE',
reason: 'P4: ' + finalFa + '→' + downgraded });
finalFa = downgraded;
}
// 2단계: 허용목록 이중검증 — 다운그레이드 후에도 허용 목록 외 액션 강제 WATCH
if (indexOfArr_(INTRADAY_ALLOWED_ACTIONS, finalFa) < 0) {
trace.push({ gate: 'INTRADAY_LOCK', result: 'FORCE_WATCH',
reason: 'P4_ALLOWLIST: ' + finalFa + ' not in allowed list→WATCH' });
finalFa = 'WATCH';
} else {
trace.push({ gate: 'INTRADAY_LOCK', result: 'PASS', reason: 'action_in_allowlist' });
}
} else {
trace.push({ gate: 'INTRADAY_LOCK', result: 'INACTIVE', reason: 'post_market' });
}
// ── Gate 1c: Heat Gate — BUY 차단/감량 ────────────────────────────────
if (h1.heatGate === 'BLOCK_NEW_BUY' && finalFa.indexOf('BUY') >= 0) {
trace.push({ gate: 'HEAT_GATE', result: 'BLOCK_BUY',
reason: 'total_heat>=10%: BUY→WATCH' });
finalFa = 'WATCH';
} else if (h1.heatGate === 'HALVE_NEW_BUY_QUANTITY' && finalFa.indexOf('BUY') >= 0) {
trace.push({ gate: 'HEAT_GATE', result: 'HALVE_QTY',
reason: 'total_heat>=7%: 수량 50% 감량 적용' });
} else {
trace.push({ gate: 'HEAT_GATE', result: 'PASS', reason: h1.heatGate });
}
// ── Gate 1d: Mean Reversion Gate — 이격 과대 BUY 차단 (MRG001) ──────────
if (finalFa.indexOf('BUY') >= 0) {
var mrgClose = df.close || 0;
var mrgMa20 = df.ma20 || 0;
if (mrgClose > 0 && mrgMa20 > 0) {
var devRatio = round2_(mrgClose / mrgMa20);
if (devRatio >= 1.15) {
trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'BUY_HARD_BLOCK',
reason: 'MRG001: deviation_ratio(' + devRatio + ')>=1.15→BUY_HARD_BLOCK' });
finalFa = 'WATCH';
} else {
trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'PASS',
reason: 'deviation_ratio=' + devRatio + '<1.15' });
}
} else {
trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'SKIP',
reason: 'close/ma20 missing' });
}
} else {
trace.push({ gate: 'MEAN_REVERSION_GATE', result: 'INACTIVE',
reason: 'action_not_BUY' });
}
// ── Gate 2: Cash Floor — BUY 차단, HOLD → TRIM 넛지 ───────────────────
if (h1.cashFloorStatus === 'HARD_BLOCK' && finalFa.indexOf('BUY') >= 0) {
trace.push({ gate: 'CASH_FLOOR', result: 'HARD_BLOCK',
reason: 'immediate_cash<floor: BUY→WATCH' });
finalFa = 'WATCH';
} else if (h1.cashFloorStatus === 'TRIM_REQUIRED' && finalFa.indexOf('BUY') >= 0) {
trace.push({ gate: 'CASH_FLOOR', result: 'BUY_BLOCKED',
reason: 'TRIM_REQUIRED: BUY→WATCH' });
finalFa = 'WATCH';
} else if (h1.cashFloorStatus === 'TRIM_REQUIRED' && finalFa === 'HOLD') {
trace.push({ gate: 'CASH_FLOOR', result: 'NUDGE_TRIM',
reason: 'TRIM_REQUIRED: HOLD→TRIM_33 권고' });
finalFa = 'TRIM_33';
} else {
trace.push({ gate: 'CASH_FLOOR', result: 'PASS', reason: h1.cashFloorStatus });
}
// ── Gate 3: Exit Policy — Sell_Signal 확인 ────────────────────────────
var ss = (df.sellSignal || '').toUpperCase();
if (ss === 'SIGNAL_CONFIRMED' || ss.indexOf('STOP') >= 0
|| ss.indexOf('EXIT') >= 0) {
trace.push({ gate: 'EXIT_POLICY', result: 'SELL_SIGNAL',
reason: 'data_feed.Sell_Signal=' + df.sellSignal });
} else {
trace.push({ gate: 'EXIT_POLICY', result: 'PASS', reason: 'no_exit_signal' });
}
routes.push({
ticker: h.ticker,
account: h.account || '',
name: h.name || df.name || '',
base_action: baseFa,
final_action: finalFa,
gate_changed: baseFa !== finalFa,
gate_trace: trace,
rs_verdict: df.rs_verdict || null
});
for (var t = 0; t < trace.length; t++) {
traces.push({
ticker: h.ticker,
account: h.account || '',
state: trace[t].gate,
check_id: 'H5_' + trace[t].gate,
rule_ref: 'gas_data_feed.gs:' + trace[t].gate,
inputs_used: {
base_action: baseFa,
close: h.close,
stop_price: h.stopPrice,
intraday_lock: h1.intradayLock,
heat_gate_status: h1.heatGate,
cash_floor_status: h1.cashFloorStatus
},
result: trace[t].result,
selected_action: finalFa,
blocked_actions: h1.blockedActions || [],
missing_inputs: [],
tie_breaker_applied: null,
reason: trace[t].reason
});
}
});
return { ["decisions"]: routes, traces: traces, lock: true };
}
function findPriceRow_(priceRows, ticker) {
for (var i = 0; i < priceRows.length; i++) {
if (priceRows[i].ticker === ticker) return priceRows[i];
}
return null;
}
function findSellQtyRow_(sellRows, ticker) {
for (var i = 0; i < sellRows.length; i++) {
if (sellRows[i].ticker === ticker) return sellRows[i];
}
return null;
}
function findBuyQtyRow_(buyRows, ticker) {
for (var i = 0; i < buyRows.length; i++) {
if (buyRows[i].ticker === ticker) return buyRows[i];
}
return null;
}
function classifyOrderType_(signalCode, holding) {
if (holding && holding.stopBreach) return 'STOP_LOSS';
if (signalCode.indexOf('BUY') >= 0) return 'BUY';
if (signalCode.indexOf('EXIT') >= 0 || signalCode.indexOf('SELL') >= 0
|| signalCode.indexOf('TRIM') >= 0 || signalCode.indexOf('ROTATE') >= 0) {
return 'SELL';
}
if (signalCode === 'HOLD') return 'HOLD';
return 'WATCH';
}
function computeTrimQuantity_(finalAction, holdingQty, sellQtyValue) {
if (finalAction === 'TRIM_25') return Math.floor(holdingQty * 0.25);
if (finalAction === 'TRIM_33') return Math.floor(holdingQty * 0.33);
if (finalAction === 'TRIM_50') return Math.floor(holdingQty * 0.50);
if (typeof sellQtyValue === 'number') return sellQtyValue;
return null;
}
function buildOrderBlueprint_(holdings, dfMap, h1, h3, h4, h5) {
// THIN_ADAPTER: [stop_loss/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:main (order_blueprint_json)
var blueprint = [];
var h5RouteRows_ = (h5 && h5["decisions"]) ? h5["decisions"] : [];
for (var i = 0; i < h5RouteRows_.length; i++) {
var routeRow = h5RouteRows_[i];
var ticker = routeRow.ticker;
var finalAction = (routeRow.final_action || '').toUpperCase();
var holding = null;
for (var j = 0; j < holdings.length; j++) {
if (holdings[j].ticker === ticker) {
holding = holdings[j];
break;
}
}
if (!holding) continue;
var df = dfMap[ticker] || {};
var priceRow = findPriceRow_(h4.prices, ticker) || {};
var sellRow = findSellQtyRow_(h3.sellQty, ticker) || {};
var buyRow = findBuyQtyRow_(h3.buyQtyInputs, ticker) || {};
var orderType = classifyOrderType_(finalAction, holding);
var limitPrice = null;
var quantity = null;
var validation = 'MANUAL_CHECK_REQUIRED';
var rationaleCode = 'FINAL_ACTION:' + finalAction;
// [Phase 1] NO_MERCY_JUDGMENT_GATE_V2: 손절가 이탈 시 절대 매도 강제 (LLM 개입 원천 차단)
var _closePrice = holding.close || df.close || 0;
var _stopPrice = priceRow.stop_price || holding.stopPrice || 0;
if (_closePrice > 0 && _stopPrice > 0 && _closePrice < _stopPrice) {
orderType = 'SELL';
finalAction = 'EXIT_100';
quantity = holding.holdingQty || 0;
limitPrice = tickNormalize_(_closePrice);
validation = (quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA';
rationaleCode = 'EMERGENCY_SELL:NO_MERCY_JUDGMENT_GATE_V2';
} else if (orderType === 'BUY') {
if (indexOfArr_(h1.blockedActions || [], 'BUY') >= 0
|| indexOfArr_(h1.blockedActions || [], 'STAGED_BUY') >= 0) {
validation = 'BLOCKED';
rationaleCode = 'BLOCKED_ACTION:' + finalAction;
} else if (typeof buyRow.final_qty === 'number' && buyRow.final_qty > 0) {
limitPrice = tickNormalize_(holding.close || df.close || 0);
quantity = buyRow.final_qty;
validation = limitPrice > 0 ? 'PASS' : 'INSUFFICIENT_DATA';
rationaleCode = 'POSITION_SIZE_V1:' + quantity;
} else {
validation = 'INSUFFICIENT_DATA';
rationaleCode = 'NO_BUY_QUANTITY';
}
} else if (indexOfArr_(h1.blockedActions || [], orderType) >= 0) {
validation = 'BLOCKED';
rationaleCode = 'BLOCKED_ACTION:' + orderType;
} else if (orderType === 'STOP_LOSS') {
limitPrice = priceRow.stop_price || tickNormalize_(holding.stopPrice || 0);
quantity = holding.holdingQty || null;
validation = (limitPrice > 0 && quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA';
rationaleCode = 'STOP_PRICE_CORE_V1:' + limitPrice;
} else if (orderType === 'SELL') {
if (finalAction === 'EXIT_100' || finalAction === 'SELL_FULL' || finalAction === 'EXIT_FULL') {
quantity = holding.holdingQty || null;
} else {
quantity = computeTrimQuantity_(finalAction, holding.holdingQty || 0, sellRow.sell_qty);
}
limitPrice = df.sellLimitPrice > 0
? tickNormalize_(df.sellLimitPrice)
: tickNormalize_(holding.close || df.close || 0);
validation = (limitPrice > 0 && quantity > 0) ? 'PASS' : 'INSUFFICIENT_DATA';
rationaleCode = 'SELL_RULE:' + finalAction;
} else {
validation = 'BLOCKED';
rationaleCode = 'NO_EXECUTION:' + finalAction;
}
blueprint.push({
account: holding.account || '일반계좌',
ticker: ticker,
name: holding.name || df.name || '',
current_holding_quantity: holding.holdingQty || 0,
average_cost_krw: holding.avgCost ? Math.round(holding.avgCost) : null,
current_price_krw: holding.close ? Math.round(holding.close) : null,
order_type: orderType,
mode: orderType === 'BUY' ? 'lead' : 'none',
limit_price_krw: limitPrice > 0 ? Math.round(limitPrice) : null,
quantity: typeof quantity === 'number' ? quantity : null,
// HS010: WATCH/BLOCKED/INSUFFICIENT_DATA 상태에서 가격·수량 null 강제
// 사용자가 감시값을 HTS 주문으로 오인 입력하는 것을 원천 차단
stop_price_krw: validation === 'PASS' ? (priceRow.stop_price || null) : null,
stop_quantity: validation === 'PASS' && orderType === 'BUY' && typeof quantity === 'number' ? quantity : null,
["take_profit_price_krw"]: validation === 'PASS' ? (priceRow.tp1_price || null) : null,
["take_profit_quantity"]: validation === 'PASS' ? (priceRow.tp1_qty || null) : null,
order_amount_krw: (limitPrice > 0 && typeof quantity === 'number') ? Math.round(limitPrice * quantity) : null,
validation_status: validation,
rationale_code: rationaleCode
});
}
return blueprint;
}
/**
* SELL_PRICE_SANITY_V2 (SPSV2) — 매도 주문 3중 가격 검증
* CHECK_1: limit_price < final_stop → INVALID_PRICE_INVERSION
* CHECK_2: stop_price < auto_trailing_stop → INVALID_TRAILING_STOP_BREACH
* CHECK_3: limit_price == 0 → INVALID_ZERO_PRICE
* validation_status를 인라인 재기록해 EXPORT_GATE가 자동 차단
* @param {Array} blueprint — buildOrderBlueprint_ 반환값
* @param {Array} profitPreservJson — profit_preservation_json (auto_trailing_stop 포함)
* @return {Array} blueprint with spsv2_verdict 필드 추가
*/
function calcSellPriceSanityV2_(blueprint, profitPreservJson) {
var ppMap = {};
(profitPreservJson || []).forEach(function(pp) {
var tk = (pp.ticker || pp.ticker_code || '').toString();
if (tk) ppMap[tk] = pp;
});
return (blueprint || []).map(function(row) {
var ot = (row.order_type || '').toString().toUpperCase();
if (ot !== 'SELL' && ot !== 'STOP_LOSS') {
return Object.assign({}, row, { spsv2_verdict: 'NOT_SELL_SKIP' });
}
if ((row.validation_status || '').toString() !== 'PASS') {
return Object.assign({}, row, { spsv2_verdict: 'SPSV2_SKIP_NOT_PASS' });
}
var limitPrice = Number(row.limit_price_krw || 0);
var stopPrice = Number(row.stop_price_krw || 0);
var pp = ppMap[(row.ticker || row.ticker_code || '').toString()] || {};
var autoTrailing = Number(pp.auto_trailing_stop || 0);
var finalStop = (autoTrailing > 0 && autoTrailing > stopPrice) ? autoTrailing : stopPrice;
var check1 = (limitPrice > 0 && finalStop > 0 && limitPrice < finalStop)
? 'INVALID_PRICE_INVERSION' : 'PASS';
var check2 = (autoTrailing > 0 && stopPrice > 0 && stopPrice < autoTrailing)
? 'INVALID_TRAILING_STOP_BREACH' : 'PASS';
var check3 = (limitPrice > 0) ? 'PASS' : 'INVALID_ZERO_PRICE';
var verdict;
if (check1 !== 'PASS') verdict = check1;
else if (check2 !== 'PASS') verdict = check2;
else if (check3 !== 'PASS') verdict = check3;
else verdict = 'SPSV2_PASS';
var newValidation = (verdict === 'SPSV2_PASS') ? row.validation_status : verdict;
return Object.assign({}, row, {
spsv2_verdict: verdict,
final_stop_price: finalStop || stopPrice || null,
auto_trailing_stop_ref: autoTrailing || null,
validation_status: newValidation
});
});
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P0-D: PRICE_HIERARCHY_LOCK_V1 (PHL-V1)
// 5계층 가격 단일화 잠금 — 표간 가격 혼재 완전 차단
// LAYER_1(주문가) / LAYER_2(손절/익절) / LAYER_3(트레일링보정) /
// LAYER_4(반등트리거) / LAYER_5(참고방어가)
// ═══════════════════════════════════════════════════════════════════════
/**
* calcPriceHierarchyLock_
* 단일 종목의 5계층 가격 분리 잠금. LAYER_5가 LAYER_1 위치에 나타나면 INVALID_LAYER_VIOLATION.
*/
function calcPriceHierarchyLock_(ticker, blueprintRow, priceRow, ppRow, scrsRow, propRefRow) {
var bp = blueprintRow || {};
var pr = priceRow || {};
var pp = ppRow || {};
var sc = scrsRow || {};
var ref = propRefRow || {};
var layer1 = toNumber_(bp.limit_price_krw) || null;
var layer2Stop = toNumber_(pr.stop_price) || null;
var layer2Tp1 = toNumber_(pr.tp1_price) || null;
var layer2Tp2 = toNumber_(pr.tp2_price) || null;
var layer3Trailing = toNumber_(pp.auto_trailing_stop) || null;
var layer4Rebound = toNumber_(sc.rebound_trigger_price) || null;
var layer5RefDef = toNumber_(ref.reference_defense_price) || null;
var finalStop = (layer3Trailing !== null && layer2Stop !== null)
? Math.max(layer2Stop, layer3Trailing)
: (layer2Stop || layer3Trailing || null);
var violations = [];
if (layer5RefDef !== null && layer1 !== null && layer5RefDef === layer1) {
violations.push({ ticker: ticker, type: 'INVALID_LAYER_VIOLATION',
detail: 'LAYER_5(ref=' + layer5RefDef + ')==LAYER_1(order=' + layer1 + ') — 참고방어가가 주문가로 오인됨' });
}
if (layer4Rebound !== null && layer2Stop !== null && layer4Rebound === layer2Stop) {
violations.push({ ticker: ticker, type: 'INVALID_LAYER_VIOLATION',
detail: 'LAYER_4(rebound=' + layer4Rebound + ')==LAYER_2(stop=' + layer2Stop + ') — 반등트리거가 손절가로 오인됨' });
}
if (layer5RefDef !== null && layer1 !== null) {
var diffPct = Math.abs(layer5RefDef - layer1) / layer1 * 100;
if (diffPct < 5) {
violations.push({ ticker: ticker, type: 'LAYER_PROXIMITY_WARNING',
detail: 'LAYER_5(ref=' + layer5RefDef + ')과 LAYER_1(order=' + layer1 + ') ' + diffPct.toFixed(1) + '% 근접 — 혼동 위험' });
}
}
return {
formula_id: 'PRICE_HIERARCHY_LOCK_V1',
ticker: ticker,
layer1_limit_price: layer1,
layer2_stop_price: layer2Stop,
layer2_tp1_price: layer2Tp1,
layer2_tp2_price: layer2Tp2,
layer3_auto_trailing: layer3Trailing,
layer4_rebound_trigger: layer4Rebound,
layer5_reference_defense: layer5RefDef,
final_stop_price: finalStop,
layer_violations: violations,
violation_count: violations.filter(function(v) { return v.type === 'INVALID_LAYER_VIOLATION'; }).length
};
}
/**
* applyPriceHierarchyLockAll_
* 전 종목 PHL-V1 일괄 실행 — hApex 내 모든 소스 참조
*/
function applyPriceHierarchyLockAll_(hApex) {
var blueprints = (hApex && hApex.order_blueprint_json) || [];
var pricesJson = (hApex && hApex.prices_json) || [];
var ppJson = (hApex && hApex.profit_preservation_json) || [];
var scrsCombo = ((hApex && hApex.scrs_v2_json) || {}).selected_combo || [];
var propRef = (hApex && hApex.proposal_reference_json) || [];
var priceMap = {}; pricesJson.forEach(function(r) { priceMap[(r.ticker||r.ticker_code||'').toString()] = r; });
var ppMap = {}; ppJson.forEach(function(r) { ppMap[(r.ticker||r.ticker_code||'').toString()] = r; });
var scrsMap = {}; scrsCombo.forEach(function(r) { scrsMap[(r.ticker||'').toString()] = r; });
var refMap = {}; propRef.forEach(function(r) { refMap[(r.ticker||'').toString()] = r; });
var tickers = {};
blueprints.forEach(function(bp) { tickers[(bp.ticker||bp.ticker_code||'')] = 1; });
return Object.keys(tickers).filter(Boolean).map(function(tk) {
var bp = blueprints.find(function(r) { return (r.ticker||r.ticker_code||'').toString() === tk; }) || {};
return calcPriceHierarchyLock_(tk, bp, priceMap[tk], ppMap[tk], scrsMap[tk], refMap[tk]);
});
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P2-D: SELL_EXECUTION_QUALITY_GATE_V1 (SEQG-V1)
// 매도 실행 품질 채점 — 가격/수량/타이밍 3축 평가
// ═══════════════════════════════════════════════════════════════════════
/**
* calcSellExecutionQualityGate_
* 매도 주문의 실행 품질을 3축(가격/수량/타이밍)으로 채점.
* - 가격축: limit_price vs stop_price 간격 충분성
* - 수량축: 보유수량 대비 매도비율 적정성 (5~70% 범위)
* - 타이밍축: PSR-V2 신호 없을 때 매도 = 불필요 매도 위험
* @param {Array} blueprint — order_blueprint_json (SPSV2 적용 후)
* @param {Array} holdings
* @param {Array} psrRows — proactive_sell_radar_json
* @return {Array} SEQG-V1 rows
*/
function calcSellExecutionQualityGate_(blueprint, holdings, psrRows) {
var holdMap = {};
(holdings || []).forEach(function(h) { holdMap[h.ticker] = h; });
var psrMap = {};
(psrRows || []).forEach(function(p) { psrMap[p.ticker] = p; });
return (blueprint || []).filter(function(row) {
return (row.order_type || '').toString().toUpperCase() === 'SELL'
|| (row.order_type || '').toString().toUpperCase() === 'STOP_LOSS';
}).map(function(row) {
var h = holdMap[(row.ticker || '').toString()] || {};
var psr = psrMap[(row.ticker || '').toString()] || {};
var limitPx = toNumber_(row.limit_price_krw) || 0;
var stopPx = toNumber_(row.stop_price_krw) || 0;
var qty = toNumber_(row.order_quantity) || 0;
var holdQty = toNumber_(h.holdingQty) || 1;
// 가격축: stop과 limit 간격이 ATR20의 0.5배 이상
var close = toNumber_(h.close) || limitPx || 1;
var priceSpread = (limitPx > 0 && stopPx > 0) ? (limitPx - stopPx) / close * 100 : 0;
var priceScore = priceSpread >= 2.0 ? 100 : priceSpread >= 1.0 ? 70 : 40;
// 수량축: 보유량 대비 5%~70%
var sellRatio = holdQty > 0 ? qty / holdQty * 100 : 0;
var qtyScore = (sellRatio >= 5 && sellRatio <= 70) ? 100
: (sellRatio > 0 && sellRatio < 5) ? 60
: sellRatio > 70 ? 50 : 0;
// 타이밍축: PSR-V2 CRITICAL/WARNING 있으면 타이밍 좋음
var radarLevel = psr.radar_level || 'CLEAR';
var timingScore = radarLevel === 'CRITICAL' ? 100
: radarLevel === 'WARNING' ? 80
: radarLevel === 'WATCH' ? 60 : 40;
var totalScore = Math.round((priceScore + qtyScore + timingScore) / 3);
var grade = totalScore >= 80 ? 'A' : totalScore >= 65 ? 'B' : totalScore >= 50 ? 'C' : 'D';
return {
ticker: row.ticker,
name: row.name || (h.name || ''),
order_type: row.order_type,
price_score: priceScore,
quantity_score: qtyScore,
timing_score: timingScore,
total_score: totalScore,
execution_grade: grade,
sell_ratio_pct: Math.round(sellRatio * 10) / 10,
price_spread_pct: Math.round(priceSpread * 10) / 10,
radar_level_ref: radarLevel,
formula_id: 'SELL_EXECUTION_QUALITY_GATE_V1'
};
});
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P2-B: PROACTIVE_SELL_RADAR_V2 — 8신호 사전 분배 감지 (D-3일)
// 분배 전 3일 이내 조기 경보 → CRITICAL/WARNING/WATCH 단계 분류
// ═══════════════════════════════════════════════════════════════════════
/**
* calcProactiveSellRadarV2_
* 8가지 사전 분배 감지 신호: 고가 근접+수축, 기관 순매도 전환, 개인 집중유입,
* 옵션 풋/콜 역전, 뉴스 감성 급락, 거래량 이상, RSI 다이버전스, 수익 보호 트리거.
* @param {Array} holdings
* @param {Object} dfMap
* @param {Object} profitPreservMap ticker→profitPreservRow 맵
* @return {Array} PSR-V2 rows
*/
function calcProactiveSellRadarV2_(holdings, dfMap, profitPreservMap) {
var ppMap = profitPreservMap || {};
return (holdings || []).map(function(h) {
var df = dfMap[h.ticker] || {};
var close = toNumber_(h.close || df.close) || 0;
var high52w = toNumber_(df.high52w || df['High52W']) || 0;
var volume = toNumber_(df.volume) || 0;
var avgVol5d = toNumber_(df.avgVolume5d || df.avgVol5d) || 0;
var rsi14 = toNumber_(df.rsi14 || df['RSI14']) || 50;
var inst5d = toNumber_(df.inst5d || df['Inst_5D']) || 0;
var frg5d = toNumber_(df.frg5d || df['FRG_5D']) || 0;
var ret5d = toNumber_(df.ret5d || df['Ret5D']) || 0;
var sentScore = toNumber_(df.sentimentScore || df['Sentiment_Score']) || 0;
var pp = ppMap[h.ticker] || {};
var autoTrail = toNumber_(pp.auto_trailing_stop) || 0;
var holdQty = toNumber_(h.holdingQty) || 0;
var signals = [];
// SIG_1: 고가 대비 2% 이내 + 거래량 30% 수축 (고점 분배 전형)
var nearHigh = high52w > 0 && close > 0 && (high52w - close) / high52w <= 0.02;
var volShrink = avgVol5d > 0 && volume > 0 && volume < avgVol5d * 0.7;
if (nearHigh && volShrink) signals.push({ id: 'SIG_1_HIGH_SHRINK', weight: 2.0 });
// SIG_2: 기관 5일 순매도 전환 (inst5d < -음수)
if (inst5d < 0) signals.push({ id: 'SIG_2_INST_SELL', weight: 2.0 });
// SIG_3: 개인 집중유입 비율 > 70% (설거지 전형)
var retailRatio = toNumber_(df.retailBuyRatio5d || df['Retail_Buy_Ratio_5D']) || 0;
if (retailRatio > 0.70) signals.push({ id: 'SIG_3_RETAIL_INFLOW', weight: 1.5 });
// SIG_4: 옵션 풋/콜 비율 역전 (put_call_ratio > 1.3)
var pcRatio = toNumber_(df.putCallRatio || df['Put_Call_Ratio']) || 0;
if (pcRatio > 1.3) signals.push({ id: 'SIG_4_PUT_CALL_INVERT', weight: 1.5 });
// SIG_5: 뉴스 감성 점수 급락 (sentiment < -20)
if (sentScore < -20) signals.push({ id: 'SIG_5_SENTIMENT_DROP', weight: 1.0 });
// SIG_6: 거래량 이상 급증 (vol > 1.5x 평균) + 음봉
var open_ = toNumber_(df.open || df['Open']) || close;
var volSpike = avgVol5d > 0 && volume > avgVol5d * 1.5;
var bearCandle = close < open_ && close > 0;
if (volSpike && bearCandle) signals.push({ id: 'SIG_6_VOL_SPIKE_BEAR', weight: 1.5 });
// SIG_7: RSI 다이버전스 (rsi14 >= 70 + 5일 수익 감소)
if (rsi14 >= 70 && ret5d < 0) signals.push({ id: 'SIG_7_RSI_DIVERGE', weight: 1.5 });
// SIG_8: 수익 보호 트리거 근접 (close <= auto_trailing_stop * 1.02)
if (autoTrail > 0 && close > 0 && close <= autoTrail * 1.02) {
signals.push({ id: 'SIG_8_TRAIL_PROXIMITY', weight: 2.0 });
}
var weightedSum = signals.reduce(function(acc, s) { return acc + s.weight; }, 0);
var radarLevel = weightedSum >= 5.0 ? 'CRITICAL'
: weightedSum >= 3.0 ? 'WARNING'
: weightedSum >= 1.5 ? 'WATCH'
: 'CLEAR';
return {
ticker: h.ticker,
name: h.name || df.name || '',
radar_level: radarLevel,
weighted_sum: Math.round(weightedSum * 10) / 10,
signal_count: signals.length,
signals: signals.map(function(s) { return s.id; }),
rsi14: round2_(rsi14),
inst5d: round2_(inst5d),
retail_ratio: retailRatio ? round2_(retailRatio) : null,
auto_trail_ref: autoTrail || null,
formula_id: 'PROACTIVE_SELL_RADAR_V2'
};
});
}
/**
* L1: SECTOR_ROTATION_MOMENTUM_V1
* 섹터 로테이션 모멘텀 추적 — rank_delta W1/W2 기반 RISING/STABLE/FADING/TOPPING_OUT 분류
* 결과는 sector_rotation_momentum_json으로 buildHarnessRows_()에 전달된다.
* calcAlphaLeadRow_()에서 FADING/TOPPING_OUT 페널티 적용.
* @param {Object} sectorFlowData — readSectorFlowForRadar_() 반환값
* @return {Array} sector_rotation_momentum_json rows
*/
function calcSectorRotationMomentum_(sectorFlowData) {
var rows = [];
var sectorNames = Object.keys(sectorFlowData || {});
sectorNames.forEach(function(sName) {
var sf = sectorFlowData[sName];
if (!sf || !Number.isFinite(sf.rank)) return;
var rankDeltaW1 = Number.isFinite(sf.prevRank) ? sf.rank - sf.prevRank : null;
var rankDeltaW2 = Number.isFinite(sf.prevRankW2) ? sf.rank - sf.prevRankW2 : null;
var momentumState = 'STABLE';
if (rankDeltaW1 !== null && rankDeltaW2 !== null) {
if (rankDeltaW1 >= 2 && rankDeltaW2 >= 2) {
// 1주일 및 2주일 연속 순위 하락 → 추세 약화
momentumState = 'FADING';
} else if (sf.rank <= 3 && rankDeltaW1 >= 1) {
// 상위권이지만 이미 하락 전환 → 고점 신호
momentumState = 'TOPPING_OUT';
} else if (rankDeltaW1 <= -2) {
// 순위 상승 → 로테이션 유입
momentumState = 'RISING';
}
} else if (rankDeltaW1 !== null) {
if (rankDeltaW1 >= 3) momentumState = 'FADING';
else if (rankDeltaW1 <= -2) momentumState = 'RISING';
}
rows.push({
sector: sName,
rank: sf.rank,
prev_rank_w1: Number.isFinite(sf.prevRank) ? sf.prevRank : null,
prev_rank_w2: Number.isFinite(sf.prevRankW2) ? sf.prevRankW2 : null,
rank_delta_w1: rankDeltaW1,
rank_delta_w2: rankDeltaW2,
momentum_state: momentumState,
formula_id: 'SECTOR_ROTATION_MOMENTUM_V1'
});
});
// 현재 순위 오름차순 정렬
rows.sort(function(a, b) { return a.rank - b.rank; });
return rows;
}
/**
* calcAlphaShield_
* 보유 종목별 Alpha-Shield 지표 계산:
* X1 deviation_ratio → MRG001 BUY_HARD_BLOCK (이격 차단)
* X3 rs_ratio → RS001 RS_LAGGARD (상대강도 부진)
* W1 수급 다이버전스 → DIVERGENCE_ALERT
* W2 오버행 압력 → OVERHANG_WARNING
* W3 섹터 로테이션 이탈 → ROTATION_WARNING
* W4 수급 감속 → FLOW_DECEL_WARNING
* critical_alert: 2개 이상 레이더 동시 발화 시 전면 재검토 강제
*/
function calcAlphaShield_(holdings, dfMap, kospiRet5d, sectorFlowData) {
var perHolding = [];
var criticalAlerts = 0;
holdings.forEach(function(h) {
var df = dfMap[h.ticker] || {};
var close = df.close || 0;
var ma20 = df.ma20 || 0;
// ── X1: MEAN_REVERSION_GATE_V1 ─────────────────────────────────────────
var deviationRatio = (close > 0 && ma20 > 0) ? round2_(close / ma20) : null;
var mrgGate = deviationRatio === null ? 'INSUFFICIENT_DATA'
: deviationRatio >= 1.15 ? 'BUY_HARD_BLOCK'
: 'PASS';
// ── X3: RS_RATIO_V1 ────────────────────────────────────────────────────
var stockRet5d = df.ret5d; // null = 컬럼 없음, 0 = 실제 0%
var rsRatio = (stockRet5d !== null && typeof kospiRet5d === 'number' && kospiRet5d !== 0)
? round2_(stockRet5d / kospiRet5d) : null;
var rsStatus = rsRatio === null ? 'INSUFFICIENT_DATA'
: rsRatio < 0.80 ? 'RS_LAGGARD' : 'RS_OK';
// ── 공통 수급 데이터 ──────────────────────────────────────────────────
var frg5d = df.frg5d; // null = 컬럼 없음
var inst5d = df.inst5d;
var frg20d = df.frg20d;
var volume = df.volume;
var avgVol5d = df.avgVolume5d;
var ma20Slope = typeof df.ma20Slope === 'number' ? df.ma20Slope : null;
var priceAboveMa20 = close > 0 && ma20 > 0 && close > ma20;
var ma20SlopePositive = ma20Slope !== null && ma20Slope > 0;
var frgNetNeg = frg5d !== null && inst5d !== null && frg5d < 0 && inst5d < 0;
var volRatio = (volume !== null && avgVol5d !== null && avgVol5d > 0)
? round2_(volume / avgVol5d) : null;
// ── W1: DIVERGENCE_SCORE_V1 ────────────────────────────────────────────
var w1Status = 'INSUFFICIENT_DATA';
if (frg5d !== null && inst5d !== null && ma20Slope !== null
&& volume !== null && avgVol5d !== null) {
w1Status = (priceAboveMa20 && !ma20SlopePositive && frgNetNeg
&& volRatio !== null && volRatio <= 0.80)
? 'DIVERGENCE_ALERT' : 'CLEAR';
}
// ── W2: OVERHANG_PRESSURE_V1 ───────────────────────────────────────────
var w2Status = 'INSUFFICIENT_DATA';
var overhangPressure = null;
if (frg20d !== null && avgVol5d !== null && avgVol5d > 0) {
overhangPressure = round2_(Math.abs(frg20d) / (avgVol5d * 20));
w2Status = (frg20d < 0 && overhangPressure > 0.30) ? 'OVERHANG_WARNING' : 'CLEAR';
}
// ── W3: SECTOR_ROTATION_RADAR_V1 ──────────────────────────────────────
var w3Status = 'INSUFFICIENT_DATA';
var sectorName = TICKER_SECTOR_MAP[h.ticker] || null;
var sfRow = sectorName ? (sectorFlowData[sectorName] || null) : null;
var sectorRank = null;
var sectorPrvRank = null;
if (sfRow) {
sectorRank = sfRow.rank;
sectorPrvRank = sfRow.prevRank;
if (Number.isFinite(sfRow.rank) && Number.isFinite(sfRow.prevRank)) {
var dropW1 = sfRow.rank - sfRow.prevRank;
var dropW2 = Number.isFinite(sfRow.prevRankW2)
? sfRow.rank - sfRow.prevRankW2 : dropW1;
w3Status = (dropW1 >= 3 && dropW2 >= 3) ? 'ROTATION_WARNING' : 'CLEAR';
}
}
// ── W4: FLOW_ACCELERATION_V1 ───────────────────────────────────────────
var w4Status = 'INSUFFICIENT_DATA';
var flowAccelRatio = null;
if (frg5d !== null && frg20d !== null) {
var buyEnergy20dAvg = frg20d / 4;
if (buyEnergy20dAvg > 0) {
flowAccelRatio = round2_(frg5d / buyEnergy20dAvg);
w4Status = (priceAboveMa20 && frg5d > 0 && flowAccelRatio < 0.50)
? 'FLOW_DECEL_WARNING' : 'CLEAR';
} else {
w4Status = 'CLEAR';
}
}
// ── 발화 집계 ────────────────────────────────────────────────────────
var ALERT_STATUSES = ['DIVERGENCE_ALERT','OVERHANG_WARNING','ROTATION_WARNING','FLOW_DECEL_WARNING'];
var fires = [w1Status, w2Status, w3Status, w4Status].filter(function(s) {
return ALERT_STATUSES.indexOf(s) >= 0;
}).length;
if (fires >= 2) criticalAlerts++;
perHolding.push({
ticker: h.ticker,
name: h.name || '',
weight_pct: h.weightPct || 0,
// X1 MRG001
deviation_ratio: deviationRatio,
mrg_gate: mrgGate,
// X3 RS001
stock_ret5d: stockRet5d,
kospi_ret5d: typeof kospiRet5d === 'number' ? kospiRet5d : null,
rs_ratio: rsRatio,
rs_status: rsStatus,
// W1
volume_ratio: volRatio,
w1_status: w1Status,
// W2
overhang_pressure: overhangPressure,
w2_status: w2Status,
// W3
sector: sectorName,
sector_rank: sectorRank,
sector_prev_rank: sectorPrvRank,
w3_status: w3Status,
// W4
flow_accel_ratio: flowAccelRatio,
w4_status: w4Status,
// 종합
radar_fires: fires,
critical_alert: fires >= 2 ? 'CRITICAL_ALERT' : 'OK'
});
});
return {
per_holding: perHolding,
critical_alert_count: criticalAlerts,
lock: true
};
}
// ── APEX V1: 판단 자료 생성 시점 하네스 ─────────────────────────────────────
/**
* calcRegimeAdjustedSellPriority_ [K3: 국면·섹터 연계 H2 동적 우선순위]
* 시장 국면(regime)에 따라 H2 매도후보의 매도 우선순위를 동적으로 조정한다.
* H2 원래 순위(rank)는 변경하지 않고 regime_priority_adjustment(음수=우선↑)와
* final_regime_rank을 추가로 부여한다.
* LLM은 regime_adjusted_sell_priority_json을 H2보다 우선 참조하되,
* sell_priority_lock=true 이므로 최종 순위를 임의 재해석할 수 없다.
*/
function calcRegimeAdjustedSellPriority_(h2Candidates, regime, dfMap, kospiRet5d) {
var result = [];
h2Candidates.forEach(function(cand) {
var candScore = (typeof cand.sell_priority_score === 'number') ? cand.sell_priority_score : cand.score;
if (typeof candScore !== 'number' || !Number.isFinite(candScore)) {
throw new Error('SELL_PRIORITY_SCHEMA_INVALID: missing score field for ticker=' + cand.ticker);
}
var df = dfMap[cand.ticker] || {};
var adj = 0;
var reason = 'NO_REGIME_ADJ';
if (regime === 'RISK_OFF' || regime === 'EVENT_SHOCK') {
// 추세 붕괴/충격 국면: KOSPI 대비 고베타(많이 떨어지는) 종목 우선 매도
var ret5d = typeof df.ret5d === 'number' ? df.ret5d : null;
var kRet5d = typeof kospiRet5d === 'number' ? kospiRet5d : null;
if (ret5d !== null && kRet5d !== null && kRet5d < -1) {
var betaProxy = ret5d / kRet5d;
if (Number.isFinite(betaProxy) && betaProxy > 1.3) {
adj = -3; reason = 'high_beta_breakdown_sell_first';
} else if (Number.isFinite(betaProxy) && betaProxy > 1.0) {
adj = -1; reason = 'above_beta_breakdown';
}
}
// 수급 동반 이탈 종목 우선
if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) {
adj = Math.min(adj, -2); reason = reason === 'NO_REGIME_ADJ' ? 'dual_outflow_breakdown' : reason;
}
} else if (regime === 'RISK_OFF_CANDIDATE') {
// 분배장 경고: 수급 약하고 flow_credit 낮은 종목 우선
var fcOk = typeof df.flowCredit === 'number';
if (fcOk && df.flowCredit < 0.30) { adj = -2; reason = 'low_flow_credit_distribution'; }
else if (fcOk && df.flowCredit < 0.45) { adj = -1; reason = 'moderate_low_flow_distribution'; }
} else if (regime === 'RISK_ON' || regime === 'SECULAR_LEADER_RISK_ON') {
// 상승기: 섹터 대비 상대적 약자 우선 정리 (리더 보호)
var sRet = typeof df.ret5d === 'number' ? df.ret5d : null;
var kRet = typeof kospiRet5d === 'number' ? kospiRet5d : null;
if (sRet !== null && kRet !== null && sRet < kRet - 3) {
adj = -2; reason = 'sector_lag_in_risk_on_trim';
}
// 중복 ETF는 상승기에도 먼저 정리
if (df.isDuplicateEtf) { adj = Math.min(adj, -2); reason = 'duplicate_etf_in_risk_on'; }
} else if (regime === 'LEADER_CONCENTRATION' || regime === 'NEUTRAL') {
// 조정기: AC(안티클라이막스) 발동 종목 우선
if (df.acGate && String(df.acGate).toUpperCase().indexOf('CLIMAX') >= 0) {
adj = -1; reason = 'anti_climax_in_pullback';
}
}
result.push({
rank: cand.rank,
ticker: cand.ticker,
name: cand.name,
tier: cand.tier,
original_score: candScore,
trim_style: cand.trim_style || '',
regime_priority_adjustment: adj,
adjusted_sort_key: cand.tier * 100 + (cand.rank + adj),
adjustment_reason: reason,
regime_applied: regime
});
});
result.sort(function(a, b) { return a.adjusted_sort_key - b.adjusted_sort_key; });
result.forEach(function(r, i) { r.final_regime_rank = i + 1; });
return result;
}
function findCandidateByTicker_(candidates, ticker) {
for (var i = 0; i < (candidates || []).length; i++) {
if (candidates[i].ticker === ticker) return candidates[i];
}
return null;
}
function findOrderBlueprintRow_(orders, ticker) {
for (var i = 0; i < (orders || []).length; i++) {
if (orders[i].ticker === ticker) return orders[i];
}
return null;
}
function calcDistributionRiskRow_(h, df, kospiRet5d, sectorFlowData) {
// [2026-06-22 정정] 이전 주석("THIN_ADAPTER: delegated to Python —
// inject_computed_harness.py:calc_distribution_detector_per_ticker")은 틀린 주석이었다.
// 이 함수(formula_id=DISTRIBUTION_RISK_SCORE_V1, spec/13b_harness_formulas.yaml:365,
// BUY/STAGED_BUY/ADD_ON 절대 차단 게이트)와 Python calc_distribution_detector_per_ticker
// (formula_id=DISTRIBUTION_SELL_DETECTOR_V1, spec/13_formula_registry.yaml:2758,
// PRE_DISTRIBUTION_EARLY_WARNING 2신호의 정밀도 보완용 6신호 감지기)는 서로 다른
// 입력·출력·목적을 가진 독립 공식이다 — 하나가 다른 하나의 GAS 중복이 아니다.
// 둘 다 유지하며 역할을 분리한다(governance/gas_logic_migration_ledger_v1.yaml F12/F13,
// 사용자 결정 2026-06-22). 이 함수를 삭제하지 말 것.
var close = df.close || h.close || 0;
var ma20 = df.ma20 || 0;
var high = df.high || close;
var low = df.low || close;
var volume = df.volume;
var avgVol5d = df.avgVolume5d;
var flowCredit = typeof df.flowCredit === 'number' ? df.flowCredit : null;
var priceAboveMa20 = close > 0 && ma20 > 0 && close > ma20;
var score = 0;
var reasons = [];
if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) {
score += 30; reasons.push('smart_money_outflow');
}
if (volume !== null && avgVol5d !== null && avgVol5d > 0 && volume < avgVol5d * 0.80) {
score += 20; reasons.push('volume_fade_after_surge');
}
if (high > low && close > 0) {
var upperWickRatio = (high - close) / Math.max(high - low, 1);
if (upperWickRatio >= 0.45 && priceAboveMa20) {
score += 15; reasons.push('upper_wick_distribution');
}
}
if (flowCredit !== null && flowCredit < 0.40) {
score += 20; reasons.push('flow_credit_low');
}
if (typeof df.ret5d === 'number' && typeof kospiRet5d === 'number' && df.ret5d < kospiRet5d - 3) {
score += 15; reasons.push('sector_relative_lag');
}
// J2: Anti-Climax Gate — 가격은 유지되나 수급 에너지 고갈 신호 (acGate / acTotal)
if (df.acGate && String(df.acGate).toUpperCase().indexOf('CLIMAX') >= 0) {
score += 15; reasons.push('anti_climax_gate');
}
if (typeof df.acTotal === 'number' && df.acTotal >= 2) {
score += 10; reasons.push('ac_total_gte2');
}
// J2: 거래량 상승 국면에서 상승폭 축소 (가격 상승 + 거래량 급증 + 수익 미실현 구간)
if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 40 && priceAboveMa20
&& (flowCredit === null || flowCredit < 0.50)) {
score += 10; reasons.push('val_surge_no_flow_support');
}
// L4: PRE_DISTRIBUTION_EARLY_WARNING_V1
// Signal 1: 신고점 근접 + 거래량 수축 — 분배 직전 전형적 패턴
var high52w = typeof df.high52w === 'number' && df.high52w > 0 ? df.high52w : null;
var nearNewHigh = (high52w !== null && close > 0 && close >= high52w * 0.97)
|| (ma20 > 0 && close > ma20 * 1.15); // 52W high 미제공 시 MA20 +15% 이상 연장으로 대체
if (nearNewHigh && volume !== null && avgVol5d !== null && avgVol5d > 0
&& volume < avgVol5d * 0.80) {
score += 12; reasons.push('new_high_volume_contraction');
}
// Signal 2: 최근 급등 후 수급 약화 — 5일 +5% 이상 상승했으나 flow credit 저조
if (typeof df.ret5d === 'number' && df.ret5d >= 5
&& flowCredit !== null && flowCredit < 0.45) {
score += 10; reasons.push('surge_weak_flow');
}
var state = score >= 70 ? 'BLOCK_BUY' : score >= 55 ? 'TRIM_REVIEW' : 'PASS';
var preDistWarning = (reasons.indexOf('new_high_volume_contraction') >= 0
|| reasons.indexOf('surge_weak_flow') >= 0) ? 'EARLY_WARNING' : 'NONE';
return {
ticker: h.ticker,
name: h.name || df.name || '',
["distribution_risk_score"]: Math.min(100, Math.max(0, score)),
anti_distribution_state: state,
pre_distribution_warning: preDistWarning,
reason_codes: reasons,
formula_id: 'DISTRIBUTION_RISK_SCORE_V1'
};
}
function calcAlphaLeadRow_(h, df, sectorFlowData, distributionRow) {
var close = df.close || h.close || 0;
var ma20 = df.ma20 || 0;
var closeVsMa20Pct = (close > 0 && ma20 > 0) ? (close / ma20 - 1) * 100 : null;
var sectorName = TICKER_SECTOR_MAP[h.ticker] || null;
var sf = sectorName ? sectorFlowData[sectorName] : null;
var score = 0;
var lateChaseRisk = 0;
var reasons = [];
if (sf && Number.isFinite(sf.rank) && sf.rank <= 2) { score += 20; reasons.push('sector_rank_top2'); }
// L1: SECTOR_ROTATION_MOMENTUM_V1 — 로테이션 모멘텀 패널티
if (sf && Number.isFinite(sf.rank) && Number.isFinite(sf.prevRank)) {
var rdW1 = sf.rank - sf.prevRank;
var rdW2 = Number.isFinite(sf.prevRankW2) ? sf.rank - sf.prevRankW2 : rdW1;
if (rdW1 >= 2 && rdW2 >= 2) {
score -= 15; reasons.push('sector_fading');
} else if (sf.rank <= 3 && rdW1 >= 1) {
score -= 10; reasons.push('sector_topping_out');
}
}
if (typeof df.ret5d === 'number' && df.ret5d > 0) { score += 10; reasons.push('ret5d_positive'); }
if (df.frg5d !== null && df.inst5d !== null && (df.frg5d + df.inst5d) > 0) { score += 25; reasons.push('smart_money_inflow'); }
if (typeof df.leaderTotal === 'number') { score += Math.min(20, df.leaderTotal * 5); reasons.push('leader_scan'); }
if (typeof df.avgTradeVal5d === 'number' && df.avgTradeVal5d >= 50) { score += 10; reasons.push('liquidity_ok'); }
if (closeVsMa20Pct !== null && closeVsMa20Pct >= 0 && closeVsMa20Pct <= 6) { score += 15; reasons.push('ma20_controlled_extension'); }
var lateChase = closeVsMa20Pct !== null && closeVsMa20Pct > 10;
if (closeVsMa20Pct !== null) {
if (closeVsMa20Pct > 10) lateChaseRisk += 60;
else if (closeVsMa20Pct > 6) lateChaseRisk += 25;
else if (closeVsMa20Pct > 3) lateChaseRisk += 10;
}
if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 60) {
lateChase = true;
lateChaseRisk += 25;
reasons.push('value_surge_extreme');
} else if (typeof df.valSurgePct === 'number' && df.valSurgePct >= 35) {
lateChaseRisk += 10;
}
if (distributionRow && distributionRow.anti_distribution_state === 'BLOCK_BUY') {
lateChase = true;
lateChaseRisk += 40;
reasons.push('distribution_block');
}
if (typeof df.dartRiskStatus === 'string' && df.dartRiskStatus !== 'OK') {
lateChase = true;
lateChaseRisk += 30;
reasons.push('dart_risk');
}
// N2: VOLUME_BREAKOUT_CONFIRM_V1 — 신고가 부근 거래량 미확인 시 뒷박 차단
var n2High52w = typeof df.high52w === 'number' && df.high52w > 0 ? df.high52w : 0;
var n2Vol = typeof df.volume === 'number' ? df.volume : 0;
var n2AvgVol5d = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : 0;
if (n2High52w > 0 && close > 0 && close >= n2High52w * 0.97) {
if (n2AvgVol5d > 0 && n2Vol < n2AvgVol5d * 1.2) {
score -= 10;
lateChaseRisk += 15;
reasons.push('unconfirmed_breakout_volume');
}
}
var state = lateChase ? 'BLOCKED_LATE_CHASE'
: score >= 75 ? 'PILOT_ALLOWED'
: score >= 55 ? 'WATCH_ONLY'
: 'WATCH_ONLY';
var buyState = state === 'PILOT_ALLOWED' ? 'ALLOW_PILOT' : (state === 'BLOCKED_LATE_CHASE' ? 'BLOCKED' : 'WATCH');
return {
ticker: h.ticker,
name: h.name || df.name || '',
alpha_lead_score: Math.min(100, Math.max(0, Math.round(score))),
lead_entry_state: state,
allowed_tranche_pct: state === 'PILOT_ALLOWED' ? 30 : 0,
buy_permission_state: buyState,
close_vs_ma20_pct: closeVsMa20Pct === null ? null : round2_(closeVsMa20Pct),
["late_chase_risk_score"]: Math.min(100, Math.max(0, Math.round(lateChaseRisk))),
blocked_reason_codes: lateChase ? ['late_chase_or_distribution'] : [],
reason_codes: reasons,
formula_id: 'ALPHA_LEAD_SCORE_V1'
};
}
function calcFollowThroughRow_(h, df) {
var close = df.close || h.close || 0;
var prevClose = df.prevClose || 0;
var ma5Proxy = prevClose || close;
var state = 'WAIT_PULLBACK';
var score = 25;
var reasons = [];
if (close > 0 && df.ma20 > 0 && close < df.ma20) {
state = 'FAILED_BREAKOUT'; reasons.push('close_below_ma20'); score = 0;
} else if (df.frg5d !== null && df.inst5d !== null && df.frg5d < 0 && df.inst5d < 0) {
state = 'FAILED_BREAKOUT'; reasons.push('dual_outflow'); score = 0;
} else if (close > 0 && ma5Proxy > 0 && close >= ma5Proxy && df.frg5d !== null && df.frg5d > 0) {
state = 'CONFIRMED_ADD_ON'; reasons.push('price_hold_and_foreign_inflow'); score = 100;
} else if (close > 0 && ma5Proxy > 0 && close >= ma5Proxy) {
score = 60;
}
return {
ticker: h.ticker,
name: h.name || df.name || '',
follow_through_state: state,
follow_through_score: score,
reason_codes: reasons,
formula_id: 'FOLLOW_THROUGH_CONFIRM_V1'
};
}
// --- Source: src/gas_adapter_parts/gdf_04_execution_quality.gs ---
function calcProfitPreservationRow_(h, df, priceRow, distributionRow) {
// THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/inject_computed_harness.py:trailing_stop_v2
var close = df.close || h.close || 0;
var avgCost = h.avgCost || 0;
var profitPct = close > 0 && avgCost > 0 ? (close - avgCost) / avgCost * 100 : 0;
var state = 'NORMAL';
var preserveScore = 100;
if (profitPct >= 30) state = 'PROFIT_LOCK_30';
else if (profitPct >= 20) state = 'PROFIT_LOCK_20';
else if (profitPct >= 10) state = 'PROFIT_LOCK_10';
else if (profitPct >= 8 || (df.atr20 > 0 && close >= avgCost + df.atr20)) state = 'BREAKEVEN_RATCHET';
if (state === 'PROFIT_LOCK_30') preserveScore = 20;
else if (state === 'PROFIT_LOCK_20') preserveScore = 40;
else if (state === 'PROFIT_LOCK_10') preserveScore = 60;
else if (state === 'BREAKEVEN_RATCHET') preserveScore = 80;
if (state === 'PROFIT_LOCK_30' && distributionRow && distributionRow.anti_distribution_state === 'PASS') {
state = 'APEX_TRAILING';
}
if (distributionRow && distributionRow.anti_distribution_state === 'BLOCK_BUY') {
preserveScore = Math.max(0, preserveScore - 15);
}
// L2: RATCHET_TRAILING_AUTO_V1 — ATR 기반 자동 트레일링 손절 계산
var atr20 = typeof df.atr20 === 'number' && df.atr20 > 0 ? df.atr20 : 0;
var ratchetStop = priceRow && typeof priceRow.stop_price === 'number' ? priceRow.stop_price : 0;
var highestClose = priceRow && typeof priceRow.highest_price_since_entry === 'number'
? priceRow.highest_price_since_entry : close;
var autoTrailingStop = null;
var autoTrailingNote = null;
if (atr20 > 0 && (state === 'PROFIT_LOCK_30' || state === 'APEX_TRAILING')) {
var raw = Math.max(ratchetStop, highestClose - 2.0 * atr20);
autoTrailingStop = tickNormalize_(raw);
autoTrailingNote = 'max(ratchet,' + highestClose + '-2.0×ATR)';
} else if (atr20 > 0 && state === 'PROFIT_LOCK_20') {
var raw = Math.max(ratchetStop, highestClose - 1.5 * atr20);
autoTrailingStop = tickNormalize_(raw);
autoTrailingNote = 'max(ratchet,' + highestClose + '-1.5×ATR)';
}
return {
ticker: h.ticker,
name: h.name || df.name || '',
profit_pct: round2_(profitPct),
profit_preservation_state: state,
rebound_preservation_score: Math.min(100, Math.max(0, Math.round(preserveScore))),
protected_stop_price: priceRow ? priceRow.stop_price : null,
ratchet_partial_qty: priceRow ? priceRow.ratchet_partial_qty : 0,
auto_trailing_stop: autoTrailingStop,
auto_trailing_note: autoTrailingNote,
formula_id: 'PROFIT_PRESERVATION_STATE_V1'
};
}
function calcExecutionQualityRow_(ticker, orderRow, df) {
var amount = orderRow && orderRow.order_amount_krw ? orderRow.order_amount_krw : 0;
var advKrw = 0;
if (typeof df.avgTradeVal5d === 'number') {
// AvgTradeValue_5D_M is usually million KRW in sheet label.
advKrw = df.avgTradeVal5d * 1000000;
}
var status = 'PASS';
var splitCount = 1;
var reasons = [];
if (amount > 0 && advKrw > 0 && amount > advKrw * 0.03) {
status = 'BLOCKED_ADV_3PCT'; reasons.push('order_amount_gt_3pct_adv');
} else if (amount > 0 && advKrw > 0 && amount > advKrw * 0.01) {
status = 'SPLIT_REQUIRED'; splitCount = 2; reasons.push('order_amount_gt_1pct_adv');
}
if (df.spreadStatus && String(df.spreadStatus).indexOf('WIDE') >= 0) {
status = 'BLOCKED_SPREAD'; reasons.push('wide_spread');
}
return {
ticker: ticker,
execution_quality_status: status,
split_count: splitCount,
child_order_amount_krw: splitCount > 1 ? Math.round(amount / splitCount) : amount,
hts_allowed: status === 'PASS',
reason_codes: reasons,
formula_id: 'EXECUTION_QUALITY_GUARD_V1'
};
}
// ── [2026-05-20_HARNESS_V5] H6: 뒷박 차단 — BREAKOUT_QUALITY_GATE_V2 ─────────
function calcBreakoutQualityGate_(h, df, alphaRow, distRow) {
var close = df.close || h.close || 0;
var prevClose = df.prevClose || close;
var ma20 = df.ma20 || 0;
var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : null;
var volume = typeof df.volume === 'number' ? df.volume : null;
var avgVol5d = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : null;
var ret1d = (close > 0 && prevClose > 0) ? (close - prevClose) / prevClose * 100 : null;
var ret3d = typeof df.ret5d === 'number' ? df.ret5d * 0.6 : null; // ret5d 프록시
var disparity = (close > 0 && ma20 > 0) ? (close / ma20 - 1) * 100 : null;
var timingScoreExit = alphaRow && typeof alphaRow.timing_score_exit === 'number' ? alphaRow.timing_score_exit : 0;
var distributionRiskScore = distRow && typeof distRow["distribution_risk_score"] === 'number' ? distRow["distribution_risk_score"] : 0;
var lateChaseRiskScore = alphaRow && typeof alphaRow["late_chase_risk_score"] === 'number' ? alphaRow["late_chase_risk_score"] : 0;
var score = 50;
var reasons = [];
if (ret3d !== null && ret3d >= 7) { score -= 30; reasons.push('ret3d_gte7'); }
if (disparity !== null && disparity > 10) { score -= 25; reasons.push('disparity_gt10'); }
if (ret1d !== null && ret1d >= 4 && volume !== null && avgVol5d !== null
&& avgVol5d > 0 && volume < avgVol5d * 0.9) { score -= 40; reasons.push('surge_day_low_vol'); }
if (rsi14 !== null && rsi14 > 75) { score -= 20; reasons.push('rsi14_gt75'); }
if (timingScoreExit >= 50) { score -= 50; reasons.push('timing_exit_gte50'); }
if (distributionRiskScore >= 70) { score -= 35; reasons.push('distribution_gte70'); }
if (lateChaseRiskScore >= 70) { score -= 30; reasons.push('late_chase_gte70'); }
if (volume !== null && avgVol5d !== null && avgVol5d > 0
&& volume >= avgVol5d * 1.5 && ret1d !== null && ret1d >= 2
&& ret3d !== null && ret3d < 5) { score += 25; reasons.push('quality_breakout_vol'); }
if (disparity !== null && disparity >= 0 && disparity < 6) { score += 15; reasons.push('disparity_healthy'); }
if (rsi14 !== null && rsi14 >= 45 && rsi14 <= 65) { score += 10; reasons.push('rsi14_healthy'); }
score = Math.max(0, Math.min(100, Math.round(score)));
var gate = score < 10 ? 'BLOCKED_LATE_CHASE' : score < 40 ? 'WATCH_COOLING_OFF' : 'PILOT_ALLOWED';
return {
ticker: h.ticker,
name: h.name || df.name || '',
breakout_quality_score: score,
breakout_quality_gate: gate,
reason_codes: reasons,
formula_id: 'BREAKOUT_QUALITY_GATE_V2',
version: '2026-05-20_HARNESS_V5'
};
}
// ── [2026-05-20_HARNESS_V5] H7: 가짜 매도 차단 — ANTI_WHIPSAW_HOLD_GATE_V1 ───
function calcAntiWhipsawGate_(h, df, kospiRet5d) {
var inst5d = typeof df.inst5d === 'number' ? df.inst5d : null;
var frg5d = typeof df.frg5d === 'number' ? df.frg5d : null;
var volSurge = typeof df.valSurgePct === 'number' ? df.valSurgePct : null;
var consecutiveSell5d = typeof df.consecutiveSellSignals5d === 'number'
? df.consecutiveSellSignals5d : 0;
var sectorRS5d = null;
if (typeof df.ret5d === 'number' && typeof kospiRet5d === 'number') {
var stockFactor = 1 + df.ret5d / 100;
var kospiFactor = 1 + kospiRet5d / 100;
sectorRS5d = kospiFactor > 0 ? stockFactor / kospiFactor * 100 : null;
}
var score = 0;
var reasons = [];
if (consecutiveSell5d >= 5) { score += 20; reasons.push('consecutive_sell_5d_gte5'); }
if (inst5d !== null && inst5d > 0) { score += 30; reasons.push('inst_net_buy'); }
if (frg5d !== null && frg5d > 0) { score += 20; reasons.push('frg_net_buy'); }
if (sectorRS5d !== null && sectorRS5d > 100) { score += 15; reasons.push('sector_outperforming'); }
if (volSurge !== null && volSurge >= 50) { score -= 25; reasons.push('vol_surge_50pct'); }
if (volSurge !== null && volSurge >= 100) { score -= 20; reasons.push('vol_surge_100pct'); }
score = Math.max(-50, Math.min(100, Math.round(score)));
// [V1.1] 자동 해제 조건 3개 — 충족 수에 따라 hold_days 결정
var wClose = h.close || df.close || 0;
var wMa20 = typeof df.ma20 === 'number' ? df.ma20 : 0;
var clearCnt = 0;
var clearList = [];
if (inst5d !== null && inst5d > 0) { clearCnt++; clearList.push('inst_net_buy'); }
if (frg5d !== null && frg5d > 0) { clearCnt++; clearList.push('frg_net_buy'); }
if (wMa20 > 0 && wClose > 0 && wClose > wMa20) { clearCnt++; clearList.push('price_above_ma20'); }
var gate, holdDays;
if (score >= 30) {
if (clearCnt >= 3) { gate = 'WHIPSAW_AUTO_RELEASED'; holdDays = 0; }
else if (clearCnt >= 2) { gate = 'WHIPSAW_WEAKENING'; holdDays = 1; }
else { gate = 'WHIPSAW_CONFIRMED'; holdDays = 3; }
} else if (score >= 10) {
gate = 'INCONCLUSIVE'; holdDays = 0;
} else {
gate = 'CONFIRMED_SELL'; holdDays = 0;
}
return {
ticker: h.ticker,
name: h.name || df.name || '',
anti_whipsaw_score: score,
anti_whipsaw_gate: gate,
anti_whipsaw_hold_days: holdDays,
clear_conditions_count: clearCnt,
clear_conditions: clearList,
reason_codes: reasons,
formula_id: 'ANTI_WHIPSAW_HOLD_GATE_V1',
version: '2026-05-24_V1.1'
};
}
// ── [2026-05-20_HARNESS_V5] H8: 4경로 결정론적 현금확보 라우터 ─────────────────
function calcSmartCashRaiseV2_(h, df, profitRow, priceRow, cashShortfallInfo) {
// THIN_ADAPTER: [stop_loss] delegated to Python — src/quant_engine/inject_computed_harness.py:cash_recovery
var posClass = String(h.positionClass || df.positionClass || '').toUpperCase();
var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : 50;
var profitStage = priceRow && priceRow.profit_lock_stage
? String(priceRow.profit_lock_stage)
: (profitRow ? String(profitRow.profit_preservation_state || 'NORMAL') : 'NORMAL');
var secularPass = priceRow && priceRow.secular_leader_gate_active === false; // PASS = not active restriction
var emergencyFull = !!(cashShortfallInfo && cashShortfallInfo.emergency_full_sell);
var stopPrice = priceRow && typeof priceRow.stop_price === 'number' ? priceRow.stop_price : 0;
var close = df.close || h.close || 0;
var breachImmediate = stopPrice > 0 && close > 0 && close < stopPrice;
var stopBreachGate = breachImmediate ? 'BREACH' : 'PASS';
var route, routeLabel, rationale;
if (emergencyFull || breachImmediate) {
route = 'ROUTE_D';
routeLabel = '긴급 전량매도';
rationale = emergencyFull ? 'emergency_full_sell=true' : 'close<stop_price(stop_breach_gate=BREACH)';
} else if (posClass.indexOf('SATELLITE') >= 0 && rsi14 >= 35) {
route = 'ROUTE_A';
routeLabel = '위성 비중 트림';
rationale = 'SATELLITE+RSI14(' + rsi14 + ')>=35';
} else if (rsi14 < 35) {
route = 'ROUTE_B';
routeLabel = '과매도 분할 매도';
rationale = 'RSI14(' + rsi14 + ')<35→K2_50/50';
} else if (posClass.indexOf('CORE') >= 0
&& (profitStage === 'PROFIT_LOCK_STAGE_20'
|| profitStage === 'PROFIT_LOCK_STAGE_30'
|| profitStage === 'PROFIT_LOCK_20'
|| profitStage === 'PROFIT_LOCK_30')
&& secularPass) {
route = 'ROUTE_C';
routeLabel = '코어 익절 잠금';
rationale = 'CORE+' + profitStage + '+secular_PASS';
} else {
route = 'NO_ACTION';
routeLabel = '현금확보 비대상';
rationale = 'no_condition_met';
}
return {
ticker: h.ticker,
name: h.name || df.name || '',
smart_cash_raise_route: route,
route_label: routeLabel,
rationale: rationale,
profit_lock_stage: profitStage,
stop_breach_gate: stopBreachGate,
emergency_full_sell: emergencyFull,
rebound_wait_pct: route === 'ROUTE_B' ? 50 : 0,
formula_id: 'SMART_CASH_RAISE_V2',
version: '2026-05-20_HARNESS_V5'
};
}
// ── [2026-05-20_HARNESS_V5] Gate 4b: O'Neil Follow-Through Day — FOLLOW_THROUGH_DAY_CONFIRM_V1
// 돌파 당일(Day 0)에 즉시 매수 금지. Day 2~7 사이에 수익률+거래량 조건 충족 시만 BUY_PILOT_ALLOWED.
// daysSinceBreakout / retSinceBreakout / volumeBreakoutDay 이 df에 없으면 프록시 계산으로 후퇴.
function calcFollowThroughDayConfirm_(h, df) {
var ticker = h.ticker;
var name = h.name || df.name || '';
// ── 입력 수집 (실제 필드 우선, 프록시 fallback) ──────────────────────────
var daysSince = typeof df.daysSinceBreakout === 'number' ? df.daysSinceBreakout : null;
var retSince = typeof df.retSinceBreakout === 'number' ? df.retSinceBreakout : null;
var volToday = typeof df.volume === 'number' ? df.volume : null;
var volBreakout = typeof df.volumeBreakoutDay === 'number' ? df.volumeBreakoutDay : null;
// 프록시: daysSinceBreakout — close vs MA20 돌파여부로 추정
// MA20 이하에서 위로 올라온 직후이면 daysSince=0, 그 이전이면 null
if (daysSince === null) {
var close = df.close || h.close || 0;
var ma20 = df.ma20 || 0;
var prevClose = df.prevClose || close;
// 오늘 ma20 상향 돌파면 Day 0
if (close > 0 && ma20 > 0 && close > ma20 && prevClose <= ma20) {
daysSince = 0;
}
// 이미 ma20 위에 있고 ret5d 존재 → days를 ret5d로 추정(보수적 5일 상한)
else if (close > 0 && ma20 > 0 && close > ma20 && typeof df.ret5d === 'number') {
// 5일 기준 프록시: 상승률이 클수록 이미 많이 경과했다고 가정
daysSince = df.ret5d >= 7 ? 8 : df.ret5d >= 3 ? 4 : 2;
}
}
// 프록시: retSinceBreakout — ret5d 사용
if (retSince === null && typeof df.ret5d === 'number') {
retSince = df.ret5d;
}
// 프록시: volBreakoutDay — avgVolume5d 사용
if (volBreakout === null && typeof df.avgVolume5d === 'number') {
volBreakout = df.avgVolume5d;
}
// ── 상태 분류 ──────────────────────────────────────────────────────────────
var state, result, reasons = [];
if (daysSince === null) {
state = 'PENDING_DATA';
result = 'WATCH_NO_BREAKOUT_TRACKED';
reasons.push('days_since_breakout_null');
} else if (daysSince === 0) {
state = 'BREAKOUT_DAY_1';
result = 'WATCH_FOLLOW_THROUGH_PENDING';
reasons.push('day0_no_immediate_buy');
} else if (daysSince > 7) {
state = 'EXTENDED_FOLLOW';
result = 'WATCH_TOO_LATE';
reasons.push('days_since_gt7');
} else {
// daysSince 2~7 범위
var volOk = (volToday !== null && volBreakout !== null && volBreakout > 0)
? (volToday >= volBreakout * 0.9) : true; // 데이터 없으면 통과
var retOk = (retSince !== null) ? (retSince >= 1.5) : false;
if (retOk && volOk) {
state = 'FOLLOW_THROUGH_OK';
result = 'BUY_PILOT_ALLOWED';
reasons.push('days_' + daysSince + '_ret_' + (retSince !== null ? retSince.toFixed(1) : 'N/A'));
if (volOk) reasons.push('vol_confirmed');
} else {
state = 'FOLLOW_THROUGH_FAIL';
result = 'WATCH_RESET_REQUIRED';
if (!retOk) reasons.push('ret_since_lt1.5pct');
if (!volOk) reasons.push('vol_lt90pct_breakout_day');
}
}
return {
ticker: ticker,
name: name,
days_since_breakout: daysSince,
ret_since_breakout: retSince,
vol_ratio_vs_breakout_day: (volToday !== null && volBreakout !== null && volBreakout > 0)
? Math.round(volToday / volBreakout * 100) / 100 : null,
follow_through_state: state,
follow_through_result: result,
reason_codes: reasons,
formula_id: 'FOLLOW_THROUGH_DAY_CONFIRM_V1',
version: '2026-05-20_HARNESS_V5'
};
}
function calcApexExecutionHarness_(holdings, dfMap, sectorFlowData, kospiRet5d, h1, h2, h3, h4, orderBlueprint, cashShortfallInfo, marketRegime) {
// THIN_ADAPTER: [sizing/decision] delegated to Python — src/quant_engine/inject_computed_harness.py:main
var alphaLead = [];
var followThrough = [];
var distribution = [];
var profitPreservation = [];
var entryFreshness = [];
var cashRaisePlan = [];
var reboundTriggers = [];
var smartSellQty = [];
var sellValuePreservation = [];
var executionQuality = [];
var buyPermission = [];
var limitPolicy = [];
var benchmarkRelativeRows = [];
var indexRelativeHealthRows = [];
var saqgRows = [];
var cashCreationLockRows = [];
// ── [2026-05-20_HARNESS_V5] 신규 V5 게이트 결과 배열
var breakoutQualityGate = [];
var antiWhipsawGate = [];
var smartCashRaiseV2 = [];
var followThroughConfirm = [];
var blockCount = 0;
var regime = marketRegime || 'UNKNOWN';
var priceMap = {};
(h4.prices || []).forEach(function(p) { priceMap[p.ticker] = p; });
var sellQtyMap = {};
(h3.sellQty || []).forEach(function(s) { sellQtyMap[s.ticker] = s; });
holdings.forEach(function(h) {
var df = dfMap[h.ticker] || {};
var distRow = calcDistributionRiskRow_(h, df, kospiRet5d, sectorFlowData);
// [PROPOSAL50] P1-B: DSD V1.1 — SIG_7/SIG_8 추가, weighted_sum 5.0/3.0 상향
applyDsdV1_1Signals_([distRow], dfMap);
var alphaRow = calcAlphaLeadRow_(h, df, sectorFlowData, distRow);
var ftRow = calcFollowThroughRow_(h, df);
var priceRow = priceMap[h.ticker] || {};
var profitRow = calcProfitPreservationRow_(h, df, priceRow, distRow);
var orderRow = findOrderBlueprintRow_(orderBlueprint, h.ticker) || {};
var eqRow = calcExecutionQualityRow_(h.ticker, orderRow, df);
var saqgState = df.saqg_v1 || (h.position_type === 'core' ? 'EXEMPT' : 'WATCHLIST_ONLY');
var cand = findCandidateByTicker_(h2.candidates, h.ticker) || {};
var sq = sellQtyMap[h.ticker] || {};
var tradePlan = calcApexTradePlan_(
h, df, h1, alphaRow, ftRow, distRow, priceRow, orderRow, sq, profitRow, cashShortfallInfo, saqgState
);
var buyState = tradePlan.buyState;
var buyReasons = tradePlan.buyReasons;
if (buyState === 'BLOCKED') blockCount++;
var style = tradePlan.style;
var immediateQty = tradePlan.immediateQty;
var reboundQty = tradePlan.reboundQty;
var k2Emergency = tradePlan.k2Emergency;
var tranchePhase = tradePlan.tranchePhase;
var currentTrancheAllowedPct = tradePlan.currentTrancheAllowedPct;
var nextTrancheCondition = tradePlan.nextTrancheCondition;
var normalizedSellPrice = tradePlan.normalizedSellPrice;
var normalizedBuyPrice = tradePlan.normalizedBuyPrice;
var htsLimitPrice = tradePlan.htsLimitPrice;
var close = h.close || df.close || 0;
var atr20 = df.atr20 || 0;
var holdingQty = h.holdingQty || 0;
var prevClose = df.prevClose || close;
// ── [2026-05-20_HARNESS_V5] V5 게이트 산출 ──────────────────────────────
var bqRow = calcBreakoutQualityGate_(h, df, alphaRow, distRow);
var awRow = calcAntiWhipsawGate_(h, df, kospiRet5d);
var scrV2 = calcSmartCashRaiseV2_(h, df, profitRow, priceRow, cashShortfallInfo);
var ftdRow = calcFollowThroughDayConfirm_(h, df);
// H6: 뒷박 차단 — BUY 상태 override
if (bqRow.breakout_quality_gate === 'BLOCKED_LATE_CHASE') {
if (buyState !== 'BLOCKED') { buyState = 'BLOCKED'; }
buyReasons.push('breakout_quality_BLOCKED_LATE_CHASE');
blockCount++;
}
// Gate 4b: FTD 미확인 — BUY 차단 (돌파 당일 즉시 매수 금지, 데이터 부재 시 WATCH로 후퇴)
if (ftdRow.follow_through_result === 'WATCH_FOLLOW_THROUGH_PENDING'
|| ftdRow.follow_through_result === 'WATCH_RESET_REQUIRED') {
if (buyState === 'ALLOW_PILOT') {
buyState = 'WATCH'; // PILOT → WATCH (BLOCKED 아님 — 관찰 유지)
buyReasons.push('ftd_' + ftdRow.follow_through_result);
}
} else if (ftdRow.follow_through_result === 'WATCH_TOO_LATE') {
if (buyState === 'ALLOW_PILOT') {
buyState = 'WATCH';
buyReasons.push('ftd_WATCH_TOO_LATE');
}
}
// H7: 가짜 매도 차단 — V1.1: CONFIRMED/WEAKENING만 보류 표기 (AUTO_RELEASED 제외)
if (awRow.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || awRow.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') {
buyReasons.push('whipsaw_hold_' + (awRow.anti_whipsaw_hold_days || 1) + 'd');
}
distribution.push(distRow);
alphaLead.push(alphaRow);
followThrough.push(ftRow);
profitPreservation.push(profitRow);
benchmarkRelativeRows.push({
ticker: h.ticker,
name: h.name || df.name || '',
stock_drawdown_from_high_pct: typeof df.stock_drawdown_from_high_pct === 'number' ? df.stock_drawdown_from_high_pct : null,
excess_drawdown_pctp: typeof df.excess_drawdown_pctp === 'number' ? df.excess_drawdown_pctp : null,
recovery_ratio_5d: typeof df.recovery_ratio_5d === 'number' ? df.recovery_ratio_5d : null,
recovery_ratio_20d: typeof df.recovery_ratio_20d === 'number' ? df.recovery_ratio_20d : null,
downside_beta: typeof df.downside_beta === 'number' ? df.downside_beta : null,
rs_line_20d_slope: typeof df.rs_line_20d_slope === 'number' ? df.rs_line_20d_slope : null,
rs_line_60d_slope: typeof df.rs_line_60d_slope === 'number' ? df.rs_line_60d_slope : null,
brt_verdict: df.brt_verdict || 'UNKNOWN',
brt_method: df.brt_method || 'DATA_MISSING',
formula_id: 'BENCHMARK_RELATIVE_TIMESERIES_V1'
});
var indexRelRow = calcIndexRelativeHealthGate_(h, df, kospiRet5d);
indexRelativeHealthRows.push(indexRelRow);
saqgRows.push({
ticker: h.ticker,
name: h.name || df.name || '',
position_type: h.position_type || 'unknown',
saqg_v1: saqgState,
saqg_penalty: typeof df.saqg_penalty === 'number' ? df.saqg_penalty : null,
saqg_failed_filters: df.saqg_failed_filters || '',
hts_allowed: saqgState === 'ELIGIBLE' || saqgState === 'EXEMPT',
formula_id: 'SATELLITE_ALPHA_QUALITY_GATE_V1'
});
breakoutQualityGate.push(bqRow);
antiWhipsawGate.push(awRow);
smartCashRaiseV2.push(scrV2);
followThroughConfirm.push(ftdRow);
executionQuality.push(eqRow);
// ── 진입 신선도 게이트 (ENTRY_FRESHNESS_GATE_V1) ───────────────────────
var freshnessState = 'FRESH_PILOT';
var freshnessReasons = [];
if (bqRow.breakout_quality_gate === 'BLOCKED_LATE_CHASE' || alphaRow["late_chase_risk_score"] >= 70) {
freshnessState = 'BLOCK_LATE_CHASE';
freshnessReasons.push('late_chase');
} else if (ftRow.follow_through_state === 'WAIT_PULLBACK' || ftdRow.follow_through_result === 'WATCH_TOO_LATE' || ftdRow.follow_through_result === 'WATCH_RESET_REQUIRED') {
freshnessState = 'PULLBACK_WAIT';
freshnessReasons.push('follow_through_wait');
} else if (distRow.pre_distribution_warning === 'EARLY_WARNING') {
freshnessState = 'STALE_REVIEW';
freshnessReasons.push('pre_distribution_warning');
} else if (buyState === 'WATCH' || buyState === 'BLOCKED') {
freshnessState = 'WATCH_FRESHNESS';
freshnessReasons.push('buy_state_' + buyState.toLowerCase());
}
if (indexRelRow.relative_health_state === 'DECOUPLED' || indexRelRow.relative_health_state === 'OVER_EXTENDED') {
freshnessState = freshnessState === 'FRESH_PILOT' ? 'WATCH_FRESHNESS' : freshnessState;
freshnessReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase());
if (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON') {
buyState = 'WATCH';
buyReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase());
}
} else if (indexRelRow.relative_health_state === 'UNDERPERFORMING') {
if (buyState === 'ALLOW_PILOT' || buyState === 'ALLOW_ADD_ON') {
buyState = 'WATCH';
}
freshnessReasons.push('index_relative_underperforming');
}
entryFreshness.push({
ticker: h.ticker,
name: h.name || df.name || '',
alpha_lead_score: alphaRow.alpha_lead_score != null ? alphaRow.alpha_lead_score : null,
["late_chase_risk_score"]: alphaRow["late_chase_risk_score"] != null ? alphaRow["late_chase_risk_score"] : null,
follow_through_state: ftRow.follow_through_state || null,
breakout_quality_gate: bqRow.breakout_quality_gate || null,
pre_distribution_warning: distRow.pre_distribution_warning || 'NONE',
t20_alpha_gate: null,
freshness_state: freshnessState,
reason_codes: freshnessReasons,
formula_id: 'ENTRY_FRESHNESS_GATE_V1'
});
// ── 회복 보존 매도 게이트 (SELL_VALUE_PRESERVATION_GATE_V1) ─────────────
var sellPreserveState = 'HOLD';
var sellPreserveReasons = [];
if (scrV2.smart_cash_raise_route === 'ROUTE_D' || k2Emergency || scrV2.stop_breach_gate === 'BREACH') {
sellPreserveState = 'EMERGENCY_EXIT';
sellPreserveReasons.push('route_d_or_breach');
} else if (awRow.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || awRow.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') {
sellPreserveState = 'REBOUND_CONFIRM_HOLD';
sellPreserveReasons.push('whipsaw_hold_' + (awRow.anti_whipsaw_hold_days || 1) + 'd');
} else if (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0) {
sellPreserveState = 'STAGED_REBOUND';
sellPreserveReasons.push('rebound_wait_qty');
} else if (profitRow.profit_preservation_state === 'PROFIT_LOCK_10'
|| profitRow.profit_preservation_state === 'PROFIT_LOCK_20'
|| profitRow.profit_preservation_state === 'PROFIT_LOCK_30'
|| profitRow.profit_preservation_state === 'APEX_TRAILING') {
sellPreserveState = 'PRESERVE_TIERED';
sellPreserveReasons.push('profit_lock');
} else if (distRow.anti_distribution_state === 'BLOCK_BUY') {
sellPreserveState = 'TRIM_ONLY';
sellPreserveReasons.push('distribution_exit');
} else if (indexRelRow.relative_health_state === 'OVER_EXTENDED' || indexRelRow.relative_health_state === 'DECOUPLED') {
if (style !== 'OVERSOLD_REBOUND_SELL') {
sellPreserveState = 'TRIM_ONLY';
}
sellPreserveReasons.push('index_relative_' + String(indexRelRow.relative_health_state).toLowerCase());
}
sellValuePreservation.push({
ticker: h.ticker,
name: h.name || df.name || '',
profit_preservation_state: profitRow.profit_preservation_state || 'NORMAL',
cash_raise_group: style,
anti_whipsaw_gate: awRow.anti_whipsaw_gate || null,
immediate_qty: immediateQty > 0 ? immediateQty : null,
rebound_wait_qty: reboundQty > 0 ? reboundQty : null,
auto_trailing_stop: profitRow.auto_trailing_stop || null,
sell_value_preservation_state: sellPreserveState,
reason_codes: sellPreserveReasons,
formula_id: 'SELL_VALUE_PRESERVATION_GATE_V1'
});
// K1: 트랜치 엔진 결과 포함 buy_permission_json
buyPermission.push({
ticker: h.ticker,
name: h.name || df.name || '',
buy_permission_state: buyState,
max_tranche_pct: buyState === 'ALLOW_PILOT' ? 30 : buyState === 'ALLOW_ADD_ON' ? 60 : 0,
tranche_phase: tranchePhase,
current_tranche_allowed_pct: currentTrancheAllowedPct,
next_tranche_condition: nextTrancheCondition,
blocked_reason_codes: buyReasons,
position_type: h.position_type || 'unknown',
brt_verdict: df.brt_verdict || null,
saqg_v1: saqgState,
rs_verdict: df.rs_verdict || null,
composite_verdict: df.composite_verdict || null,
rag_v1: df.rag_v1 || null,
formula_id: 'BUY_PERMISSION_MATRIX_V1+STAGED_ENTRY_TRANCHE_V1'
});
// K2: 반등 대기 분할 매도 결과 포함 cash_raise_plan_json
cashRaisePlan.push({
ticker: h.ticker,
name: h.name || df.name || '',
rank: cand.rank || null,
execution_style: style,
immediate_qty: immediateQty > 0 ? immediateQty : null,
rebound_wait_qty: reboundQty > 0 ? reboundQty : null,
emergency_full_sell: k2Emergency,
max_daily_qty: Math.floor(holdingQty * 0.50),
expected_immediate_krw: immediateQty > 0 ? Math.round(immediateQty * close) : 0,
cash_shortfall_min_krw: (cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw) || 0,
formula_id: 'SMART_CASH_RAISE_PLAN_V1+K2_STAGED_REBOUND_SELL'
});
// K2: 반등 트리거 조건부 잔여 수량
var reboundTriggerPrice = null;
if (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0) {
// 반등 트리거: prevClose + 0.5×ATR 또는 단순 close + 0.3×ATR
reboundTriggerPrice = atr20 > 0
? tickNormalize_((prevClose > 0 ? prevClose : close) + atr20 * 0.5)
: null;
}
reboundTriggers.push({
ticker: h.ticker,
rebound_trigger_state: (style === 'OVERSOLD_REBOUND_SELL' && reboundQty > 0)
? 'WAIT_REBOUND_TRIGGER' : 'NOT_APPLICABLE',
trigger_price: reboundTriggerPrice,
rebound_sell_qty: reboundQty > 0 ? reboundQty : null,
emergency_override: k2Emergency,
formula_id: 'REBOUND_SELL_TRIGGER_V1'
});
smartSellQty.push({
ticker: h.ticker,
immediate_sell_qty: immediateQty > 0 ? immediateQty : null,
staged_total_qty: (typeof sq.sell_qty === 'number' && sq.sell_qty > 0) ? sq.sell_qty : null,
rebound_wait_qty: reboundQty > 0 ? reboundQty : null,
emergency_full_sell: k2Emergency,
expected_cash_recovered_krw: immediateQty > 0 ? Math.round(immediateQty * close) : 0,
formula_id: 'SELL_QUANTITY_ALLOCATOR_V1+K2_STAGED_REBOUND_SELL'
});
// J5: 스타일별 실제 지정가 산출 결과 포함 limit_price_policy_json
limitPolicy.push({
ticker: h.ticker,
execution_style: style,
sell_limit_price: normalizedSellPrice,
buy_limit_price: normalizedBuyPrice,
hts_limit_price: htsLimitPrice,
tick_status: htsLimitPrice ? 'TICK_OK' : 'NO_EXECUTION_PRICE',
sell_price_basis: style === 'URGENT_LIQUIDITY_TRIM' ? 'min(close,prevClose×0.998)'
: style === 'OVERSOLD_REBOUND_SELL' ? 'close_no_undercut'
: style === 'DISTRIBUTION_EXIT' ? 'close-0.25×ATR20'
: style === 'PROFIT_PROTECT_TRIM' ? 'ratchet_stop_or_close×0.999'
: 'close',
formula_id: 'LIMIT_PRICE_POLICY_V1'
});
});
// K3: 국면·섹터 연계 H2 동적 우선순위
var regimeAdjPriority = calcRegimeAdjustedSellPriority_(
h2.candidates, regime, dfMap, kospiRet5d
);
// ── [2026-05-21_CLA_HARNESS_V1] SATELLITE_FAILURE_GATE_V1 ────────────────────
var satelliteRowsForSFG = [];
holdings.forEach(function(h) {
if (h.position_type !== 'core') {
var df = dfMap[h.ticker] || {};
satelliteRowsForSFG.push({
composite_verdict: df.composite_verdict || null,
rs_verdict: df.rs_verdict || null,
ret20d: typeof df.ret20d === 'number' ? df.ret20d : null,
excess_ret_10d: typeof df.excess_ret_10d === 'number' ? df.excess_ret_10d : null
});
}
});
var sfgResult = calcSatelliteFailureGate_(satelliteRowsForSFG);
var sapgResult = calcSatelliteAggregatePnlGate_(holdings);
holdings.forEach(function(h) {
var df = dfMap[h.ticker] || {};
cashCreationLockRows.push(calcCashCreationPurposeLockRow_(h, df, sfgResult));
});
// ── [2026-05-21_AEW_V1] ALPHA_EVALUATION_WINDOW_V1 ──────────────────────────
var aewRows = calcAlphaEvaluationWindow_(holdings, dfMap);
// SFG-1: TRIGGERED 시 위성 BUY 전면 차단 (post-processing)
if (sfgResult.sfg_v1 === 'TRIGGERED' || sapgResult.sapg_status === 'SAPG_CRITICAL') {
buyPermission.forEach(function(bp) {
var h = holdings.find(function(x) { return x.ticker === bp.ticker; });
if (h && h.position_type !== 'core') {
if (bp.buy_permission_state !== 'BLOCKED') {
bp.buy_permission_state = 'BLOCKED';
bp.blocked_reason_codes = (bp.blocked_reason_codes || []).concat([
sfgResult.sfg_v1 === 'TRIGGERED' ? 'sfg_v1_TRIGGERED' : 'sapg_CRITICAL'
]);
}
}
});
}
// ── [QEH010] WHIPSAW V1.1 → order_blueprint validation_status 소급 차단 ──
// V1.1: WHIPSAW_CONFIRMED(hold_3d) + WHIPSAW_WEAKENING(hold_1d) 차단
// WHIPSAW_AUTO_RELEASED(hold_0d)은 자동 해제 — 차단 안 함
var whipsawTickers_ = {};
antiWhipsawGate.forEach(function(aw) {
if (aw.anti_whipsaw_gate === 'WHIPSAW_CONFIRMED' || aw.anti_whipsaw_gate === 'WHIPSAW_WEAKENING') {
whipsawTickers_[aw.ticker] = aw.anti_whipsaw_hold_days || 1;
}
});
var SELL_ORDER_TYPES_ = { SELL: 1, TRIM: 1, EXIT_100: 1, EXIT_FULL: 1 };
orderBlueprint.forEach(function(bp) {
var wHoldDays = whipsawTickers_[bp.ticker];
if (wHoldDays
&& SELL_ORDER_TYPES_[bp.order_type]
&& bp.validation_status === 'PASS') {
bp.validation_status = 'BLOCKED';
bp.rationale_code = 'WHIPSAW_V1_1:hold_' + wHoldDays + 'd';
}
});
// ── [2026-05-20_HARNESS_V5] V5 포트폴리오 레벨 집계
var smartCashRaiseRoute = 'NO_ACTION';
for (var sci = 0; sci < smartCashRaiseV2.length; sci++) {
if (smartCashRaiseV2[sci].smart_cash_raise_route !== 'NO_ACTION') {
smartCashRaiseRoute = smartCashRaiseV2[sci].smart_cash_raise_route;
break; // 첫 번째 실제 경로를 포트폴리오 레벨 대표 경로로 설정
}
}
return {
alpha_lead_json: alphaLead,
follow_through_json: followThrough,
distribution_risk_json: distribution,
profit_preservation_json: profitPreservation,
entry_freshness_json: entryFreshness,
cash_raise_plan_json: cashRaisePlan,
rebound_sell_trigger_json: reboundTriggers,
smart_sell_quantities_json: smartSellQty,
sell_value_preservation_json: sellValuePreservation,
execution_quality_json: executionQuality,
buy_permission_json: buyPermission,
limit_price_policy_json: limitPolicy,
regime_adjusted_sell_priority_json: regimeAdjPriority,
benchmark_relative_timeseries_json: benchmarkRelativeRows,
index_relative_health_json: indexRelativeHealthRows,
saqg_json: saqgRows,
cash_creation_purpose_lock_json: cashCreationLockRows,
// ── [2026-05-20_HARNESS_V5] 신규 V5 출력 ──────────────────────────────
breakout_quality_gate_json: breakoutQualityGate,
anti_whipsaw_gate_json: antiWhipsawGate,
smart_cash_raise_json: smartCashRaiseV2,
smart_cash_raise_route: smartCashRaiseRoute,
follow_through_confirm_json: followThroughConfirm,
breakout_quality_gate_lock: true,
anti_whipsaw_gate_lock: true,
follow_through_lock: true,
follow_through_confirm_lock: true,
apex_block_count: blockCount,
// ── [2026-05-21_CLA_HARNESS_V1] 신규 하네스 출력 ──────────────────────────
satellite_failure_gate_json: sfgResult,
sapg_json: sapgResult,
// ── [2026-05-21_AEW_V1] ─────────────────────────────────────────────────────
alpha_evaluation_window_json: aewRows,
sfg_v1_lock: true
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// [2026-05-23_PROPOSAL46] PA1~PA5 신규 하네스 calc 함수
// spec/13b_harness_formulas.yaml: PA1 PREDICTIVE_ALPHA_ENGINE_V1
// PA2 ANTI_LATE_ENTRY_GATE_V2
// PA3 CASH_PRESERVATION_SELL_ENGINE_V2
// PA4 MACRO_EVENT_SYNCHRONIZER_V1
// PA5 CONSISTENCY_VALIDATOR_V2
// ═══════════════════════════════════════════════════════════════════════════════
/**
* [PROPOSAL47_B6 / PROPOSAL48_B6_FALLBACK] prediction_accuracy_rate 읽기.
* 우선순위: ① monthly_history.prediction_accuracy_rate
* ② settings.prediction_accuracy_rate
* ③ 상수 기본값 48.48 (운영 중 실측값으로 교체 예정)
* 값이 0~1 범위면 *100 변환, 0~100 범위면 그대로 사용.
*/
var PREDICTION_ACCURACY_RATE_DEFAULT_ = 48.48; // 2026-05-23 실측, 매월 갱신
function getPredictionAccuracyRate_() {
function parseAccuracy_(val) {
if (val === '' || val === null || val === undefined) return null;
var num = typeof val === 'number' ? val : parseFloat(String(val));
if (isNaN(num)) return null;
return num <= 1 ? Math.round(num * 1000) / 10 : num;
}
try {
var ss = getSpreadsheet_();
// ① monthly_history 시트
var sh = ss.getSheetByName('monthly_history');
if (sh) {
var mhData = sh.getDataRange().getValues();
if (mhData && mhData.length >= 2) {
var header = mhData[0] || [];
var colIdx = -1;
for (var i = 0; i < header.length; i++) {
if (String(header[i]).trim().toLowerCase() === 'prediction_accuracy_rate') {
colIdx = i; break;
}
}
if (colIdx >= 0) {
for (var r = mhData.length - 1; r >= 1; r--) {
var parsed = parseAccuracy_(mhData[r][colIdx]);
if (parsed !== null) return parsed;
}
}
}
}
// ② settings 시트 (Key-Value 구조)
var settingsSh = ss.getSheetByName('settings');
if (settingsSh) {
var sData = settingsSh.getDataRange().getValues();
for (var si = 0; si < sData.length; si++) {
var key = String(sData[si][0] || '').trim().toLowerCase();
if (key === 'prediction_accuracy_rate') {
var parsed2 = parseAccuracy_(sData[si][1]);
if (parsed2 !== null) return parsed2;
}
}
}
} catch(e) { /* fallback to default */ }
// ③ 상수 기본값
return PREDICTION_ACCURACY_RATE_DEFAULT_;
}
/**
* [PA1 V1.2] 팩터 가중치 오버라이드 읽
* settings 시트의 pa1_w_<factor> 키-값을 읽어 기본값과 병합.
* 오버라이드가 존재하면 _source='DYNAMIC', 없으면 'STATIC'.
*/
function getPa1WeightOverrides_() {
var defaults = {
pullback_entry: 20, flow_strong: 20, rs_leader: 15,
volume_confirm: 15, rsi_healthy: 15, brt_leader: 15,
chase_risk: 25, distribution: 20, rsi_overbought: 20,
foreign_sell: 15, usd_krw_weak: 10, stale_position: 10,
_source: 'STATIC'
};
try {
var ss = getSpreadsheet_();
var sh = ss.getSheetByName('settings');
if (!sh) return defaults;
var data = sh.getDataRange().getValues();
var overrides = {};
for (var i = 0; i < data.length; i++) {
var key = String(data[i][0] || '').trim();
if (key.indexOf('pa1_w_') !== 0) continue;
var factorName = key.slice(6); // 'pa1_w_' = 6자
var val = parseFloat(String(data[i][1] || ''));
if (!isNaN(val) && val >= 0 && val <= 50) overrides[factorName] = val;
}
if (Object.keys(overrides).length === 0) return defaults;
var merged = {};
for (var k in defaults) merged[k] = defaults[k];
for (var k in overrides) merged[k] = overrides[k];
merged._source = 'DYNAMIC';
return merged;
} catch(e) {
return defaults;
}
}
/**
* [PA1 V1.3] T+5 피드백 기록
* STRONG_BUY_SIGNAL / EXIT_SIGNAL / TRIM_SIGNAL 예측 → pa1_feedback 시트 기록.
* V1.3: TRIM_SIGNAL 추가, signal_type 컬럼 추가 (BUY/SELL 분리 정확도 추적)
* evaluatePa1FeedbackBatch_() 주간 배치에서 결과를 평가.
*/
function recordPa1FeedbackEntry_(paeRows, dfMap) {
if (!paeRows || !paeRows.length) return;
// [V1.3] TRIM_SIGNAL 추가
var RECORD_VERDICTS = { STRONG_BUY_SIGNAL: 1, EXIT_SIGNAL: 1, TRIM_SIGNAL: 1 };
var toRecord = paeRows.filter(function(pa) { return !!RECORD_VERDICTS[pa.synthesis_verdict]; });
if (!toRecord.length) return;
try {
var ss = getSpreadsheet_();
var sh = ss.getSheetByName('pa1_feedback');
if (!sh) {
sh = ss.insertSheet('pa1_feedback');
sh.appendRow(['date','ticker','synthesis_verdict','direction_confidence',
'close_at_record','signal_type','t5_evaluated','t5_return_pct','t5_correct']);
} else {
// [V1.3] signal_type 컬럼 없으면 헤더 확인 — 없어도 appendRow는 동작함
}
var today = Utilities.formatDate(new Date(), 'Asia/Seoul', 'yyyy-MM-dd');
toRecord.forEach(function(pa) {
var df = dfMap[pa.ticker] || {};
var closeNow = df.close || 0;
var signalType = (pa.synthesis_verdict === 'STRONG_BUY_SIGNAL') ? 'BUY' : 'SELL';
sh.appendRow([today, pa.ticker, pa.synthesis_verdict,
pa.direction_confidence, closeNow, signalType, false, '', '']);
});
} catch(e) {
Logger.log('[PA1_FEEDBACK] recordPa1FeedbackEntry_ error: ' + e.message);
}
}
/**
* [PA1 V1.3] 매도 PASS 정확도 조회
* pa1_feedback 시트에서 signal_type=SELL + t5_evaluated=true 행의 정확도 산출.
* @return {number|null} sell_pass_accuracy_rate (0~100) or null if insufficient data
*/
function getSellPassAccuracyRate_() {
try {
var ss = getSpreadsheet_();
var fbSh = ss.getSheetByName('pa1_feedback');
if (!fbSh) return null;
var data = fbSh.getDataRange().getValues();
if (data.length < 2) return null;
var header = data[0];
var COL = {};
header.forEach(function(h, i) { COL[String(h)] = i; });
if (COL['signal_type'] == null || COL['t5_evaluated'] == null || COL['t5_correct'] == null) return null;
var sellRows = data.slice(1).filter(function(row) {
return String(row[COL['signal_type']] || '').toUpperCase() === 'SELL'
&& (row[COL['t5_evaluated']] === true || String(row[COL['t5_evaluated']]).toUpperCase() === 'TRUE');
});
if (sellRows.length < 5) return null;
var correct = sellRows.filter(function(row) {
return row[COL['t5_correct']] === true || String(row[COL['t5_correct']]).toUpperCase() === 'TRUE';
}).length;
return Math.round(correct / sellRows.length * 1000) / 10;
} catch(e) {
Logger.log('[PA1_V1.3] getSellPassAccuracyRate_ error: ' + e.message);
return null;
}
}
/**
* [PA1 V1.2] 주간 배치 — T+5(7캘린더일) 결과 평가 + prediction_accuracy_rate 갱신
* GAS 트리거에 주 1회 등록해 사용 (매주 월요일 권장).
*/
function evaluatePa1FeedbackBatch_() {
try {
var ss = getSpreadsheet_();
var fbSh = ss.getSheetByName('pa1_feedback');
if (!fbSh) { Logger.log('[PA1_V1.2] pa1_feedback 시트 없음'); return; }
var data = fbSh.getDataRange().getValues();
if (data.length < 2) return;
var header = data[0];
var COL = {};
header.forEach(function(h, i) { COL[String(h)] = i; });
var reqCols = ['date','ticker','synthesis_verdict','close_at_record','t5_evaluated','t5_return_pct','t5_correct'];
for (var ci = 0; ci < reqCols.length; ci++) {
if (COL[reqCols[ci]] == null) { Logger.log('[PA1_V1.2] 컬럼 누락: ' + reqCols[ci]); return; }
}
// 현재 종가 맵 (data_feed 시트)
var priceMap = {};
var dfSheet = ss.getSheetByName('data_feed');
if (dfSheet) {
var dfData = dfSheet.getDataRange().getValues();
if (dfData.length > 1) {
var dfHeader = dfData[0];
var tCol = dfHeader.indexOf('Ticker');
var cCol = dfHeader.indexOf('Close');
if (tCol >= 0 && cCol >= 0) {
for (var ri = 1; ri < dfData.length; ri++) {
var t = String(dfData[ri][tCol] || '').trim();
var c = parseFloat(String(dfData[ri][cCol] || ''));
if (t && !isNaN(c) && c > 0) priceMap[t] = c;
}
}
}
}
var todayMs = new Date().getTime();
var evalThisRun = 0;
for (var i = 1; i < data.length; i++) {
var row = data[i];
var evaled = row[COL['t5_evaluated']];
if (evaled === true || String(evaled).toUpperCase() === 'TRUE') continue;
var daysDiff = (todayMs - new Date(row[COL['date']]).getTime()) / 86400000;
if (daysDiff < 7) continue;
var ticker = String(row[COL['ticker']] || '');
var verdict = String(row[COL['synthesis_verdict']] || '');
var closeAt = parseFloat(String(row[COL['close_at_record']] || ''));
var closeNow = priceMap[ticker] || 0;
if (closeAt <= 0 || closeNow <= 0) continue;
var t5Ret = Math.round((closeNow - closeAt) / closeAt * 10000) / 100;
var isCorrect = (verdict === 'STRONG_BUY_SIGNAL') ? (t5Ret > 0) : (t5Ret < 0);
fbSh.getRange(i + 1, COL['t5_evaluated'] + 1).setValue(true);
fbSh.getRange(i + 1, COL['t5_return_pct'] + 1).setValue(t5Ret);
fbSh.getRange(i + 1, COL['t5_correct'] + 1).setValue(isCorrect ? 'CORRECT' : 'WRONG');
evalThisRun++;
}
// prediction_accuracy_rate 갱신 (최소 10건 평가 완료 후)
var freshData = fbSh.getDataRange().getValues();
var allEval = 0, allCorrect = 0;
for (var j = 1; j < freshData.length; j++) {
var ev = freshData[j][COL['t5_evaluated']];
if (ev !== true && String(ev).toUpperCase() !== 'TRUE') continue;
allEval++;
if (String(freshData[j][COL['t5_correct']] || '') === 'CORRECT') allCorrect++;
}
if (allEval >= 10) {
var newRate = Math.round(allCorrect / allEval * 1000) / 10;
var settingSh = ss.getSheetByName('settings');
if (settingSh) {
var sData = settingSh.getDataRange().getValues();
var updated = false;
for (var si = 0; si < sData.length; si++) {
if (String(sData[si][0] || '').trim().toLowerCase() === 'prediction_accuracy_rate') {
settingSh.getRange(si + 1, 2).setValue(newRate);
updated = true;
break;
}
}
if (!updated) settingSh.appendRow(['prediction_accuracy_rate', newRate]);
Logger.log('[PA1_V1.2] prediction_accuracy_rate=' + newRate + '% (' + allCorrect + '/' + allEval + ')');
}
}
Logger.log('[PA1_V1.2] evaluatePa1FeedbackBatch_ 완료: 이번 평가=' + evalThisRun + '건');
// [PA1 V1.2] 정확도 기반 가중치 자동 조정 (평가 완료 후)
if (allEval >= 10) {
var accuracy7d = allCorrect / allEval;
adjustPaeWeights_();
}
} catch(e) {
Logger.log('[PA1_V1.2] evaluatePa1FeedbackBatch_ 오류: ' + e.message);
}
}
/**
* [PA1 V1.2] adjustPaeWeights_
* T+5 예측 정확도(7일) 기반으로 thesis/antithesis 가중치 자동 조정.
* 조정값을 settings 시트에 pa1_w_<factor> 형태로 기록 → 다음 실행 시 반영.
*/
function adjustPaeWeights_() {
try {
// 현재 precision 읽기
var accRate = getPredictionAccuracyRate_();
if (accRate === null) return; // 데이터 부족 시 조정 안 함
var accuracy = accRate / 100; // 0~1 범위로 변환
var ss = getSpreadsheet_();
var settingSh = ss.getSheetByName('settings');
if (!settingSh) return;
var sData = settingSh.getDataRange().getValues();
var currentWeights = {};
var rowIndex = {};
sData.forEach(function(row, i) {
var key = String(row[0] || '').trim().toLowerCase();
if (key.indexOf('pa1_w_') === 0) {
currentWeights[key] = parseFloat(String(row[1] || '')) || null;
rowIndex[key] = i + 1; // 1-based
}
});
// 기본 thesis/antithesis 총합 (12개 팩터 기본 가중치 합)
var DEFAULT_THESIS_TOTAL = 100; // 20+20+15+15+15+15
var DEFAULT_ANTI_TOTAL = 100; // 25+20+20+15+10+10
// 조정 방향 결정
var adjustThesis = 0;
var adjustAnti = 0;
if (accuracy < 0.55) {
// 정확도 낮음 → antithesis 강화 (+5% of base)
adjustThesis = -5;
adjustAnti = +5;
} else if (accuracy > 0.75) {
// 정확도 높음 → thesis 강화 (+3% of base)
adjustThesis = +3;
adjustAnti = 0;
} else {
Logger.log('[PA1_V1.2] adjustPaeWeights_: 정확도 정상범위(' + Math.round(accuracy*100) + '%) — 조정 불필요');
return;
}
// thesis 팩터 가중치 조정 (각 비례 분배)
var thesisFactors = ['pullback_entry','flow_strong','rs_leader','volume_confirm','rsi_healthy','brt_leader'];
var thesisDefaults = { pullback_entry: 20, flow_strong: 20, rs_leader: 15, volume_confirm: 15, rsi_healthy: 15, brt_leader: 15 };
thesisFactors.forEach(function(f) {
var key = 'pa1_w_' + f;
var baseW = thesisDefaults[f] || 0;
var currentW = currentWeights[key] != null ? currentWeights[key] : baseW;
var delta = Math.round(baseW / DEFAULT_THESIS_TOTAL * adjustThesis);
var newW = Math.max(5, Math.min(35, currentW + delta));
if (rowIndex[key]) {
settingSh.getRange(rowIndex[key], 2).setValue(newW);
} else {
settingSh.appendRow([key, newW]);
}
});
// antithesis 팩터 가중치 조정
var antiFactors = ['chase_risk','distribution','rsi_overbought','foreign_sell','usd_krw_weak','stale_position'];
var antiDefaults = { chase_risk: 25, distribution: 20, rsi_overbought: 20, foreign_sell: 15, usd_krw_weak: 10, stale_position: 10 };
antiFactors.forEach(function(f) {
var key = 'pa1_w_' + f;
var baseW = antiDefaults[f] || 0;
var currentW = currentWeights[key] != null ? currentWeights[key] : baseW;
var delta = Math.round(baseW / DEFAULT_ANTI_TOTAL * adjustAnti);
var newW = Math.max(5, Math.min(40, currentW + delta));
if (rowIndex[key]) {
settingSh.getRange(rowIndex[key], 2).setValue(newW);
} else {
settingSh.appendRow([key, newW]);
}
});
Logger.log('[PA1_V1.2] adjustPaeWeights_ 완료: accuracy=' + Math.round(accuracy*100) + '% adjustThesis=' + adjustThesis + ' adjustAnti=' + adjustAnti);
} catch(e) {
Logger.log('[PA1_V1.2] adjustPaeWeights_ 오류: ' + e.message);
}
}
/**
* updatePa1WeightsManual_
* PA1 팩터 가중치를 Work-1 승인값으로 settings 시트에 직접 기록.
* 근거: 기존 8.0x 획일 비율(thesis=30, anti=240) → 2.6x 차별화(thesis=70, anti=185)
* 효과: 모든 종목이 EXIT(-83~-95)로 획일화됐던 synthesis가 종목별 차별화됨
* (예: 000270 기아 +20 BULLISH / 005930 삼성전자 -18 BEARISH 등)
* 사용법: GAS 에디터 → updatePa1WeightsManual_ 선택 → 실행
*/
function updatePa1WeightsManual_() {
try {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var settingSh = ss.getSheetByName(SETTINGS_SHEET_NAME);
if (!settingSh) {
Logger.log('[updatePa1WeightsManual_] settings 시트를 찾을 수 없음');
return;
}
// Work-1 승인 PA1 가중치 (thesis 70pt, antithesis 185pt, ratio=2.6x)
var APPROVED_WEIGHTS = {
// Thesis 팩터 (개별종목 차별화 강화): 5→10~15
pa1_w_pullback_entry: 15, // 눌림목 진입 — 핵심 타이밍
pa1_w_flow_strong: 15, // 수급 강세
pa1_w_rs_leader: 10, // 상대강도 선도
pa1_w_volume_confirm: 10, // 거래량 확인
pa1_w_rsi_healthy: 10, // RSI 여력
pa1_w_brt_leader: 10, // BRT 선도
// Antithesis 팩터 (핵심만 유지, 획일화 해소): 일부 완화
pa1_w_chase_risk: 40, // 뒷박 위험 — 유지
pa1_w_distribution: 40, // 분배 신호 — 유지
pa1_w_rsi_overbought: 40, // RSI 과열 — 유지
pa1_w_foreign_sell: 30, // 외인 매도 — 완화 (단기 노이즈)
pa1_w_usd_krw_weak: 15, // 환율 약세 — 대폭 완화 (전 종목 동일 페널티 방지)
pa1_w_stale_position: 20 // 장기보유 페널티 — 완화
};
// settings 시트에서 기존 pa1_w_* 행 인덱스 수집
var data = settingSh.getDataRange().getValues();
var rowIndex = {};
data.forEach(function(row, i) {
var key = String(row[0] || '').trim().toLowerCase();
if (key.indexOf('pa1_w_') === 0) {
rowIndex[key] = i + 1; // 1-based
}
});
// 값 쓰기 (존재하면 업데이트, 없으면 추가)
var updated = []; var added = [];
Object.keys(APPROVED_WEIGHTS).forEach(function(key) {
var val = APPROVED_WEIGHTS[key];
if (rowIndex[key]) {
settingSh.getRange(rowIndex[key], 2).setValue(val);
updated.push(key + '=' + val);
} else {
settingSh.appendRow([key, val]);
added.push(key + '=' + val);
}
});
var thesisTotal = 15+15+10+10+10+10;
var antiTotal = 40+40+40+30+15+20;
Logger.log('[updatePa1WeightsManual_] 완료');
Logger.log(' 업데이트: ' + updated.join(', '));
Logger.log(' 신규 추가: ' + (added.length ? added.join(', ') : '없음'));
Logger.log(' thesis합=' + thesisTotal + 'pt antithesis합=' + antiTotal + 'pt ratio=' + (antiTotal/thesisTotal).toFixed(1) + 'x');
SpreadsheetApp.getUi().alert(
'PA1 가중치 업데이트 완료\n' +
'thesis합=' + thesisTotal + 'pt / antithesis합=' + antiTotal + 'pt (ratio=' + (antiTotal/thesisTotal).toFixed(1) + 'x)\n' +
'업데이트: ' + updated.length + '개 / 추가: ' + added.length + '개\n\n' +
'다음 runDataFeed 실행 시 새 가중치가 PA1 계산에 반영됩니다.'
);
} catch(e) {
Logger.log('[updatePa1WeightsManual_] 오류: ' + e.message);
SpreadsheetApp.getUi().alert('오류: ' + e.message);
}
}
/**
* PA4 — MACRO_EVENT_SYNCHRONIZER_V1
* 외국인 순매도 연속일·USD/KRW·FOMC·VIX 등 거시 변수를 macro_risk_score로 환산.
* heat_gate_adj(-3/-1/0/+1) 및 mega_sell_alert 산출.
* @param {Object} macroJson getMacroJson() 반환값
* @param {Array} eventRows getEventRiskJson().events (DaysLeft, Type 컬럼)
*/
function calcMacroEventSynchronizerV1_(macroJson, eventRows) {
return calcMacroEventSynchronizerV1Impl_(macroJson, eventRows);
}
/**
* PA1 — PREDICTIVE_ALPHA_ENGINE_V1
* 正(thesis) + 反(antithesis) = 合(direction_confidence) 3계층 점수.
* synthesis_verdict=BEARISH(EXIT/TRIM) → BUY 차단 근거.
* @param {Array} holdings
* @param {Object} dfMap
* @param {Object} macroJson getMacroJson() 반환값
* @param {Object} mesResult calcMacroEventSynchronizerV1_ 반환값
*/
function calcPredictiveAlphaEngineV1_(holdings, dfMap, macroJson, mesResult, weightOverrides) {
return calcPredictiveAlphaEngineV1Impl_(holdings, dfMap, macroJson, mesResult, weightOverrides);
}
/**
* PA2 — ANTI_LATE_ENTRY_GATE_V2
* 3중 AND 게이트: velocity_1d / velocity_5d / distribution_weighted_sum.
* ANTI_CHASING_VELOCITY_V1을 완전 대체.
* @param {Array} holdings
* @param {Object} dfMap
*/
function calcAntiLateEntryGateV2_(holdings, dfMap) {
return calcAntiLateEntryGateV2Impl_(holdings, dfMap);
}
/**
* PA3 — CASH_PRESERVATION_SELL_ENGINE_V2
* K2(분할) + C1(폭포수) + C2(타이밍)를 통합. 매도 스타일 결정 + value_preservation_score.
* h3.sellQty에 수량이 있는 종목만 처리.
* @param {Array} holdings
* @param {Object} dfMap
* @param {Object} cashShortfallInfo calcCashShortfallHarness_ 반환값
* @param {Object} h3 calcQuantities_ 반환값 (.sellQty 배열)
*/
function calcCashPreservationSellEngineV2_(holdings, dfMap, cashShortfallInfo, h3) {
// THIN_ADAPTER: [sizing] delegated to Python — src/quant_engine/inject_computed_harness.py:cash_recovery
var shortfallKrw = (cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw) || 0;
var sellQtyMap = {};
((h3 && h3.sellQty) || []).forEach(function(sq) {
if (typeof sq.sell_qty === 'number' && sq.sell_qty > 0) {
sellQtyMap[sq.ticker] = Math.floor(sq.sell_qty);
}
});
var rows = [];
holdings.forEach(function(h) {
var df = dfMap[h.ticker] || {};
var baseQty = sellQtyMap[h.ticker] || 0;
if (baseQty <= 0 && shortfallKrw <= 0) return;
var close = h.close || df.close || 0;
var prevClose = df.prevClose || close;
var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : 50;
var atr20 = typeof df.atr20 === 'number' ? df.atr20 : (close * 0.02);
var stopPrice = h.stopPrice || 0;
var frg5d = typeof df.frg5d === 'number' ? df.frg5d : 0;
var inst5d = typeof df.inst5d === 'number' ? df.inst5d : 0;
var volume = typeof df.volume === 'number' ? df.volume : 0;
var avgVol5d = typeof df.avgVolume5d === 'number' ? df.avgVolume5d : 0;
// 현금 부족 시 baseQty 추정 (h3 미포함 종목)
if (baseQty <= 0 && shortfallKrw > 0 && close > 0) {
baseQty = Math.min(Math.floor(shortfallKrw / close), h.holdingQty || 0);
}
if (baseQty <= 0) return;
// distribution weighted_sum (inline)
var distWS = 0;
if (frg5d < 0) distWS += 2.0;
if (inst5d < 0) distWS += 2.0;
if (avgVol5d > 0 && volume > avgVol5d * 1.3) distWS += 1.5;
if (prevClose > 0 && close < prevClose) distWS += 1.5;
if (rsi14 > 70) distWS += 1.0;
if (df.acGate === 'BLOCK') distWS += 1.0;
var emergencyFullSell = h.stopBreach === true;
// ── execution_style 결정 ─────────────────────────────────────────────────
var execStyle;
if (emergencyFullSell) execStyle = 'EMERGENCY_FULL_EXIT';
else if (rsi14 < 30) execStyle = 'OVERSOLD_REBOUND_SELL';
else execStyle = 'STAGED_WATERFALL';
// ── 수량 산출 ────────────────────────────────────────────────────────────
var immediateQty = 0, reboundWaitQty = 0, reboundTriggerPrice = 0, reboundDeadlineDays = 0;
if (execStyle === 'OVERSOLD_REBOUND_SELL') {
immediateQty = Math.floor(baseQty * 0.50);
reboundWaitQty = baseQty - immediateQty;
// TICK_NORMALIZER_V1 간소화: 10원 단위 반올림
reboundTriggerPrice = Math.round((prevClose + 0.5 * atr20) / 10) * 10;
reboundDeadlineDays = 3;
} else if (execStyle === 'EMERGENCY_FULL_EXIT') {
immediateQty = baseQty;
reboundWaitQty = 0;
reboundTriggerPrice = 0;
reboundDeadlineDays = 0;
} else {
immediateQty = Math.floor(baseQty * 0.50);
reboundWaitQty = baseQty - immediateQty;
reboundTriggerPrice = prevClose > 0 ? prevClose : close;
reboundDeadlineDays = 5;
}
// ── rebound_scenario ─────────────────────────────────────────────────────
var limitPrice = prevClose > 0 ? prevClose : close;
var immediateKrw = immediateQty * limitPrice;
var reboundUpsideKrw = reboundWaitQty * (reboundTriggerPrice > 0 ? reboundTriggerPrice : limitPrice);
var downsideRiskKrw = reboundWaitQty * (stopPrice > 0 ? stopPrice : close * 0.92);
var rrNum = reboundUpsideKrw - immediateKrw;
var rrDen = Math.max(1, immediateKrw - downsideRiskKrw);
var riskRewardRatio = round2_(rrNum / rrDen);
// ── value_preservation_score ─────────────────────────────────────────────
var vpScore = 100;
if (immediateQty >= baseQty && rsi14 < 30) vpScore -= 30; // full_sell_oversold
if (distWS >= 3.0) vpScore -= 15; // distribution_high
if (reboundWaitQty > 0) vpScore += 15; // rebound_wait_exists
if (reboundTriggerPrice > 0 && limitPrice > 0
&& reboundTriggerPrice <= limitPrice * 1.03) vpScore += 10; // tight_trigger
vpScore = Math.max(0, Math.min(100, Math.round(vpScore)));
rows.push({
ticker: h.ticker,
name: h.name || df.name || '',
execution_style: execStyle,
base_qty: baseQty,
immediate_qty: immediateQty,
rebound_wait_qty: reboundWaitQty,
rebound_trigger_price: reboundTriggerPrice,
rebound_deadline_days: reboundDeadlineDays,
risk_reward_ratio: riskRewardRatio,
value_preservation_score: vpScore,
immediate_sell_krw: Math.round(immediateKrw),
rebound_upside_krw: Math.round(reboundUpsideKrw),
emergency_full_sell_flag: emergencyFullSell,
sell_value_damage_warning: vpScore < 50,
dist_weighted_sum: Math.round(distWS * 10) / 10,
formula_id: 'CASH_PRESERVATION_SELL_ENGINE_V2'
});
});
return rows;
}
/**
* PA5 — CONSISTENCY_VALIDATOR_V2
* 12개 논리 검증 항목으로 hApex 일관성 점검. score < 90 → cv_verdict=BLOCK.
* Sprint C 마지막에 실행 — 이전 PA1~PA4 결과까지 모두 포함한 hApex 검증.
* @param {Object} hApex
* @param {Object} asResult
* @param {Object} cashFloorInfo
* @param {string} capturedAtIso
* @param {Date} now
*/
function calcConsistencyValidatorV2_(hApex, asResult, cashFloorInfo, capturedAtIso, now) {
return calcConsistencyValidatorV2Impl_(hApex, asResult, cashFloorInfo, capturedAtIso, now);
}
/**
* [PROPOSAL47_A1] WATCH_BREAKOUT_REALTIME_GATE_V1
* REVIEW / EXIT 라이프사이클 단계의 보유 종목 중 velocity_1d >= 2.0% 급등 탐지.
* 감시 중 급등 누락(49건 근본 원인) 해결 — 당일 급등 감지 시 후보 승격 검토 신호 생성.
* anti_late_entry_grade가 F(BLOCK)인 경우 승격 제외 (추격 매수 방지).
*
* @param {Array} holdings asResult.holdings
* @param {Object} dfMap 종목별 데이터 피드
* @param {Array} slgRows satellite_lifecycle_gate_json (lifecycle_stage 포함)
* @param {Array} aleRows anti_late_entry_json (entry_grade 포함, F면 제외)
* @returns {Array} watch_breakout_candidates_json
*/
function calcWatchBreakoutRealtimeGateV1_(holdings, dfMap, slgRows, aleRows) {
var VELOCITY_THRESHOLD = 2.0;
var REVIEW_STAGES = ['REVIEW', 'EXIT'];
var slgMap = {};
(slgRows || []).forEach(function(r) {
slgMap[String(r.ticker || '')] = String(r.lifecycle_stage || '');
});
var aleMap = {};
(aleRows || []).forEach(function(r) {
aleMap[String(r.ticker || '')] = r;
});
var results = [];
(holdings || []).forEach(function(h) {
var ticker = String(h.ticker || '');
var stage = slgMap[ticker] || '';
if (REVIEW_STAGES.indexOf(stage) < 0) return;
var df = dfMap[ticker] || {};
var close = Number(df.close || h.close || 0);
var prevClose = Number(df.prevClose || 0);
if (close <= 0 || prevClose <= 0) return;
var velocity1d = Math.round((close - prevClose) / prevClose * 10000) / 100;
if (velocity1d < VELOCITY_THRESHOLD) return;
var aleEntry = aleMap[ticker] || {};
var aleGrade = aleEntry.entry_grade || 'B';
if (aleGrade === 'F') return; // 추격매수 방지: anti_late_entry_grade F 제외
results.push({
ticker: ticker,
name: h.name || df.name || '',
lifecycle_stage: stage,
velocity_1d: velocity1d,
promotion_signal: 'WATCH_BREAKOUT',
anti_late_entry_grade: aleGrade,
formula_id: 'WATCH_BREAKOUT_REALTIME_GATE_V1'
});
});
return results;
}
/**
* [PROPOSAL48_A3] ANTI_WHIPSAW_REENTRY_GATE_V1
* 매도 압박(tier=1/2) 종목이 당일 +3% 이상 급반등 시 REENTRY_CANDIDATE 마킹.
* 9건 "매도 신호 후 반등" 패턴 처리. 매도 실행 전 재검토 신호 제공.
*
* @param {Array} sellCandidates hApex.sell_candidates_json (tier, ticker, action 포함)
* @param {Object} dfMap 종목별 데이터 피드
* @param {Array} holdings asResult.holdings
* @returns {Array} anti_whipsaw_reentry_json
*/
function calcAntiWhipsawReentryGateV1_(sellCandidates, dfMap, holdings) {
var REENTRY_VELOCITY_THRESHOLD = 3.0; // 재진입 급반등 기준: +3%
var WHIPSAW_TIERS = [1, 2]; // 즉시·단계 매도 압박 대상
var results = [];
(sellCandidates || []).forEach(function(cand) {
var tier = typeof cand.tier === 'number' ? cand.tier : parseInt(cand.tier) || 99;
if (WHIPSAW_TIERS.indexOf(tier) < 0) return;
var ticker = cand.ticker;
var df = dfMap[ticker] || {};
var h = (holdings || []).find(function(x) { return x.ticker === ticker; }) || {};
var close = h.close || df.close || 0;
var prevClose = df.prevClose || 0;
if (close <= 0 || prevClose <= 0) return;
var velocity1d = Math.round((close - prevClose) / prevClose * 10000) / 100;
if (velocity1d < REENTRY_VELOCITY_THRESHOLD) return;
var profitPct = h.avgCost > 0
? Math.round((close - h.avgCost) / h.avgCost * 1000) / 10
: null;
var rsi14 = typeof df.rsi14 === 'number' ? df.rsi14 : null;
// 재진입 등급: A(rsi<50 + rs_leader), B(rsi<60), C(기본)
var reentryGrade = 'C';
if (rsi14 !== null && rsi14 < 50 && df.rs_verdict === 'LEADER') reentryGrade = 'A';
else if (rsi14 !== null && rsi14 < 60) reentryGrade = 'B';
results.push({
ticker: ticker,
name: h.name || df.name || '',
sell_tier: tier,
sell_action: cand.action || '',
velocity_1d: velocity1d,
close: close,
prev_close: prevClose,
rsi14: rsi14,
rs_verdict: df.rs_verdict || '',
profit_pct: profitPct,
reentry_grade: reentryGrade,
reentry_signal: 'REENTRY_CANDIDATE',
whipsaw_warning: '매도 압박 중 반등 — 실행 전 재검토 권고',
formula_id: 'ANTI_WHIPSAW_REENTRY_GATE_V1'
});
});
return results;
}
/**
* [PROPOSAL48_C7] getAlphaHistorySummary_
* alpha_history 시트의 T20/T60 alpha gate 결과를 집계.
* 위성 종목의 장기 알파 생성 능력 추적 — T+5 피드백 루프 대용 지표.
* DATA_INSUFFICIENT 상태에서도 구조를 갖춰 LLM 참조 가능하게 유지.
*/
function getAlphaHistorySummary_() {
try {
var ss = getSpreadsheet_();
var sh = ss.getSheetByName('alpha_history');
if (!sh) return { status: 'NO_SHEET', formula_id: 'ALPHA_HISTORY_SUMMARY_V1' };
var rows = sh.getDataRange().getValues();
if (!rows || rows.length < 2) return { status: 'EMPTY', formula_id: 'ALPHA_HISTORY_SUMMARY_V1' };
var header = rows[0].map(function(h) { return String(h).trim(); });
var idx = {};
['Ticker','T20_Alpha_Gate','T60_Alpha_Gate','T20_Vs_Core_Pctp','T60_Vs_Core_Pctp','SAQG_Grade_At_Entry'].forEach(function(k) {
idx[k] = header.indexOf(k);
});
var t20 = { total: 0, pass: 0, fail: 0, missing: 0 };
var t60 = { total: 0, pass: 0, fail: 0, missing: 0 };
var gradeCount = {};
for (var r = 1; r < rows.length; r++) {
var row = rows[r];
var g20 = idx['T20_Alpha_Gate'] >= 0 ? String(row[idx['T20_Alpha_Gate']] || '') : '';
var g60 = idx['T60_Alpha_Gate'] >= 0 ? String(row[idx['T60_Alpha_Gate']] || '') : '';
var grade = idx['SAQG_Grade_At_Entry'] >= 0 ? String(row[idx['SAQG_Grade_At_Entry']] || '') : '';
if (g20 && g20 !== 'PENDING') {
t20.total++;
if (g20 === 'PASS') t20.pass++;
else if (g20 === 'FAIL') t20.fail++;
else t20.missing++;
}
if (g60 && g60 !== 'PENDING') {
t60.total++;
if (g60 === 'PASS') t60.pass++;
else if (g60 === 'FAIL') t60.fail++;
else t60.missing++;
}
if (grade) gradeCount[grade] = (gradeCount[grade] || 0) + 1;
}
var t20Rate = t20.total > 0 ? Math.round(t20.pass / t20.total * 1000) / 10 : null;
var t60Rate = t60.total > 0 ? Math.round(t60.pass / t60.total * 1000) / 10 : null;
return {
status: (t20.total > 0 || t60.total > 0) ? 'OK' : 'DATA_INSUFFICIENT',
t20_total: t20.total,
t20_pass_rate: t20Rate,
t20_pass: t20.pass,
t20_fail: t20.fail,
t60_total: t60.total,
t60_pass_rate: t60Rate,
t60_pass: t60.pass,
t60_fail: t60.fail,
grade_count: gradeCount,
total_rows: rows.length - 1,
formula_id: 'ALPHA_HISTORY_SUMMARY_V1'
};
} catch(e) {
return { status: 'ERROR', error: e.message, formula_id: 'ALPHA_HISTORY_SUMMARY_V1' };
}
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P0-1: EXPORT_GATE_V1 — PENDING_EXPORT 원인 자동 진단
// Direction G5: PENDING_EXPORT 원인 진단 의무
// ═══════════════════════════════════════════════════════════════════════
/**
* calcExportGate_
* 5개 체크리스트 자동 평가 → EXPORT_READY / PENDING_EXPORT
* PASS 전 HTS 입력 금지 조건을 결정론적으로 산출.
*/
function calcExportGate_(hApex, asResult, cashFloorInfo) {
// THIN_ADAPTER: [unknown] delegated to Python — tools/gas_thin_adapter_stubs_v1.py:stub_calc_export_gate
var checks = [];
// CHECK_1: account_snapshot 캡처 완료 여부
var captureRequired = !(asResult && asResult.holdings && asResult.holdings.length > 0
&& asResult.settlementCashD2Krw > 0);
checks.push({
check_id: 'CHECK_1_SNAPSHOT_CAPTURED',
status: captureRequired ? 'FAIL' : 'PASS',
message: captureRequired
? 'account_snapshot 미캡처 — HTS 화면 캡처 후 재실행 필요'
: 'account_snapshot OK'
});
// CHECK_2: 데이터 완성도 (buy_permission_json 기준 전 종목 존재)
var bpJson = (hApex && hApex.buy_permission_json) || [];
var holdingCount = (asResult && asResult.holdings) ? asResult.holdings.length : 0;
var dataOk = holdingCount > 0 && bpJson.length >= holdingCount;
checks.push({
check_id: 'CHECK_2_DATA_COMPLETENESS',
status: dataOk ? 'PASS' : 'FAIL',
message: dataOk
? 'data_feed 완성도 OK (' + bpJson.length + '/' + holdingCount + ')'
: 'data_feed 누락 — npm run convert-data-json 후 재실행'
});
// CHECK_3: 하네스 무결성 체크섬 (consistency_score 기준)
var cvScore = (hApex && typeof hApex.consistency_score === 'number') ? hApex.consistency_score : null;
var cvOk = cvScore !== null && cvScore >= 70;
checks.push({
check_id: 'CHECK_3_HARNESS_INTEGRITY',
status: cvOk ? 'PASS' : 'FAIL',
message: cvOk
? 'consistency_score=' + cvScore + ' 무결성 OK'
: 'consistency_score=' + (cvScore !== null ? cvScore : 'null') + ' — 70 미만 또는 미산출'
});
// CHECK_4: SELL_PRICE_SANITY — INVALID 주문 없음
var blueprint = (hApex && hApex.order_blueprint_json) || [];
var invalidPrices = blueprint.filter(function(b) {
return String(b.validation_status || '').indexOf('INVALID') >= 0;
});
checks.push({
check_id: 'CHECK_4_NO_INVALID_PRICES',
status: invalidPrices.length === 0 ? 'PASS' : 'FAIL',
message: invalidPrices.length === 0
? 'SELL_PRICE_SANITY 이상 없음'
: 'INVALID 가격 ' + invalidPrices.length + '건: ' +
invalidPrices.map(function(b) { return b.ticker; }).join(',')
});
// CHECK_5: cashFloor 블록 상태 확인 (HARD_BLOCK 시 현금 부족 경보)
var cashStatus = (cashFloorInfo && cashFloorInfo.status) || 'UNKNOWN';
var cashOk = cashStatus !== 'UNKNOWN';
checks.push({
check_id: 'CHECK_5_CASH_LEDGER',
status: cashOk ? 'PASS' : 'WARN',
message: cashOk
? 'cash_floor_status=' + cashStatus + ' (기록됨)'
: 'cash_floor_status=UNKNOWN — settlement_cash_d2_krw 확인 필요'
});
// [PROPOSAL51] P1-A: CHECK_6 — SCRS_RENDER 검증 (immediate_sell_qty 유효값 필수)
var scrsV2 = (hApex && hApex.scrs_v2_json) || {};
// [PROPOSAL51-FIX] GAS는 immediate_qty 반환 (calcSmartCashRecoverySell_ 확인)
var scrsRows = scrsV2.selected_combo || scrsV2.candidates || scrsV2.rows || [];
var scrsRenderOk = scrsRows.length === 0 || scrsRows.every(function(r) {
var qty = r.immediate_qty !== undefined ? r.immediate_qty : r.immediate_sell_qty;
return qty !== null && qty !== undefined && qty !== '-' && qty !== '';
});
checks.push({
check_id: 'CHECK_6_SCRS_RENDER',
status: scrsRenderOk ? 'PASS' : 'WARN',
message: scrsRenderOk
? 'SCRS-V2 immediate_sell_qty 렌더링 OK'
: 'SCRS-V2 immediate_sell_qty 누락 — render_operational_report 키 불일치 확인 필요'
});
// [PROPOSAL51] P1-A: CHECK_7 — PORTFOLIO_HEALTH_SCORE 타입 (Boolean 금지)
var healthScore = hApex && hApex.portfolio_health_score;
var healthTypeOk = (typeof healthScore === 'number' && !isNaN(healthScore));
checks.push({
check_id: 'CHECK_7_HEALTH_SCORE_TYPE',
status: healthTypeOk ? 'PASS' : 'WARN',
message: healthTypeOk
? 'portfolio_health_score=' + healthScore + ' (숫자 OK)'
: 'portfolio_health_score=' + JSON.stringify(healthScore) + ' — 숫자여야 함 (Boolean/null 금지)'
});
// [PROPOSAL51] P1-A: CHECK_8 — CLUSTER_SYNC 교정 없음 확인
var clusterSync = (hApex && hApex.cluster_sync_result_json) || {};
var clusterSyncOk = clusterSync.status === 'SYNCED' || !clusterSync.status;
checks.push({
check_id: 'CHECK_8_CLUSTER_SYNC',
status: clusterSyncOk ? 'PASS' : 'WARN',
message: clusterSyncOk
? 'SEMICONDUCTOR_CLUSTER_SYNC: 정합성 OK'
: 'CLUSTER_SYNC 교정 발생 (cluster_pct=' + (clusterSync.cluster_pct || '?')
+ '%, threshold=' + (clusterSync.threshold_pct || '?') + '%)'
});
var failChecks = checks.filter(function(c) { return c.status === 'FAIL'; });
var warnChecks = checks.filter(function(c) { return c.status === 'WARN'; });
var exportStatus;
if (failChecks.length > 0) exportStatus = 'PENDING_EXPORT';
else if (warnChecks.length > 0) exportStatus = 'REVIEW_ONLY';
else exportStatus = 'EXPORT_READY';
var htsAllowed = exportStatus === 'EXPORT_READY';
var nonPassChecks = checks.filter(function(c) { return c.status !== 'PASS'; });
var resolutionGuide = nonPassChecks.map(function(c) {
return '[' + c.check_id + '] ' + c.message;
});
return {
json_validation_status: exportStatus,
export_gate_status: exportStatus,
all_checks_passed: failChecks.length === 0 && warnChecks.length === 0,
checks: checks,
failed_checks: failChecks.map(function(c) { return c.check_id; }),
warn_checks: warnChecks.map(function(c) { return c.check_id; }),
resolution_guide: resolutionGuide,
hts_entry_allowed: htsAllowed,
formula_id: 'EXPORT_GATE_V2'
};
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P0-2: ROUTING_TRACE_V1 — 라우팅 Trace 필수 출력 (Direction G4)
// ═══════════════════════════════════════════════════════════════════════
/**
* buildRoutingTrace_
* 모든 보고서 선행 출력 의무 — request_route, bundle, prompt, 검증 상태 etc.
* 누락 시 보고서 전체 INCOMPLETE_REPORT.
*/
function buildRoutingTrace_(intradayLock, cashFloorInfo, hApex, capturedAtIso) {
var scope = intradayLock ? 'TRIM_ONLY' : 'FULL_ANALYSIS';
var bundleSelected = (function() {
var cv = (hApex && hApex.consistency_score);
if (cv === null || cv === undefined) return 'retirement_portfolio_ultra_compact';
if (cv < 70) return 'retirement_portfolio_ultra_compact';
return 'retirement_portfolio_compact';
})();
var exportGate = (hApex && hApex.export_gate_json) || {};
var jsonValStatus = exportGate.json_validation_status || 'PENDING_EXPORT';
var captureRequired = exportGate.checks
? !exportGate.checks.some(function(c) {
return c.check_id === 'CHECK_1_SNAPSHOT_CAPTURED' && c.status === 'PASS';
})
: true;
var cashLedgerBasis = 'D2_ONLY';
var snapshotExecGate = (cashFloorInfo && cashFloorInfo.status === 'PASS')
? 'FULL_EXECUTION' : 'REVIEW_ONLY';
return {
request_route: 'PIPELINE_EOD_BATCH',
bundle_selected: bundleSelected,
prompt_entrypoint: 'prompts/analysis_prompt.md',
json_validation_status: jsonValStatus,
capture_required: captureRequired,
intraday_scope: scope,
snapshot_execution_gate: snapshotExecGate,
price_basis: capturedAtIso || 'UNKNOWN',
cash_ledger_basis: cashLedgerBasis,
routing_trace_complete: true,
formula_id: 'ROUTING_TRACE_V1'
};
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P0-3: WATCH_LEDGER_V1 — WATCH 감시 원장 (Direction I4)
// HTS 입력 금지 컬럼명만 허용 — 주문표와 물리적 분리
// ═══════════════════════════════════════════════════════════════════════
/**
* buildWatchLedger_
* order_blueprint_json에서 validation_status != PASS 행을 분리.
* 허용 컬럼: ticker/name, reference_stop_price, reference_tp_state, hts_allowed, reason_code
* 금지 컬럼: 지정가, 손절가, 익절가, 주문가, 주문수량 등 (INVALID_COLUMN)
*/
function buildWatchLedger_(orderBlueprint, h4) {
// THIN_ADAPTER: [stop_loss/take_profit] delegated to Python — tools/gas_thin_adapter_stubs_v1.py:stub_build_watch_ledger
var priceMap = {};
((h4 && h4.prices) || []).forEach(function(p) { priceMap[p.ticker] = p; });
var blueprintRows = Array.isArray(orderBlueprint) ? orderBlueprint : [];
var watchRows = blueprintRows.filter(function(b) {
return b.validation_status !== 'PASS';
});
return watchRows.map(function(b) {
var p = priceMap[b.ticker] || {};
var tpState = (function() {
if (!p.tp1_price) return 'INVALID_TP_STALE';
if (p.tp_state === 'TP1_ALREADY_TRIGGERED') return 'TP1_ALREADY_TRIGGERED';
return 'PENDING';
})();
return {
ticker: b.ticker,
name: b.name || '',
reference_stop_price: p.stop_price || null,
reference_tp_state: tpState,
hts_allowed: false,
reason_code: b.validation_status || 'NO_EXECUTION:WATCH',
note: '주문 아님. HTS 입력 금지.'
};
});
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P1-1: EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1 (EJCE-V1)
// 30년 전문가 수준 3관점(애널리스트·트레이더·퀀트) 합의 게이트
// Direction EJ1: consensus_result=NO_BUY 시 BUY 절대 금지
// ═══════════════════════════════════════════════════════════════════════
/**
* calcExpertJudgmentConsensus_
* 3관점 독립 채점 → majority_rule → final_allowed_action 고착화
* LLM "분위기 좋으니까" 판단을 결정론적 합의로 대체.
*/
function calcExpertJudgmentConsensus_(ticker, df, paeRow, h1, hApex, dfMap) {
df = df || {};
paeRow = paeRow || {};
// ── ANALYST_VIEW: 펀더멘털·밸류에이션 ─────────────────────────────────────
var compositeScore = toNumber_(df['SS001_Score'] || df['composite_score']) || 0;
var pegScore = toNumber_(df['PEG_Score'] || df['peg_score']) || 0;
var upsidePct = toNumber_(df['Upside_Pct'] || df['upside_pct']) || 0;
var epsMiss = toNumber_(df['EPS_Revision_Status'] === 'MISS' ? 1 : 0);
var dartRisk = String(df['DART_Risk'] || '').toUpperCase() === 'Y';
var analystScore = 0;
if (compositeScore >= 70) analystScore += 30;
else if (compositeScore >= 50) analystScore += 15;
if (pegScore >= 8 || upsidePct > 15) analystScore += 20;
if (upsidePct > 15) analystScore += 5;
if (epsMiss >= 2) analystScore -= 30;
if (dartRisk) analystScore -= 20;
var analystVerdict = analystScore >= 30 ? 'BULLISH'
: analystScore >= -10 ? 'NEUTRAL'
: 'BEARISH';
// ── TRADER_VIEW: 타이밍·수급·추세 ─────────────────────────────────────────
var flowCredit = toNumber_(df['Flow_Credit'] || df['flow_credit']) || 0;
var rsVerdict = String(df['RS_Verdict'] || df['rs_verdict'] || '').toUpperCase();
var velocity1d = toNumber_(df['Ret5D'] != null ? df['Close'] / (df['Close'] / (1 + toNumber_(df['Ret5D']) / 100)) - 1 : 0) * 100;
// 더 단순하게: Ret5D/5 근사
var ret5d = toNumber_(df['Ret5D'] || df['ret5d']) || 0;
var vel1d_approx = ret5d / 5;
var paeAnti = toNumber_(paeRow.antithesis_score) || 0;
var distCount = toNumber_(df['Dist_Signals'] || df['distribution_signals_count']) || 0;
var ma20 = toNumber_(df['MA20']) || 0;
var close = toNumber_(df['Close'] || df['close']) || 0;
var atr20 = toNumber_(df['ATR20']) || 0;
var inPullback = (ma20 > 0 && close > 0) ? close <= ma20 * 1.03 : false;
var traderScore = 0;
if (flowCredit >= 0.55 && rsVerdict === 'LEADER') traderScore += 25;
if (inPullback) traderScore += 20;
if (vel1d_approx < 1.5 && ret5d > 0) traderScore += 20;
if (vel1d_approx >= 3.0) traderScore -= 30; // 뒷박 강한 패널티
if (paeAnti >= 50) traderScore -= 25; // 설거지 경보
if (distCount >= 2) traderScore -= 25;
var traderVerdict = traderScore >= 20 ? 'ENTRY_OK'
: traderScore >= -10 ? 'WAIT'
: 'BLOCK_ENTRY';
// ── QUANT_VIEW: 통계·팩터·리스크예산 ─────────────────────────────────────
var pacVal = toNumber_((hApex && hApex.portfolio_alpha_confidence)) || 0;
var heatGate = String((hApex && hApex.heat_gate_status) || '').toUpperCase();
var ddGuard = String((hApex && hApex.drawdown_guard_state) || '').toUpperCase();
var expectedEdge = toNumber_(df['Expected_Edge'] || df['expected_edge']) || 0;
var atrAvail = atr20 > 0;
var quantScore = 0;
if (expectedEdge > 0 && atrAvail) quantScore += 25;
if (atrAvail) quantScore += 10;
if (pacVal > 20) quantScore += 20;
if (pacVal < -20) quantScore -= 30; // 전체 알파 신뢰도 BLOCK
if (heatGate === 'BLOCK_NEW_BUY') quantScore -= 20;
if (ddGuard === 'NO_BUY') quantScore -= 15;
var quantVerdict = quantScore >= 20 ? 'APPROVED'
: quantScore >= -10 ? 'REDUCED'
: 'REJECTED';
// ── CONSENSUS_MATRIX: 2/3 이상 BLOCK → NO_BUY ───────────────────────────
var blockCount = 0;
if (analystVerdict === 'BEARISH') blockCount++;
if (traderVerdict === 'BLOCK_ENTRY') blockCount++;
if (quantVerdict === 'REJECTED') blockCount++;
var consensusResult, finalAllowedAction;
if (blockCount >= 2) {
consensusResult = 'NO_BUY';
finalAllowedAction = 'HOLD';
} else if (analystVerdict === 'BULLISH' && traderVerdict === 'ENTRY_OK' && quantVerdict === 'APPROVED') {
consensusResult = 'STRONG_BUY';
finalAllowedAction = 'BUY';
} else if (analystVerdict === 'BULLISH' && traderVerdict === 'ENTRY_OK') {
consensusResult = 'BUY_HALF';
finalAllowedAction = 'BUY_HALF';
} else if (analystVerdict === 'BULLISH' && traderVerdict === 'WAIT') {
consensusResult = 'BUY_PULLBACK';
finalAllowedAction = 'WAIT_PULLBACK';
} else if (analystVerdict === 'NEUTRAL' && traderVerdict === 'ENTRY_OK') {
consensusResult = 'BUY_PILOT';
finalAllowedAction = 'PILOT';
} else {
consensusResult = 'HOLD_WATCH';
finalAllowedAction = 'WATCH';
}
var blockReasons = [];
if (analystVerdict === 'BEARISH') blockReasons.push('ANALYST_BEARISH');
if (traderVerdict === 'BLOCK_ENTRY') blockReasons.push('TRADER_BLOCK_ENTRY_vel=' + vel1d_approx.toFixed(1) + '%');
if (quantVerdict === 'REJECTED') blockReasons.push('QUANT_REJECTED_pac=' + pacVal.toFixed(1));
return {
ticker: ticker,
analyst_score: analystScore,
analyst_verdict: analystVerdict,
trader_score: traderScore,
trader_verdict: traderVerdict,
quant_score: quantScore,
quant_verdict: quantVerdict,
block_count: blockCount,
consensus_result: consensusResult,
final_allowed_action: finalAllowedAction,
block_reasons: blockReasons,
override_required: blockCount >= 2,
formula_id: 'EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1'
};
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P1-2: SMART_CASH_RECOVERY_SELL_ENGINE_V2 (SCRS-V2)
// 세련된 현금확보 매도 — 주식가치 보호 + 반등 포착 통합 엔진
// Direction C3: SCRS-V2 selected_combo만 HTS 주문표 기재 허용
// ═══════════════════════════════════════════════════════════════════════
/**
* calcSmartCashRecoverySell_
* 현금 부족액을 최소 주식가치 훼손으로 회수.
* 반등 기대 수익(expected_rebound_gain_krw) 사전 산출.
* "현금 급함" 이유로 Stage_2 우회 원천 차단.
*/
function calcSmartCashRecoverySell_(holdings, dfMap, cashShortfallInfo, h2, hApex) {
var shortfall = toNumber_((cashShortfallInfo && cashShortfallInfo.cash_shortfall_min_krw)) || 0;
var totalAsset = toNumber_((hApex && hApex.total_asset_krw) || (cashShortfallInfo && cashShortfallInfo.total_asset_krw)) || 1;
var emergencyScore = shortfall / totalAsset * 100;
var level = emergencyScore >= 15 ? 'EMERGENCY'
: emergencyScore >= 8 ? 'URGENT'
: emergencyScore >= 3 ? 'NORMAL'
: 'TRIM_ONLY';
var holdMap = {};
(holdings || []).forEach(function(h) { holdMap[h.ticker] = h; });
var sellQtyMap = {};
((hApex && hApex.sell_quantities_json) || []).forEach(function(sq) {
sellQtyMap[sq.ticker] = sq;
});
var candidates = ((h2 && h2.candidates) || []).slice();
// [Phase 3] SMART_CASH_RECOVERY_V6: value_damage_score(가치 훼손 점수) 기준 오름차순 정렬
candidates.forEach(function(c) {
var h = holdMap[c.ticker] || {};
var df = dfMap[c.ticker] || {};
var close = toNumber_(h.close || df['Close'] || df.close) || 0;
var atr20 = toNumber_(df['ATR20'] || df.atr20) || (close * 0.02);
// 가치 훼손 점수: 슬리피지 및 낙폭 리스크를 수치화 (낮을수록 매도 유리)
c.value_damage_score = close > 0 ? ((atr20 * 0.3) / close) * 100 : 100;
});
candidates.sort(function(a, b) {
return (a.value_damage_score || 0) - (b.value_damage_score || 0);
});
var cumulative = 0;
var combo = [];
for (var i = 0; i < candidates.length; i++) {
if (shortfall > 0 && cumulative >= shortfall) break;
var c = candidates[i];
var h = holdMap[c.ticker] || {};
var df = dfMap[c.ticker] || {};
var close = toNumber_(h.close || df['Close'] || df.close) || 0;
var atr20 = toNumber_(df['ATR20'] || df.atr20) || (close * 0.02);
var holding = toNumber_(h.holdingQty || h.holding_qty) || 0;
var sqRow = sellQtyMap[c.ticker] || {};
var baseQty = toNumber_(sqRow.sell_qty) || Math.floor(holding * 0.33);
if (close <= 0 || baseQty <= 0) continue;
var currentValue = holding * close;
var immediateQty = Math.floor(baseQty * 0.50);
var reboundWaitQty = baseQty - immediateQty;
var slippage = atr20 * 0.3;
var immediateKrw = immediateQty * Math.max(0, close - slippage);
var damagePct = currentValue > 0 ? immediateKrw / currentValue * 100 : 100;
if (damagePct > 30 && level !== 'EMERGENCY') continue;
var reboundTrigger = tickNormalize_(close + atr20 * 0.5, close);
var expectedReboundKrw = reboundWaitQty * Math.max(0, reboundTrigger - close);
// [Phase 3] 유동성 기준 exec_mode 강제 지정
var avgTradeValue = toNumber_(df['AvgTradeValue_20D_M'] || df.avgTradeVal20d) || 10000000000;
var execMode = 'LIMIT_NEAR_BID';
if (avgTradeValue < 5000000000) {
execMode = 'TWAP_5_SPLIT';
} else if (avgTradeValue > 50000000000) {
execMode = 'MARKET';
}
cumulative += immediateKrw;
combo.push({
rank: c.rank,
ticker: c.ticker,
name: c.name || (h.name || ''),
exec_mode: execMode,
value_damage_score: Math.round(c.value_damage_score * 10) / 10,
immediate_qty: immediateQty,
rebound_wait_qty: reboundWaitQty,
immediate_krw: Math.round(immediateKrw),
rebound_trigger_price: reboundTrigger,
expected_rebound_krw: Math.round(expectedReboundKrw),
value_damage_pct: Math.round(damagePct * 10) / 10,
rebound_deadline_date: addBusinessDays_(new Date(), 3)
});
}
var totalReboundGain = combo.reduce(function(s, c) { return s + c.expected_rebound_krw; }, 0);
var avgDamage = combo.length > 0
? combo.reduce(function(s, c) { return s + c.value_damage_pct; }, 0) / combo.length : 0;
var emergencyFullSell = combo.length > 0
&& combo[0].immediate_krw * 2 < shortfall
&& level === 'EMERGENCY';
return {
emergency_level: level,
shortfall_krw: Math.round(shortfall),
selected_combo: combo,
total_immediate_sell_krw: Math.round(cumulative),
expected_rebound_gain_krw: Math.round(totalReboundGain),
value_damage_pct_avg: Math.round(avgDamage * 10) / 10,
emergency_full_sell: emergencyFullSell,
shortfall_covered: shortfall <= 0 || cumulative >= shortfall,
formula_id: 'SMART_CASH_RECOVERY_SELL_ENGINE_V6'
};
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P1-C: CASH_RECOVERY_DISPLAY_LOCK_V1 (CRDL-V1)
// 현금회복 금액 3분리 표시 잠금 — 207억 과대표시 차단
// min_required / optimal_combo / reference_total (주문 아님)
// ═══════════════════════════════════════════════════════════════════════
/**
* calcCashRecoveryDisplayLock_
* 현금회복 금액을 3분리(최소필요/최적조합/전체후보) 표시 잠금.
* reference_total_krw는 "주문 아님" 레이블 필수.
*/
function calcCashRecoveryDisplayLock_(scrsJson, trimPlanJson, cashInfo) {
function normalizeRows_(v) {
if (Array.isArray(v)) return v;
if (!v) return [];
if (typeof v === 'string') {
try { return normalizeRows_(JSON.parse(v)); } catch (e) { return []; }
}
if (typeof v === 'object') {
var vals = [];
for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) vals.push(v[k]);
return vals;
}
return [];
}
var scrs = scrsJson || {};
if (typeof scrs === 'string') {
try { scrs = JSON.parse(scrs); } catch (e0) { scrs = {}; }
}
var trim = normalizeRows_(trimPlanJson);
var cash = cashInfo || {};
var minRequired = toNumber_(cash.cash_shortfall_min_krw) || 0;
var combo = normalizeRows_(scrs.selected_combo);
var optimalCombo = combo.reduce(function(s, r) { return s + (toNumber_(r.immediate_krw) || 0); }, 0);
var refTotal = trim.reduce(function(s, r) {
return s + (toNumber_(r.sell_amount_krw || r.trim_amount_krw || r.trimming_krw) || 0);
}, 0);
var coverageStatus;
if (minRequired <= 0) coverageStatus = 'NO_SHORTFALL';
else if (optimalCombo < minRequired) coverageStatus = 'UNCOVERED';
else if (optimalCombo > minRequired * 2) coverageStatus = 'OVER_SELL';
else coverageStatus = 'COVERED';
return {
formula_id: 'CASH_RECOVERY_DISPLAY_LOCK_V1',
min_required_krw: Math.round(minRequired),
optimal_combo_krw: Math.round(optimalCombo),
reference_total_krw: Math.round(refTotal),
coverage_status: coverageStatus,
display_mode: 'SHOW_MIN_OPTIMAL',
reference_label: '참고용 전체 후보 누적 — 주문 아님',
over_sell_warning: coverageStatus === 'OVER_SELL'
? 'OVER_SELL_WARNING: 최적조합(' + Math.round(optimalCombo/10000) + '만원)이 최소필요(' + Math.round(minRequired/10000) + '만원)의 2배 초과' : null,
shortfall_uncovered: coverageStatus === 'UNCOVERED'
? 'CASH_SHORTFALL_UNCOVERED: SCRS-V2 재실행 필요' : null
};
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P1-B: DATA_QUALITY_GATE_V2 (DQG-V2)
// 데이터 완성도 필드충족률 기반 게이트 — 행수 카운트 폐기
// COMPLETE(≥90%) / PARTIAL(≥60%) / INSUFFICIENT(<60%)
// ═══════════════════════════════════════════════════════════════════════
/**
* calcDataQualityGateV2_
* 핵심 필드 충족률로 데이터 완성도 등급 산출.
* T+20=0건, trade_quality=0건 시 특수 경고 발동.
*/
function calcDataQualityGateV2_(hApex) {
var h = hApex || {};
var pa1 = ((h.alpha_lead_json || [])[0]) || {};
var tradeQualRecords = ((h.trade_quality_report_json || {}).records || []);
var tqFirst = tradeQualRecords[0] || {};
var alphaHist = (h.alpha_history_summary_json) || {};
var scrsV2 = (h.scrs_v2_json) || {};
var combo = scrsV2.selected_combo || [];
var cluster = (h.semiconductor_cluster_json) || {};
var alphaEval = (h.alpha_evaluation_window_json || []);
var firstAlpha = alphaEval[0] || {};
var pp0 = ((h.profit_preservation_json) || [])[0] || {};
var isValid = function(v) {
return v !== null && v !== undefined && v !== '-' && v !== 'PENDING' && v !== '';
};
// [R2-1c] 필드경로 버그 수정: 실재 데이터를 0으로 깔던 false-negative 제거.
// prediction: alpha_lead_json[0] → pa1_report_json(PA1 진짜 필드).
// cash: cash_shortfall_json.cash_shortfall_min_krw(None) → 직접키 h.cash_shortfall_min_krw.
// cluster: h.semiconductor_cluster_json → h.semiconductor_cluster_gate_json 또는 직접 필드.
// stop_loss: final_stop_price/stop_price(없는 키) → protected_stop_price/auto_trailing_stop.
// trade_quality/alpha_eval/pattern: 표본 필요 → PENDING 값으로 명시(분모 제외).
var pa1Report = h.pa1_report_json || {};
if (typeof pa1Report === 'string') { try { pa1Report = JSON.parse(pa1Report); } catch(e) { pa1Report = {}; } }
var pa1Rows = Array.isArray(pa1Report) ? pa1Report : (pa1Report.rows || []);
var pa1Row0 = pa1Rows[0] || {};
var clusterDirect = h.semiconductor_cluster_json || {};
if (typeof clusterDirect === 'string') { try { clusterDirect = JSON.parse(clusterDirect); } catch(e) { clusterDirect = {}; } }
var CATEGORIES = {
prediction: [pa1Row0.direction_confidence, pa1Row0.synthesis_verdict, pa1Row0.thesis_score, pa1Row0.antithesis_score],
trade_quality: [tqFirst.grade || 'PENDING', tqFirst.feedback_tag || 'PENDING', tqFirst.t5_return_pct, tqFirst.t20_vs_core_pct],
pattern: [(h.pattern_blacklist_auto_json || {}).status || 'PENDING', (h.pattern_blacklist_auto_json || {}).accumulated_poor_count],
["stop_loss"]: [pp0.auto_trailing_stop, pp0.protected_stop_price, pp0.profit_preservation_state],
cash: [h.settlement_cash_d2_krw, h.cash_floor_status, h.cash_shortfall_min_krw],
sell_engine: [scrsV2.emergency_level, (combo[0] || {}).immediate_qty, (combo[0] || {}).rebound_wait_qty],
cluster: [clusterDirect.cluster_state, clusterDirect.combined_pct],
alpha_eval: [firstAlpha.alpha_gate_verdict || 'PENDING', alphaHist.prediction_accuracy_rate]
};
var categoryScores = {};
Object.keys(CATEGORIES).forEach(function(cat) {
var fields = CATEGORIES[cat];
var filled = fields.filter(isValid).length;
categoryScores[cat] = Math.round(filled / fields.length * 100);
});
var catVals = Object.keys(categoryScores).map(function(k) { return categoryScores[k]; });
var overallPct = catVals.length > 0
? Math.round(catVals.reduce(function(s, v) { return s + v; }, 0) / catVals.length) : 0;
var grade = overallPct >= 90 ? 'COMPLETE' : overallPct >= 60 ? 'PARTIAL' : 'INSUFFICIENT';
var warnings = [];
var t20Count = toNumber_((alphaHist).t20_evaluation_count) || 0;
var tqCount = tradeQualRecords.length;
var accRate = alphaHist.prediction_accuracy_rate;
var t5Count = toNumber_(alphaHist.t5_match_count) || 0;
if (t20Count === 0) warnings.push('warn_t20_zero: T+20 평가 0건 — 장기 예측 신뢰도 미검증');
if (tqCount === 0) warnings.push('warn_quality_unverified: 거래 품질 기록 0건');
if (!isValid(accRate)) warnings.push('warn_accuracy_unknown: 예측 정확도 미산출(PENDING)');
if (t5Count < 5) warnings.push('warn_insufficient_samples: T+5 표본 ' + t5Count + '건(최소 5건 미달)');
return {
formula_id: 'DATA_QUALITY_GATE_V2',
overall_completeness_pct: overallPct,
completeness_grade: grade,
category_scores: categoryScores,
special_warnings: warnings,
t20_evaluation_count: t20Count,
trade_quality_record_count: tqCount,
prediction_accuracy_rate: accRate || null,
confidence_ceiling: grade === 'INSUFFICIENT'
? 'BUY_SELL_CONFIDENCE_LIMITED: 핵심 데이터 부족 — 신호 신뢰도 상한 경고' : null
};
}
/**
* addBusinessDays_: 영업일 기준 날짜 계산 (토·일 제외)
*/
function addBusinessDays_(startDate, days) {
var d = new Date(startDate.getTime());
var added = 0;
while (added < days) {
d.setDate(d.getDate() + 1);
var dow = d.getDay();
if (dow !== 0 && dow !== 6) added++;
}
return Utilities.formatDate(d, 'Asia/Seoul', 'yyyy-MM-dd');
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P2-1: DETERMINISTIC_SERVING_LOCK_ENGINE_V1 (DSLE-V1)
// 11단계 stage_token 잠금 + LLM 수치 생성 = 0 강제
// Direction D3: LLM 서빙 수치 생성 절대 금지
// ═══════════════════════════════════════════════════════════════════════
/**
* calcDeterministicServingLock_
* 11단계 파이프라인 각 단계의 status·checksum을 토큰으로 기록.
* integrity_checksum 불일치 시 INVALID_SERVING_OVERRIDE 자동 표시.
*/
function calcDeterministicServingLock_(hApex, capturedAtIso, now) {
var stages = [
{ id: 'Stage_01_freshness', key: 'data_freshness_status' },
{ id: 'Stage_02_intraday', key: 'intraday_scope' },
{ id: 'Stage_03_portfolio', key: 'cash_floor_status' },
{ id: 'Stage_04_macro', key: 'macro_risk_score' },
{ id: 'Stage_05_sell_radar', key: 'distribution_sell_detector_json' },
{ id: 'Stage_06_buy_gate', key: 'anti_late_entry_json' },
{ id: 'Stage_07_sell_priority', key: 'sell_candidates_json' },
{ id: 'Stage_08_cash_recovery', key: 'scrs_v2_json' },
{ id: 'Stage_09_rs_quality', key: 'rs_verdict' },
{ id: 'Stage_10_tick_norm', key: 'tick_normalized_prices_json' },
{ id: 'Stage_11_serving', key: 'order_blueprint_json' },
];
var tokens = [];
var blockDetected = false;
var blockReason = null;
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var value = hApex ? hApex[s.key] : null;
var status = (value !== null && value !== undefined) ? 'OK' : 'MISSING';
if (status === 'MISSING' && i < 4) {
blockDetected = true;
blockReason = blockReason || (s.id + '_MISSING');
}
tokens.push({
stage_id: s.id,
key: s.key,
status: status,
checksum: computeStringChecksum_(safeStringifyForChecksum_(value))
});
}
var tokenChecksum = computeStringChecksum_(safeStringifyForChecksum_(tokens));
return {
route_lock_status: blockDetected ? 'PARTIALLY_LOCKED' : 'FULLY_LOCKED',
stage_tokens: tokens,
integrity_checksum: tokenChecksum,
llm_serving_budget: {
max_tokens: 1000,
numeric_generation_allowed: 0,
constraint: 'LLM_SERVING_CONSTRAINT_V1'
},
block_reason: blockReason,
captured_at: capturedAtIso || null,
generated_at: now ? Utilities.formatDate(now, 'Asia/Seoul', 'yyyy-MM-dd HH:mm') : null,
formula_id: 'DETERMINISTIC_SERVING_LOCK_ENGINE_V1'
};
}
// ============================================================
// WBS-4.4 일별 성과 대시보드 (포트폴리오 수익률 vs KOSPI 알파)
// ============================================================
/**
* 매일 runDataFeed 이후 호출. evaluation_dashboard 시트에
* 포트폴리오 수익률·KOSPI 수익률·알파·누적알파·MDD 를 기록.
*
* 설계 원칙:
* - daily_history → total_asset, mdd_pct
* - macro 시트 → KOSPI Close (어제/오늘 Close 차이로 1D 수익률 계산)
* - evaluation_dashboard 시트의 직전 행을 기준 자산·KOSPI Close 로 사용
* - 시트 없으면 자동 생성, 오늘 행이 이미 있으면 덮어쓰기
*/
function updateEvaluationDashboard_() {
var ss = getSpreadsheet_();
var today = Utilities.formatDate(new Date(), 'Asia/Seoul', 'yyyy-MM-dd');
// ── 1. daily_history에서 오늘 total_asset, mdd_pct 읽기 ──────────────────
var histSheet = ss.getSheetByName('daily_history');
if (!histSheet) {
Logger.log('[EVAL_DASH] daily_history 시트 없음, 건너뜀');
return;
}
var histData = histSheet.getDataRange().getValues();
if (histData.length < 2) {
Logger.log('[EVAL_DASH] daily_history 데이터 부족');
return;
}
var hHdr = histData[0].map(function(c) { return String(c).trim(); });
var hDateIdx = hHdr.indexOf('date');
var hAssetIdx = hHdr.indexOf('total_asset');
var hMddIdx = hHdr.indexOf('mdd_pct');
if (hDateIdx < 0 || hAssetIdx < 0) {
Logger.log('[EVAL_DASH] daily_history 헤더 불일치: ' + hHdr.join(','));
return;
}
var todayHistRow = null;
for (var r = 1; r < histData.length; r++) {
if (String(histData[r][hDateIdx]).trim() === today) {
todayHistRow = histData[r];
break;
}
}
if (!todayHistRow) {
Logger.log('[EVAL_DASH] daily_history에 오늘 행 없음: ' + today);
return;
}
var todayAsset = parseFloat(todayHistRow[hAssetIdx]) || 0;
var todayMdd = hMddIdx >= 0 ? (parseFloat(todayHistRow[hMddIdx]) || 0) : 0;
// ── 2. macro 시트에서 KOSPI Close 읽기 ────────────────────────────────────
var todayKospiClose = null;
var macroSheet = ss.getSheetByName('macro');
if (macroSheet) {
var mData = macroSheet.getDataRange().getValues();
var mHdrRowIdx = 0;
for (var i = 0; i < Math.min(5, mData.length); i++) {
if (mData[i].join(',').indexOf('Name') >= 0) { mHdrRowIdx = i; break; }
}
var mHdr = mData[mHdrRowIdx].map(function(c) { return String(c).trim(); });
var mNameIdx = mHdr.indexOf('Name');
var mCloseIdx = mHdr.indexOf('Close');
for (var j = mHdrRowIdx + 1; j < mData.length; j++) {
if (mNameIdx >= 0 && String(mData[j][mNameIdx]).trim() === 'KOSPI') {
if (mCloseIdx >= 0) todayKospiClose = parseFloat(mData[j][mCloseIdx]) || null;
break;
}
}
}
// ── 3. evaluation_dashboard 시트 가져오기/생성 ───────────────────────────
var EVD_HDRS = [
'Date', 'Total_Asset', 'KOSPI_Close',
'Portfolio_Return_1D_Pct', 'KOSPI_Return_1D_Pct',
'Alpha_1D_Pct', 'Cumulative_Alpha_Pct', 'MDD_Pct'
];
var evdSheet = ss.getSheetByName('evaluation_dashboard');
if (!evdSheet) {
evdSheet = ss.insertSheet('evaluation_dashboard');
evdSheet.getRange(1, 1, 1, EVD_HDRS.length).setValues([EVD_HDRS]);
evdSheet.setFrozenRows(1);
}
// ── 4. 직전 행(prev) 및 오늘 행 위치 파악 ──────────────────────────────
var evdData = evdSheet.getDataRange().getValues();
var eHdr = evdData.length > 0
? evdData[0].map(function(c) { return String(c).trim(); })
: EVD_HDRS;
var eDateIdx = eHdr.indexOf('Date');
var eAssetIdx = eHdr.indexOf('Total_Asset');
var eKospiIdx = eHdr.indexOf('KOSPI_Close');
var eCumAlphaIdx = eHdr.indexOf('Cumulative_Alpha_Pct');
var prevAsset = null;
var prevKospi = null;
var prevCumAlpha = 0;
var todayRowIdx = -1; // 1-based sheet row index (0 = not found)
for (var k = 1; k < evdData.length; k++) {
var rowDate = eDateIdx >= 0 ? String(evdData[k][eDateIdx]).trim() : '';
if (rowDate === today) {
todayRowIdx = k + 1; // getRange은 1-based
} else if (rowDate !== '' && rowDate < today) {
prevAsset = eAssetIdx >= 0 ? (parseFloat(evdData[k][eAssetIdx]) || null) : null;
prevKospi = eKospiIdx >= 0 ? (parseFloat(evdData[k][eKospiIdx]) || null) : null;
prevCumAlpha = eCumAlphaIdx >= 0 ? (parseFloat(evdData[k][eCumAlphaIdx]) || 0) : 0;
}
}
// ── 5. 수익률·알파 계산 ────────────────────────────────────────────────
var portfolioRet1D = null;
if (prevAsset !== null && prevAsset > 0 && todayAsset > 0) {
portfolioRet1D = Math.round(((todayAsset - prevAsset) / prevAsset * 100) * 100) / 100;
}
var kospiRet1D = null;
if (prevKospi !== null && prevKospi > 0 && todayKospiClose !== null && todayKospiClose > 0) {
kospiRet1D = Math.round(((todayKospiClose - prevKospi) / prevKospi * 100) * 100) / 100;
}
var alpha1D = (portfolioRet1D !== null && kospiRet1D !== null)
? Math.round((portfolioRet1D - kospiRet1D) * 100) / 100
: null;
var cumAlpha = alpha1D !== null
? Math.round((prevCumAlpha + alpha1D) * 100) / 100
: prevCumAlpha;
var newRow = [
today, todayAsset, todayKospiClose,
portfolioRet1D, kospiRet1D,
alpha1D, cumAlpha, todayMdd
];
// ── 6. 오늘 행 덮어쓰기 또는 추가 ────────────────────────────────────
if (todayRowIdx > 0) {
evdSheet.getRange(todayRowIdx, 1, 1, newRow.length).setValues([newRow]);
Logger.log('[EVAL_DASH] 오늘 행 업데이트 date=' + today
+ ' portfolio_ret=' + portfolioRet1D
+ ' alpha=' + alpha1D + ' cum_alpha=' + cumAlpha);
} else {
evdSheet.appendRow(newRow);
Logger.log('[EVAL_DASH] 오늘 행 추가 date=' + today
+ ' portfolio_ret=' + portfolioRet1D
+ ' alpha=' + alpha1D + ' cum_alpha=' + cumAlpha);
}
}
// --- Source: src/gas_adapter_parts/gdf_05_alpha_engines.gs ---
function safeStringifyForChecksum_(value) {
var s = JSON.stringify(value);
return (s === undefined || s === null) ? '' : s;
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P2-2: YAML_GAS_COVERAGE_AUDIT_ENGINE_V1 (YGCA-V1)
// YAML 지침 ↔ GAS 함수 커버리지 감사 — settings 탭에 결과 기록
// ═══════════════════════════════════════════════════════════════════════
/**
* auditYamlGasCoverage_
* 필수 함수 목록과 실제 정의를 비교해 커버리지 % 산출.
* GAS에서는 typeof 로 함수 존재 여부를 확인한다.
*/
function auditYamlGasCoverage_() {
var REQUIRED = [
// Stage 0
{ yaml: 'HARNESS_DATA_FRESHNESS_GATE_V1', gs: 'calcHarnessDataFreshnessGate_' },
{ yaml: 'INTRADAY_ACTION_MATRIX_V1', gs: 'calcIntradayLock_' },
// Stage 1
{ yaml: 'FLOW_CREDIT_V1', gs: 'buildAllowedAction' },
{ yaml: 'TARGET_CASH_PCT_V1', gs: 'calcCashFloor_' },
{ yaml: 'TOTAL_HEAT_V1', gs: 'calcHarnessPortfolioGuardState_' },
{ yaml: 'CASH_SHORTFALL_V1', gs: 'calcCashShortfallHarness_' },
{ yaml: 'CASH_RECOVERY_OPTIMIZER_V1', gs: 'calcCashPreservationPlan_' },
// Stage 2
{ yaml: 'POSITION_SIZE_V1', gs: 'calcQuantities_' },
{ yaml: 'STOP_PRICE_CORE_V1', gs: 'calcPrices_' },
{ yaml: 'PROFIT_RATCHET_TIERED_V2', gs: 'calcProfitPreservationRow_' },
{ yaml: 'TAKE_PROFIT_LADDER_V1', gs: 'calcTpQuantityLadder_' },
// Stage 3
{ yaml: 'DISTRIBUTION_SELL_DETECTOR_V1', gs: 'calcDistributionRiskRow_' },
{ yaml: 'DIVERGENCE_SCORE_V1', gs: 'calcSellConflictScore_' },
{ yaml: 'OVERHANG_PRESSURE_V1', gs: 'calcReboundHoldbackScore_' },
{ yaml: 'FLOW_ACCELERATION_V1', gs: 'calcAlphaShield_' },
{ yaml: 'PRE_DISTRIBUTION_EARLY_WARNING_V1', gs: 'calcDistributionRiskRow_' },
// Stage 4
{ yaml: 'ANTI_LATE_ENTRY_GATE_V2', gs: 'calcAntiLateEntryGateV2_' },
{ yaml: 'PULLBACK_ENTRY_TRIGGER_V1', gs: 'calcEntryTimingSignal_' },
{ yaml: 'BREAKOUT_QUALITY_GATE_V2', gs: 'calcBreakoutQualityGate_' },
{ yaml: 'STAGED_ENTRY_TRANCHE_V1', gs: 'calcCoreSatelliteExecutionState_' },
// Stage 5
{ yaml: 'SELL_WATERFALL_ENGINE_V1', gs: 'calcSmartCashRaiseV2_' },
{ yaml: 'SELL_EXECUTION_TIMING_V1', gs: 'calcExitSellAction_' },
{ yaml: 'SELL_VALUE_PRESERVATION_TIERED_V2', gs: 'calcCashPreservationSellEngineV2_' },
{ yaml: 'SELL_PRICE_SANITY_V1', gs: 'calcSellSignalSanityScore_' },
{ yaml: 'K2_STAGED_REBOUND_SELL_V1', gs: 'calcAntiWhipsawGate_' },
// Stage 6
{ yaml: 'TICK_NORMALIZER_V1', gs: 'tickNormalize_' },
// Stage 7-8
{ yaml: 'RS_VERDICT_V2', gs: 'calcIndexRelativeHealthGate_' },
{ yaml: 'BENCHMARK_RELATIVE_TIMESERIES_V1', gs: 'calcIndexRelativeHealthGate_' },
{ yaml: 'SATELLITE_ALPHA_QUALITY_GATE_V1', gs: 'calcCoreCandidateQualityGrade_' },
{ yaml: 'SATELLITE_LIFECYCLE_GATE_V1', gs: 'calcSatelliteLifecycleGate_' },
{ yaml: 'PORTFOLIO_CORRELATION_GATE_V1', gs: 'calcPortfolioCorrelationGate_' },
// Stage 9
{ yaml: 'LLM_SERVING_CONSTRAINT_V1', gs: 'calcDeterministicServingLock_' },
{ yaml: 'DETERMINISTIC_ROUTING_ENGINE_V1', gs: 'buildHarnessContext_' },
// Portfolio risk
{ yaml: 'DRAWDOWN_GUARD_V1', gs: 'calcDrawdownGuard_' },
{ yaml: 'PORTFOLIO_BETA_GATE_V1', gs: 'calcPortfolioBetaGate_' },
{ yaml: 'SECTOR_CONCENTRATION_LIMIT_V1', gs: 'calcSectorConcentrationGate_' },
{ yaml: 'POSITION_COUNT_LIMIT_V1', gs: 'calcPositionCountLimit_' },
{ yaml: 'SINGLE_POSITION_WEIGHT_CAP_V1', gs: 'calcSinglePositionWeightCap_' },
{ yaml: 'SEMICONDUCTOR_CLUSTER_GATE_V1', gs: 'calcSemiconductorClusterGate_' },
{ yaml: 'PORTFOLIO_DRAWDOWN_GATE_V1', gs: 'calcPortfolioDrawdownGate_' },
{ yaml: 'WIN_LOSS_STREAK_GUARD_V1', gs: 'calcWinLossStreakGuard_' },
// Alerts
{ yaml: 'STOP_BREACH_ALERT_V1', gs: 'calcStopBreachAlert_' },
{ yaml: 'RELATIVE_STOP_SIGNAL_V1', gs: 'calcRelativeStopSignal_' },
{ yaml: 'TP_TRIGGER_ALERT_V1', gs: 'calcTpTriggerAlert_' },
{ yaml: 'HEAT_CONCENTRATION_ALERT_V1', gs: 'calcHeatConcentrationAlert_' },
{ yaml: 'REGIME_TRANSITION_ALERT_V1', gs: 'calcRegimeTransitionAlert_' },
{ yaml: 'PORTFOLIO_HEALTH_SCORE_V1', gs: 'calcPortfolioHealthScore_' },
// Proposal50 신규
{ yaml: 'EXPORT_GATE_V1', gs: 'calcExportGate_' },
{ yaml: 'ROUTING_TRACE_V1', gs: 'buildRoutingTrace_' },
{ yaml: 'WATCH_LEDGER_V1', gs: 'buildWatchLedger_' },
{ yaml: 'EXPERT_JUDGMENT_CONSENSUS_ENGINE_V1', gs: 'calcExpertJudgmentConsensus_' },
{ yaml: 'SMART_CASH_RECOVERY_SELL_ENGINE_V2', gs: 'calcSmartCashRecoverySell_' },
{ yaml: 'DETERMINISTIC_SERVING_LOCK_ENGINE_V1', gs: 'calcDeterministicServingLock_' },
{ yaml: 'MACRO_REGIME_ADAPTIVE_GATE_V2', gs: 'calcMacroRegimeAdaptiveGate_' },
{ yaml: 'MANDATORY_REDUCTION_PLAN_V1', gs: 'calcMandatoryReductionPlan_' },
// Proposal50 P0 Gap 해소 함수
{ yaml: 'VALIDATE_ORDER_CONDITION_V1', gs: 'validateOrderCondition_' },
{ yaml: 'SHADOW_LEDGER_V1', gs: 'buildShadowLedger_' },
{ yaml: 'LLM_SERVING_CONSTRAINT_V1', gs: 'calcLlmServingConstraint_' },
{ yaml: 'AVG_TRADE_VALUE_SIGNAL_V1', gs: 'calcAvgTradeValueSignal_' },
{ yaml: 'TRIM_PLAN_MIN_CASH_V1', gs: 'calcTrimPlanMinCash_' },
{ yaml: 'PREDICTIVE_ALPHA_ENGINE_V1', gs: 'calcPredictiveAlphaEngineV1_' },
{ yaml: 'MACRO_EVENT_SYNCHRONIZER_V1', gs: 'calcMacroEventSynchronizerV1_' },
{ yaml: 'ANTI_LATE_ENTRY_GATE_V2', gs: 'calcAntiLateEntryGateV2_' },
{ yaml: 'CONSISTENCY_VALIDATOR_V2', gs: 'calcConsistencyValidatorV2_' },
{ yaml: 'SATELLITE_FAILURE_GATE_V1', gs: 'calcSatelliteFailureGate_' },
{ yaml: 'SATELLITE_AGGREGATE_PNL_GATE_V1', gs: 'calcSatelliteAggregatePnlGate_' },
{ yaml: 'CLA_REGIME_EXIT_CONDITION_V1', gs: 'calcClaRegimeExitCondition_' },
{ yaml: 'EVENT_RISK_HOLD_GATE_V1', gs: 'calcEventRiskHoldGate_' },
{ yaml: 'SECTOR_ROTATION_MOMENTUM_V1', gs: 'calcSectorRotationMomentum_' },
// Monthly Batch 피드백 루프
{ yaml: 'TRADE_QUALITY_SCORER_V1', gs: 'calcTradeQualityScorer_' },
{ yaml: 'PATTERN_BLACKLIST_AUTO_V1', gs: 'calcPatternBlacklistAuto_' },
{ yaml: 'ALPHA_FEEDBACK_LOOP_V1', gs: 'calcAlphaFeedbackLoop_' },
// Proposal51 신규
{ yaml: 'SELL_PRICE_SANITY_V2', gs: 'calcSellPriceSanityV2_' },
{ yaml: 'EXPORT_GATE_V2', gs: 'calcExportGate_' },
{ yaml: 'SEMICONDUCTOR_CLUSTER_SYNC_V1', gs: 'syncSemiconductorCluster_' },
{ yaml: 'PROACTIVE_SELL_RADAR_V2', gs: 'calcProactiveSellRadarV2_' },
{ yaml: 'ANTI_LATE_ENTRY_GATE_V3', gs: 'applyAlegGate4And5_' },
{ yaml: 'PRICE_HIERARCHY_LOCK_V1', gs: 'applyPriceHierarchyLockAll_' },
{ yaml: 'DATA_QUALITY_GATE_V2', gs: 'calcDataQualityGateV2_' },
{ yaml: 'CASH_RECOVERY_DISPLAY_LOCK_V1', gs: 'calcCashRecoveryDisplayLock_' },
// Proposal53 신규
{ yaml: 'FUNDAMENTAL_QUALITY_GATE_V1', gs: 'calcFundamentalQualityGateV1_' },
{ yaml: 'HORIZON_ALLOCATION_LOCK_V1', gs: 'calcHorizonAllocationLockV1_' },
{ yaml: 'SMART_MONEY_LIQUIDITY_GATE_V1', gs: 'calcSmartMoneyLiquidityGateV1_' },
{ yaml: 'ROUTING_SERVING_DECISION_TRACE_V2', gs: 'buildRoutingServingTraceV2_' },
{ yaml: 'FUNDAMENTAL_MULTI_FACTOR_SCORE_V2', gs: 'calcFundamentalMultiFactorScoreV2_' },
{ yaml: 'EARNINGS_GROWTH_QUALITY_GATE_V1', gs: 'calcEarningsGrowthQualityGateV1_' },
{ yaml: 'MARKET_SHARE_MOMENTUM_PROXY_V1', gs: 'calcMarketShareMomentumProxyV1_' },
{ yaml: 'CASHFLOW_STABILITY_GATE_V1', gs: 'calcCashflowStabilityGateV1_' },
{ yaml: 'ROUTING_DECISION_EXPLAIN_LOCK_V1', gs: 'calcRoutingExplainLockV1_' },
];
var implemented = REQUIRED.filter(function(req) {
try { return typeof eval(req.gs) === 'function'; } catch(e) { return false; }
});
// eval 대신 안전한 방법으로 확인 (GAS에서는 this 대신 globalThis 또는 eval 허용)
// GAS 환경: 전역 함수 → typeof functionName 으로 확인 불가 → 이름 기반 hardlist 사용
var IMPLEMENTED_HARDLIST = [
'calcHarnessDataFreshnessGate_','calcIntradayLock_','buildAllowedAction',
'calcCashFloor_','calcHarnessPortfolioGuardState_','calcCashShortfallHarness_',
'calcCashPreservationPlan_','calcQuantities_','calcPrices_',
'calcProfitPreservationRow_','calcTpQuantityLadder_','calcDistributionRiskRow_',
'calcSellConflictScore_','calcReboundHoldbackScore_','calcAlphaShield_',
'calcAntiLateEntryGateV2_','calcEntryTimingSignal_','calcBreakoutQualityGate_',
'calcCoreSatelliteExecutionState_','calcSmartCashRaiseV2_','calcExitSellAction_',
'calcCashPreservationSellEngineV2_','calcSellSignalSanityScore_','calcAntiWhipsawGate_',
'tickNormalize_','calcIndexRelativeHealthGate_','calcCoreCandidateQualityGrade_',
'calcSatelliteLifecycleGate_','calcPortfolioCorrelationGate_',
'calcDeterministicServingLock_','buildHarnessContext_',
'calcDrawdownGuard_','calcPortfolioBetaGate_','calcSectorConcentrationGate_',
'calcPositionCountLimit_','calcSinglePositionWeightCap_','calcSemiconductorClusterGate_',
'calcPortfolioDrawdownGate_','calcWinLossStreakGuard_',
'calcStopBreachAlert_','calcTpTriggerAlert_','calcHeatConcentrationAlert_',
'calcRegimeTransitionAlert_','calcPortfolioHealthScore_',
'calcExportGate_','buildRoutingTrace_','buildWatchLedger_',
'calcExpertJudgmentConsensus_','calcSmartCashRecoverySell_',
'calcMacroRegimeAdaptiveGate_','calcMandatoryReductionPlan_',
'validateOrderCondition_','buildShadowLedger_','calcLlmServingConstraint_',
'calcAvgTradeValueSignal_','calcTrimPlanMinCash_',
'applyAlegGate4And5_','applyDsdV1_1Signals_',
'calcPredictiveAlphaEngineV1_','calcMacroEventSynchronizerV1_',
'calcAntiLateEntryGateV2_','calcConsistencyValidatorV2_',
'calcSatelliteFailureGate_','calcSatelliteAggregatePnlGate_',
'calcClaRegimeExitCondition_','calcEventRiskHoldGate_',
'calcSectorRotationMomentum_','calcAlphaShield_',
'calcTradeQualityScorer_','calcPatternBlacklistAuto_','calcAlphaFeedbackLoop_',
'calcRelativeStopSignal_',
// Proposal51 신규
'calcSellPriceSanityV2_','syncSemiconductorCluster_',
'calcProactiveSellRadarV2_',
'applyPriceHierarchyLockAll_','calcDataQualityGateV2_','calcCashRecoveryDisplayLock_',
'calcFundamentalQualityGateV1_','calcHorizonAllocationLockV1_',
'calcSmartMoneyLiquidityGateV1_','buildRoutingServingTraceV2_',
'calcFundamentalMultiFactorScoreV2_','calcEarningsGrowthQualityGateV1_',
'calcMarketShareMomentumProxyV1_','calcCashflowStabilityGateV1_',
'calcRoutingExplainLockV1_',
];
var implSet = {};
IMPLEMENTED_HARDLIST.forEach(function(f) { implSet[f] = true; });
var gaps = REQUIRED.filter(function(req) { return !implSet[req.gs]; });
var implCount = REQUIRED.length - gaps.length;
var coveragePct = Math.round(implCount / REQUIRED.length * 1000) / 10;
var result = {
total_required: REQUIRED.length,
implemented: implCount,
coverage_pct: coveragePct,
gaps: gaps.map(function(g) { return { yaml: g.yaml, gs: g.gs }; }),
coverage_label: coveragePct >= 95 ? 'FULL'
: coveragePct >= 80 ? 'HIGH'
: coveragePct >= 60 ? 'MEDIUM'
: 'LOW',
formula_id: 'YAML_GAS_COVERAGE_AUDIT_ENGINE_V1'
};
Logger.log('[COVERAGE_AUDIT] ' + coveragePct + '% (' + implCount + '/' + REQUIRED.length + ')'
+ (gaps.length > 0 ? ' GAPS: ' + gaps.map(function(g){ return g.yaml; }).join(',') : ''));
// settings 탭에 기록
try {
var ss = getSpreadsheet_();
var sh = ss.getSheetByName('settings');
if (sh) {
var data = sh.getDataRange().getValues();
var found = false;
for (var i = 0; i < data.length; i++) {
if (String(data[i][0]) === 'coverage_pct') {
sh.getRange(i + 1, 2).setValue(coveragePct);
found = true;
break;
}
}
if (!found) {
sh.appendRow(['coverage_pct', coveragePct, 'YAML↔GAS 커버리지 %', new Date().toISOString()]);
}
}
} catch(e) {
Logger.log('[COVERAGE_AUDIT] settings 탭 기록 실패: ' + e.message);
}
return result;
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P0-B: MACRO_REGIME_ADAPTIVE_GATE_V2 (MRAG-V2)
// 거시·이벤트 위험도 4레이어 → heat_gate_threshold / position_size_scale 동적 조정
// Direction ME2: effective_heat_gate_threshold = ME1 + MRAG-V2 중 더 엄격한 값
// ═══════════════════════════════════════════════════════════════════════
/**
* calcMacroRegimeAdaptiveGate_
* LAYER_1 미시(Market Internals) + LAYER_2 거시(Macro) + LAYER_3 글로벌 + LAYER_4 이벤트
* total_mrag_score 0~100 → heat_gate_threshold / position_size_scale 결정론적 조정
*/
function calcMacroRegimeAdaptiveGate_(macroJson, mesResult, hApex) {
return calcMacroRegimeAdaptiveGateV2Impl_(macroJson, mesResult, hApex);
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P1-A: ANTI_LATE_ENTRY_GATE V2.1 — GATE_4/GATE_5 추가
// 뒷박 원천 차단 5게이트 완성 (기존 V2의 3게이트 → 5게이트)
// ═══════════════════════════════════════════════════════════════════════
/**
* applyAlegGate4And5_
* alegRows에 GATE_4(PAE연동) + GATE_5(블랙리스트) 추가.
* Direction A2: BLOCK if ANY gate(1~5)=BLOCK
*/
function applyAlegGate4And5_(alegRows, paeRows, hApex) {
return applyAlegGate4And5Impl_(alegRows, paeRows, hApex);
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P1-B: DISTRIBUTION_SELL_DETECTOR V1.1 — SIG_7/SIG_8
// 설거지 신호 6개 → 8개, weighted_sum 임계값 5.0/3.0 상향
// ═══════════════════════════════════════════════════════════════════════
/**
* applyDsdV1_1Signals_
* dsdRows에 SIG_7/SIG_8 추가 적용.
* Direction B3: weighted_sum >= 5.0 → DISTRIBUTION_CONFIRMED
*/
function applyDsdV1_1Signals_(dsdRows, dfMap) {
(dsdRows || []).forEach(function(dsdRow) {
var df = dfMap[dsdRow.ticker] || {};
var close_ = toNumber_(df['Close'] || df.close) || 0;
var open_ = toNumber_(df['Open'] || df.open) || 0;
// SIG_7: 연속 양봉 후 음봉 반전 (w=1.5)
var prev3Bull = df['Prev3D_AllBullish'] === true
|| String(df['Prev3D_AllBullish'] || '').toUpperCase() === 'TRUE';
var todayBear = close_ < open_ && close_ > 0 && open_ > 0;
var sig7 = prev3Bull && todayBear;
dsdRow.sig_7_reversal = sig7;
if (sig7) dsdRow.weighted_sum = (toNumber_(dsdRow.weighted_sum) || 0) + 1.5;
// SIG_8: 개인집중유입 + 기관매도 (w=1.5) — 데이터 없으면 w=0
var retailR = toNumber_(df['Retail_Buy_Ratio_5D'] || df.retail_buy_ratio_5d) || 0;
var instS = toNumber_(df['Inst_5D'] || df.inst_5d) || 0;
var sig8 = retailR > 0.70 && instS < 0;
dsdRow.sig_8_retail_inflow = sig8;
if (sig8) dsdRow.weighted_sum = (toNumber_(dsdRow.weighted_sum) || 0) + 1.5;
// V1.1 임계값 재적용
var ws = toNumber_(dsdRow.weighted_sum) || 0;
dsdRow.distribution_verdict = ws >= 5.0 ? 'DISTRIBUTION_CONFIRMED'
: ws >= 3.0 ? 'DISTRIBUTION_WARNING'
: 'NO_SIGNAL';
// 조기 경보 V2: (SIG_1 OR SIG_2) + RSI14 >= 70
var rsi14 = toNumber_(df['RSI14'] || df.rsi14) || 0;
dsdRow.early_warning_v2 = (dsdRow.sig_1 || dsdRow.sig_2) && rsi14 >= 70;
dsdRow.dsd_version = 'V1.1';
});
return dsdRows;
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL50] P1-C: MANDATORY_REDUCTION_PLAN_V1
// 반도체 클러스터 한도 2배 초과 → 4주 의무 감축 계획 결정론적 산출
// ═══════════════════════════════════════════════════════════════════════
/**
* calcMandatoryReductionPlan_
* Direction O2: mandatory_reduction_json을 하네스 확정값으로 잠금.
*/
function calcMandatoryReductionPlan_(semiconductorClusterGate, holdings, dfMap, h3, totalAsset) {
function toDateYmd_(v) {
if (!v) return null;
if (typeof v === 'string') return v.slice(0, 10);
if (Object.prototype.toString.call(v) === '[object Date]' && !isNaN(v.getTime())) {
return Utilities.formatDate(v, 'Asia/Seoul', 'yyyy-MM-dd');
}
return null;
}
// [PROPOSAL51-FIX] calcSemiconductorClusterGate_ 반환키는 combined_pct (cluster_pct 아님)
var clusterPct = toNumber_((semiconductorClusterGate || {}).combined_pct
|| (semiconductorClusterGate || {}).cluster_pct) || 0;
var clusterLimit = toNumber_((semiconductorClusterGate || {}).cap_pct
|| (semiconductorClusterGate || {}).cluster_limit_pct) || 25;
if (clusterPct <= clusterLimit * 2.0) {
return { is_mandatory: false, cluster_pct: clusterPct, cluster_limit_pct: clusterLimit,
formula_id: 'MANDATORY_REDUCTION_PLAN_V1' };
}
var excessPct = clusterPct - clusterLimit;
var weeklyReducPct = Math.ceil(excessPct / 4 * 10) / 10;
var weeklyReducKrw = Math.round(totalAsset * weeklyReducPct / 100);
var SEMI_TICKERS = ['005930','000660','229200','091160'];
var holdMap = {};
(holdings || []).forEach(function(h) { holdMap[h.ticker] = h; });
var sellQtyMap = {};
((h3 && h3.sellQty) || []).forEach(function(sq) { sellQtyMap[sq.ticker] = sq; });
var reduction = [];
// 1순위: RS_BROKEN
(holdings || []).filter(function(h) {
var df = dfMap[h.ticker] || {};
return SEMI_TICKERS.indexOf(h.ticker) >= 0
&& String(df['RS_Verdict'] || df.rs_verdict || '').toUpperCase() === 'BROKEN';
}).forEach(function(h) {
reduction.push({ priority: 1, reason: 'RS_BROKEN', ticker: h.ticker, name: h.name || '',
suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null });
});
// 2순위: ETF
(holdings || []).filter(function(h) {
return SEMI_TICKERS.indexOf(h.ticker) >= 0
&& (h.name && (h.name.indexOf('KODEX') >= 0 || h.name.indexOf('TIGER') >= 0
|| h.name.indexOf('ETF') >= 0 || h.ticker === '229200'));
}).filter(function(h) { return !reduction.some(function(r) { return r.ticker === h.ticker; }); })
.forEach(function(h) {
reduction.push({ priority: 2, reason: 'ETF_PREFERRED', ticker: h.ticker, name: h.name || '',
suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null });
});
// 3순위: APEX_SUPER
(holdings || []).filter(function(h) {
var df = dfMap[h.ticker] || {};
return SEMI_TICKERS.indexOf(h.ticker) >= 0
&& String(df['Profit_Lock_Stage'] || df.profit_lock_stage || '').toUpperCase() === 'APEX_SUPER';
}).filter(function(h) { return !reduction.some(function(r) { return r.ticker === h.ticker; }); })
.forEach(function(h) {
reduction.push({ priority: 3, reason: 'APEX_SUPER_TRAILING', ticker: h.ticker, name: h.name || '',
suggested_sell_qty: (sellQtyMap[h.ticker] || {}).sell_qty || null });
});
var completeDate = addBusinessDays_(new Date(), 20); // 4주 × 5영업일
return {
is_mandatory: true,
cluster_pct: clusterPct,
cluster_limit_pct: clusterLimit,
current_excess_pct: Math.round(excessPct * 10) / 10,
weekly_reduction_target_pct: weeklyReducPct,
weekly_reduction_target_krw: weeklyReducKrw,
weeks_to_normalize: 4,
estimated_completion_date: toDateYmd_(completeDate),
reduction_priority: reduction,
formula_id: 'MANDATORY_REDUCTION_PLAN_V1'
};
}
// ═══════════════════════════════════════════════════════════════════════
// [PROPOSAL51] P0-C: SEMICONDUCTOR_CLUSTER_SYNC_V1
// cluster gate ↔ mandatory_reduction_plan 단일 소스 동기화
// ═══════════════════════════════════════════════════════════════════════
/**
* syncSemiconductorCluster_
* SEMICONDUCTOR_CLUSTER_SYNC_V1: cluster_gate ↔ mandatory_reduction_json 정합성 검증 및 자동 교정
* - combined_pct > cap_pct * 2이면 is_mandatory=true 강제
* - combined_pct <= cap_pct * 2이면 is_mandatory=false 강제
* @param {Object} hApex — mandatory_reduction_json 포함
* @return {{ status, corrected, before_is_mandatory, after_is_mandatory, cluster_pct, threshold_pct }}
*/
function syncSemiconductorCluster_(hApex) {
var mrj = (hApex && hApex.mandatory_reduction_json) || {};
var clusterPct = toNumber_(mrj.cluster_pct) || 0;
var clusterLimit = toNumber_(mrj.cluster_limit_pct) || 25;
var threshold = clusterLimit * 2.0;
var shouldBeMandatory = clusterPct > threshold;
var wasMandatory = mrj.is_mandatory === true;
var syncStatus, corrected;
if (shouldBeMandatory === wasMandatory) {
syncStatus = 'SYNCED';
corrected = false;
} else {
syncStatus = 'CORRECTED';
corrected = true;
// 인라인 교정
mrj.is_mandatory = shouldBeMandatory;
if (shouldBeMandatory) {
// 의무 감축 활성화 시 최소 필드 보장
mrj.current_excess_pct = Math.round((clusterPct - clusterLimit) * 10) / 10;
} else {
// 의무 감축 비활성화 — 세부 필드 제거
delete mrj.current_excess_pct;
delete mrj.weekly_reduction_target_pct;
delete mrj.weekly_reduction_target_krw;
delete mrj.reduction_priority;
}
hApex.mandatory_reduction_json = mrj;
Logger.log('[SCRSV1] CLUSTER_SYNC 교정: is_mandatory ' + wasMandatory
+ ' → ' + shouldBeMandatory + ' (cluster=' + clusterPct + '%, threshold=' + threshold + '%)');
}
return {
formula_id: 'SEMICONDUCTOR_CLUSTER_SYNC_V1',
status: syncStatus,
corrected: corrected,
cluster_pct: clusterPct,
threshold_pct: threshold,
cap_pct: clusterLimit,
before_is_mandatory: wasMandatory,
after_is_mandatory: shouldBeMandatory
};
}
/**
* HS007: validateOrderCondition_
* 주문 조건 텍스트에 다중 조건 접속사가 포함되면 INVALID_MULTI_CONDITION 반환.
* HTS 자동주문은 단일 지정가만 허용 — 접속사 복합 조건은 HTS 오입력 원인.
*/
function validateOrderCondition_(text) {
if (!text || typeof text !== 'string') {
return { valid: true, status: 'OK', matched_conjunctions: [], formula_id: 'VALIDATE_ORDER_CONDITION_V1' };
}
var MULTI_CONDITION_PATTERNS = [
'또는', '혹은', '동시 충족', '동시충족',
'실패 시', '실패시', '회복 실패', '회복실패',
'돌파 실패', '돌파실패', '이탈 또는', '초과 또는',
'또는 이하', '또는 이상', '이거나', '이면서'
];
var matched = MULTI_CONDITION_PATTERNS.filter(function(p) {
return text.indexOf(p) >= 0;
});
if (matched.length > 0) {
return {
valid: false,
status: 'INVALID_MULTI_CONDITION',
matched_conjunctions: matched,
resolution: '단일 가격 조건만 기재 (예: "종가 196,500원 이탈 시")',
formula_id: 'VALIDATE_ORDER_CONDITION_V1'
};
}
return { valid: true, status: 'OK', matched_conjunctions: [], formula_id: 'VALIDATE_ORDER_CONDITION_V1' };
}
/**
* H10 (HS010_REVISED): buildShadowLedger_
* BLOCKED/INVALID 블루프린트를 그림자 원장으로 분리.
* 차단 여부와 무관하게 산출 지표를 투명하게 보존 — 사용자의 사후 평가·오버라이드 지원.
*/
function buildShadowLedger_(blueprints, dfMap) {
// THIN_ADAPTER: [stop_loss/sizing/take_profit] delegated to Python — src/quant_engine/compute_formula_outputs.py:check_sell_price_sanity
dfMap = dfMap || {};
var ledger = [];
var bpRows = Array.isArray(blueprints) ? blueprints : [];
bpRows.forEach(function(bp) {
var isBlocked = bp.validation_status === 'BLOCKED'
|| bp.validation_status === 'INVALID'
|| String(bp.validation_status || '').indexOf('INVALID') === 0;
if (!isBlocked) return;
var df = dfMap[bp.ticker] || {};
ledger.push({
ticker: bp.ticker,
name: bp.name || df.name || '',
block_reason: bp.rationale_code || bp.validation_status || 'BLOCKED',
order_type: bp.order_type || '',
limit_price_calc: bp.limit_price || null,
["stop_loss_calc"]: bp["stop_loss"] || df["stop_loss_price"] || null,
["take_profit_calc"]: bp["take_profit"] || df["tp1_price"] || null,
base_qty_calc: bp.qty || df.base_qty || null,
value_at_risk_krw: bp.value_at_risk_krw || null,
override_possible: true,
formula_id: 'SHADOW_LEDGER_V1'
});
});
return {
shadow_ledger: ledger,
blocked_count: ledger.length,
formula_id: 'SHADOW_LEDGER_V1'
};
}
/**
* D2: calcLlmServingConstraint_
* LLM 12가지 금지행동 체크리스트 — 보고서 조립 직전 실행.
* 하나라도 위반 가능성이 있으면 INVALID_LLM_OVERRIDE 태그를 반환하여 보고서에 표기.
*/
function calcLlmServingConstraint_(hApex) {
var h = hApex || {};
var violations = [];
// Check 1: 미등록 공식 사용 가능성 — serving_lock_json numeric_generation_allowed
var sLock = h.serving_lock_json || {};
var budget = sLock.llm_serving_budget || {};
if (budget.numeric_generation_allowed !== 0) {
violations.push({ check: 1, rule: '미등록 공식으로 지정가/수량 산출', status: 'WARN_NOT_LOCKED' });
}
// Check 2: BLOCK 판정 우회 — hts_entry_allowed=false인데 blueprint PASS 존재 불가
var exportGate = h.export_gate_json || {};
if (exportGate.hts_entry_allowed === false) {
var blueprints = h.order_blueprint_json || [];
var passCount = (Array.isArray(blueprints) ? blueprints : []).filter(function(b) {
return b.validation_status === 'PASS';
}).length;
if (passCount > 0) {
violations.push({ check: 2, rule: 'hts_entry_allowed=false 상태에서 PASS blueprint 존재', status: 'VIOLATION' });
}
}
// Check 3: SELL_PRICE_SANITY INVALID 가격 복원 위험 — INVALID 종목이 shadow_ledger에 없으면 경고
var shadowLedger = h.shadow_ledger_json || {};
var invalidBlueprints = (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : [])
.filter(function(b) { return String(b.validation_status || '').indexOf('INVALID') === 0; });
if (invalidBlueprints.length > 0 && (!shadowLedger.blocked_count || shadowLedger.blocked_count === 0)) {
violations.push({ check: 3, rule: 'INVALID blueprint가 Shadow Ledger에 미포함', status: 'VIOLATION' });
}
// Check 5: K2 반등 대기 수량 — scrs_v2_json에 rebound_wait_qty가 있으면 분리 표기 의무
var scrs = h.scrs_v2_json || {};
var selectedCombo = Array.isArray(scrs.selected_combo) ? scrs.selected_combo : [];
if (selectedCombo.length > 0) {
var hasRebound = selectedCombo.some(function(c) { return c.rebound_wait_qty > 0; });
if (hasRebound && !scrs._display_split_confirmed) {
violations.push({ check: 5, rule: 'K2 rebound_wait_qty 분리 미표기 위험', status: 'WARN' });
}
}
// Check 9: consistency_score < 90이면 보고서 계속 생성 금지
var asResult = h.account_snapshot_result || {};
var cScore = asResult.consistency_score;
if (typeof cScore === 'number' && cScore < 90) {
violations.push({ check: 9, rule: 'consistency_score=' + cScore + ' < 90 (ABORT 필요)', status: 'VIOLATION' });
}
// Check 10: mega_sell_alert=TRUE이면 BUY/ADD_ON 금지
var macroJson = h.macro_event_json || {};
if (macroJson.mega_sell_alert === true || macroJson.mega_sell_alert === 'TRUE') {
var buyBlueprints = (Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : [])
.filter(function(b) { return b.order_type === 'BUY' || b.order_type === 'ADD_ON'; });
if (buyBlueprints.length > 0) {
violations.push({ check: 10, rule: 'mega_sell_alert=TRUE 상태에서 BUY/ADD_ON blueprint 존재', status: 'VIOLATION' });
}
}
// Check 11: synthesis_verdict=BEARISH 종목에 BUY 금지
var paeRows = h.predictive_alpha_json || [];
var bearishTickers = (Array.isArray(paeRows) ? paeRows : [])
.filter(function(r) { return r.synthesis_verdict === 'BEARISH'; })
.map(function(r) { return r.ticker; });
if (bearishTickers.length > 0) {
(Array.isArray(h.order_blueprint_json) ? h.order_blueprint_json : []).forEach(function(b) {
if ((b.order_type === 'BUY' || b.order_type === 'ADD_ON') && bearishTickers.indexOf(b.ticker) >= 0) {
violations.push({ check: 11, rule: 'synthesis_verdict=BEARISH 종목 BUY blueprint: ' + b.ticker, status: 'VIOLATION' });
}
});
}
var constraintStatus = violations.some(function(v) { return v.status === 'VIOLATION'; })
? 'INVALID_LLM_OVERRIDE' : violations.length > 0 ? 'WARN' : 'PASS';
return {
constraint_status: constraintStatus,
violations: violations,
violation_count: violations.filter(function(v) { return v.status === 'VIOLATION'; }).length,
warn_count: violations.filter(function(v) { return v.status === 'WARN' || v.status === 'WARN_NOT_LOCKED'; }).length,
total_checks: 12,
formula_id: 'LLM_SERVING_CONSTRAINT_V1'
};
}
/**
* H6: calcAvgTradeValueSignal_
* secular_leader(005930·000660) PROFIT_LOCK_STAGE_20 구간에서
* 5일 평균 거래대금 > 20일 평균 × 3.0이면 과열신호 +1 판정.
*/
function calcAvgTradeValueSignal_(ticker, df) {
df = df || {};
var SECULAR_TICKERS = ['005930', '000660'];
var isSecular = SECULAR_TICKERS.indexOf(String(ticker || '')) >= 0;
var stage = String(df.profit_lock_stage || df.Profit_Lock_Stage || '').toUpperCase();
var avgVal5d = toNumber_(df.avg_trade_val_5d || df.avgTradeVal5d) || 0;
var avgVal20d = toNumber_(df.avg_trade_val_20d || df.avgTradeVal20d) || 0;
if (!isSecular || stage !== 'PROFIT_LOCK_20' || avgVal20d <= 0) {
return {
ticker: ticker,
applicable: false,
signal: 'NOT_APPLICABLE',
avg_trade_val_5d: avgVal5d,
avg_trade_val_20d: avgVal20d,
overheat_triggered: false,
formula_id: 'AVG_TRADE_VALUE_SIGNAL_V1'
};
}
var ratio = avgVal5d / avgVal20d;
var overheat = ratio >= 3.0;
return {
ticker: ticker,
applicable: true,
signal: overheat ? 'OVERHEAT_TRADE_VALUE' : 'NORMAL',
avg_trade_val_5d: avgVal5d,
avg_trade_val_20d: avgVal20d,
ratio_5d_vs_20d: Math.round(ratio * 100) / 100,
overheat_triggered: overheat,
overheat_score_add: overheat ? 1 : 0,
threshold: 3.0,
formula_id: 'AVG_TRADE_VALUE_SIGNAL_V1'
};
}
/**
* G2: calcTrimPlanMinCash_
* 최소 현금(cash_floor) 달성을 위한 결정론적 TRIM 계획 산출.
* H2 매도후보 순위(sell_priority) 그대로 종목 순서를 결정 — LLM 임의 선택 금지.
*/
function calcTrimPlanMinCash_(holdings, dfMap, cashShortfallInfo, sellPriorityList) {
dfMap = dfMap || {};
var shortfall = toNumber_((cashShortfallInfo || {}).cash_shortfall_min_krw) || 0;
var plan = [];
var accumulatedKrw = 0;
var holdingRows = Array.isArray(holdings) ? holdings : [];
var priorityRows = Array.isArray(sellPriorityList) ? sellPriorityList : [];
priorityRows.forEach(function(sp) {
if (accumulatedKrw >= shortfall) return;
var h = holdingRows.find(function(x) { return x.ticker === sp.ticker; }) || {};
var df = dfMap[sp.ticker] || {};
var avgCost = toNumber_(h.avg_cost || h.average_cost) || 0;
var qty = toNumber_(h.qty || h.quantity) || 0;
if (qty === 0 || avgCost === 0) {
plan.push({
priority: sp.priority || plan.length + 1,
ticker: sp.ticker,
name: sp.name || df.name || '',
sell_qty: 'CAPTURE_REQUIRED',
estimated_sell_krw: 0,
sell_price_ref: null,
accumulated_krw: accumulatedKrw,
shortfall_covered: false,
note: 'CAPTURE_REQUIRED: qty/cost 미확정'
});
return;
}
var closePrice = toNumber_(df.close || df.close_price) || avgCost;
var remaining = shortfall - accumulatedKrw;
var neededQty = Math.ceil(remaining / closePrice);
var sellQty = Math.min(neededQty, qty);
var estimatedKrw = sellQty * closePrice;
accumulatedKrw += estimatedKrw;
plan.push({
priority: sp.priority || plan.length + 1,
ticker: sp.ticker,
name: sp.name || df.name || '',
sell_qty: sellQty,
estimated_sell_krw: Math.round(estimatedKrw),
sell_price_ref: closePrice,
accumulated_krw: Math.round(accumulatedKrw),
shortfall_covered: accumulatedKrw >= shortfall,
note: accumulatedKrw >= shortfall ? 'SHORTFALL_MET' : 'PARTIAL'
});
});
return {
cash_shortfall_min_krw: Math.round(shortfall),
plan: plan,
total_plan_krw: Math.round(accumulatedKrw),
shortfall_fully_covered: accumulatedKrw >= shortfall,
is_plan_only: true,
hts_order_required: 'order_blueprint_json.validation_status 기준으로만 판단',
formula_id: 'TRIM_PLAN_MIN_CASH_V1'
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// [PROPOSAL50] F1 — TRADE_QUALITY_SCORER_V1
// 실행된 매수·매도를 T+5/T+20 기준으로 자동 채점.
// trade_quality_history 시트를 읽어 미채점 레코드를 업데이트하고 결과 배열 반환.
// ═══════════════════════════════════════════════════════════════════════════════
/**
* calcTradeQualityScorer_
* trade_quality_history 시트에서 미채점 레코드를 배치 처리.
* BUY: velocity/ma20/volume/t5/t20 각 20점 합산 (100점 만점)
* SELL: above_ma20/above_cost/not_too_early/cash_goal_met 각 25점 합산 (100점 만점)
*/
function calcTradeQualityScorer_(ss) {
try {
ss = ss || getSpreadsheet_();
var sh = ss.getSheetByName('trade_quality_history');
if (!sh) {
Logger.log('[F1] trade_quality_history 시트 없음');
return { status: 'SHEET_NOT_FOUND', scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
}
var data = sh.getDataRange().getValues();
if (data.length < 2) {
return { status: 'NO_DATA', scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
}
var header = data[0];
var COL = {};
header.forEach(function(h, i) { COL[String(h).trim()] = i; });
// 필수 컬럼 확인
var REQ = ['ticker', 'action', 'scored'];
for (var ri = 0; ri < REQ.length; ri++) {
if (COL[REQ[ri]] == null) {
Logger.log('[F1] 필수 컬럼 누락: ' + REQ[ri]);
return { status: 'COLUMN_MISSING', missing: REQ[ri], scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
}
}
// 현재 종가 맵 (T+5/T+20 평가용)
var priceMap = {};
var dfSheet = ss.getSheetByName('data_feed');
if (dfSheet) {
var dfData = dfSheet.getDataRange().getValues();
if (dfData.length > 1) {
var dfHeader = dfData[0];
var tCol = dfHeader.indexOf('Ticker');
var cCol = dfHeader.indexOf('Close');
if (tCol >= 0 && cCol >= 0) {
for (var dri = 1; dri < dfData.length; dri++) {
var tk = String(dfData[dri][tCol] || '').trim();
var cl = parseFloat(String(dfData[dri][cCol] || ''));
if (tk && !isNaN(cl) && cl > 0) priceMap[tk] = cl;
}
}
}
}
var todayMs = new Date().getTime();
var scoredResults = [];
var scoredThisRun = 0;
for (var i = 1; i < data.length; i++) {
var row = data[i];
var alreadyScored = String(row[COL['scored']] || '').toUpperCase();
if (alreadyScored === 'TRUE' || alreadyScored === 'SCORED') continue;
var ticker = String(row[COL['ticker']] || '').trim();
var action = String(row[COL['action']] || '').toUpperCase();
if (!ticker) continue;
var entryDate = row[COL['entry_date'] != null ? COL['entry_date'] : -1];
var daysSinceEntry = entryDate ? (todayMs - new Date(entryDate).getTime()) / 86400000 : 0;
// T+5 이상 경과해야 채점 (T+20 필드는 optional)
if (COL['entry_date'] != null && daysSinceEntry < 7) continue;
var score = 0;
var subscores = {};
var feedbackTag = 'GOOD_EXECUTION';
if (action === 'BUY') {
// 매수 품질 채점
var velocity1d = parseFloat(String(row[COL['velocity_1d_at_entry'] != null ? COL['velocity_1d_at_entry'] : -1] || ''));
var entryPrice = parseFloat(String(row[COL['entry_price'] != null ? COL['entry_price'] : -1] || ''));
var ma20Entry = parseFloat(String(row[COL['ma20_at_entry'] != null ? COL['ma20_at_entry'] : -1] || ''));
var volRatio = parseFloat(String(row[COL['volume_ratio_at_entry'] != null ? COL['volume_ratio_at_entry'] : -1] || ''));
var t5RetPct = parseFloat(String(row[COL['t5_return_pct'] != null ? COL['t5_return_pct'] : -1] || ''));
var t20VsCore = parseFloat(String(row[COL['t20_vs_core_pctp'] != null ? COL['t20_vs_core_pctp'] : -1] || ''));
// velocity_ok: 진입일 속도 < 1% (추격 아님)
if (!isNaN(velocity1d) && velocity1d < 1) { score += 20; subscores.velocity_ok = 20; }
else subscores.velocity_ok = 0;
// ma20_proximity: 진입가 ≤ MA20 × 1.01
if (!isNaN(entryPrice) && !isNaN(ma20Entry) && ma20Entry > 0 && entryPrice <= ma20Entry * 1.01) {
score += 20; subscores.ma20_proximity = 20;
} else subscores.ma20_proximity = 0;
// volume_confirm: 거래량비율 ≥ 1.2
if (!isNaN(volRatio) && volRatio >= 1.2) { score += 20; subscores.volume_confirm = 20; }
else subscores.volume_confirm = 0;
// t5_positive: T+5 수익률 > 0
if (!isNaN(t5RetPct) && t5RetPct > 0) { score += 20; subscores.t5_positive = 20; }
else subscores.t5_positive = 0;
// t20_alpha: T+20 대비 코어 초과 > 0
if (!isNaN(t20VsCore) && t20VsCore > 0) { score += 20; subscores.t20_alpha = 20; }
else subscores.t20_alpha = 0;
// 피드백 태그
if (subscores.velocity_ok === 0 && subscores.ma20_proximity === 0) feedbackTag = 'CHASE_ENTRY';
else if (subscores.t5_positive === 0 && subscores.t20_alpha === 0) feedbackTag = 'DISTRIBUTION_ENTRY';
} else if (action === 'SELL') {
// 매도 품질 채점
var sellPrice = parseFloat(String(row[COL['sell_price'] != null ? COL['sell_price'] : -1] || ''));
var ma20Sell = parseFloat(String(row[COL['ma20_at_sell'] != null ? COL['ma20_at_sell'] : -1] || ''));
var avgCost = parseFloat(String(row[COL['average_cost'] != null ? COL['average_cost'] : -1] || ''));
var priceT5After = parseFloat(String(row[COL['price_t5_after_sell'] != null ? COL['price_t5_after_sell'] : -1] || ''));
var cashRecov = parseFloat(String(row[COL['cash_recovered_krw'] != null ? COL['cash_recovered_krw'] : -1] || ''));
var cashGoal = parseFloat(String(row[COL['cash_shortfall_min_krw'] != null ? COL['cash_shortfall_min_krw'] : -1] || ''));
// above_ma20: 매도가 ≥ MA20 × 0.99
if (!isNaN(sellPrice) && !isNaN(ma20Sell) && ma20Sell > 0 && sellPrice >= ma20Sell * 0.99) {
score += 25; subscores.above_ma20 = 25;
} else subscores.above_ma20 = 0;
// above_cost: 매도가 ≥ 평단
if (!isNaN(sellPrice) && !isNaN(avgCost) && avgCost > 0 && sellPrice >= avgCost) {
score += 25; subscores.above_cost = 25;
} else subscores.above_cost = 0;
// not_too_early: T+5 사후 종가가 없거나 매도가 이상
if (isNaN(priceT5After) || priceT5After <= sellPrice) {
score += 25; subscores.not_too_early = 25;
} else subscores.not_too_early = 0;
// cash_goal_met: 실제 회수액 ≥ 목표 부족분
if (!isNaN(cashRecov) && !isNaN(cashGoal) && cashGoal > 0 && cashRecov >= cashGoal) {
score += 25; subscores.cash_goal_met = 25;
} else subscores.cash_goal_met = 0;
// 피드백 태그
if (subscores.above_cost === 0) feedbackTag = 'PANIC_EXIT';
else if (subscores.not_too_early === 0) feedbackTag = 'OVERSOLD_PANIC';
} else {
continue; // BUY/SELL 이외 레코드 스킵
}
// 등급 결정
var grade;
if (score >= 90) grade = 'EXCELLENT';
else if (score >= 75) grade = 'GOOD';
else if (score >= 60) grade = 'ACCEPTABLE';
else if (score >= 40) grade = 'POOR';
else grade = 'CRITICAL';
if (grade === 'POOR' || grade === 'CRITICAL') {
feedbackTag = score < 40 ? 'PATTERN_ALERT' : 'CHASE_ENTRY_OR_PANIC_EXIT';
} else if (grade === 'EXCELLENT' || grade === 'GOOD') {
feedbackTag = 'GOOD_EXECUTION';
}
// 시트 업데이트
var scoreCol = COL['score'] != null ? COL['score'] + 1 : null;
var gradeCol = COL['grade'] != null ? COL['grade'] + 1 : null;
var fbTagCol = COL['feedback_tag'] != null ? COL['feedback_tag'] + 1 : null;
var scoredCol = COL['scored'] != null ? COL['scored'] + 1 : null;
if (scoreCol) sh.getRange(i + 1, scoreCol).setValue(score);
if (gradeCol) sh.getRange(i + 1, gradeCol).setValue(grade);
if (fbTagCol) sh.getRange(i + 1, fbTagCol).setValue(feedbackTag);
if (scoredCol) sh.getRange(i + 1, scoredCol).setValue('SCORED');
scoredResults.push({
row: i,
ticker: ticker,
action: action,
score: score,
grade: grade,
feedback_tag: feedbackTag,
subscores: subscores,
formula_id: 'TRADE_QUALITY_SCORER_V1'
});
scoredThisRun++;
}
// 전체 기록 집계 (기존 채점 포함)
var allResults = [];
var freshData = sh.getDataRange().getValues();
for (var j = 1; j < freshData.length; j++) {
var r = freshData[j];
var sc = String(r[COL['scored']] || '').toUpperCase();
if (sc !== 'TRUE' && sc !== 'SCORED') continue;
allResults.push({
ticker: String(r[COL['ticker']] || '').trim(),
action: String(r[COL['action']] || '').toUpperCase(),
score: parseFloat(String(r[COL['score']] || '')) || 0,
grade: String(r[COL['grade']] || 'UNKNOWN'),
feedback_tag: String(r[COL['feedback_tag']] || '')
});
}
Logger.log('[F1] calcTradeQualityScorer_ 완료: 이번 채점=' + scoredThisRun + '건, 전체=' + allResults.length + '건');
// F2: F1 완료 직후 블랙리스트 자동 갱신 (F1 → F2 파이프라인)
try {
calcPatternBlacklistAuto_(allResults);
} catch (pbErr) {
Logger.log('[F1] calcPatternBlacklistAuto_ 연동 오류: ' + pbErr.message);
}
var f1Result = {
status: 'OK',
scored_count: scoredThisRun,
total_records: allResults.length,
trade_quality: allResults,
last_computed: new Date().toISOString(),
formula_id: 'TRADE_QUALITY_SCORER_V1'
};
// settings 시트에 trade_quality_json 캐시 저장 (harness_rows 일간 출력용)
// 셀 50K 한도 초과 방지: trade_quality 최근 100건만 저장
try {
var setSh = ss.getSheetByName('settings');
if (setSh) {
var sData = setSh.getDataRange().getValues();
var updated = false;
var f1Slim = Object.assign({}, f1Result,
{ trade_quality: (f1Result.trade_quality || []).slice(-100) });
var serialized = JSON.stringify(f1Slim);
for (var si = 0; si < sData.length; si++) {
if (String(sData[si][0] || '').trim() === 'trade_quality_json') {
setSh.getRange(si + 1, 2).setValue(serialized);
updated = true;
break;
}
}
if (!updated) setSh.appendRow(['trade_quality_json', serialized]);
}
} catch(writeErr) {
Logger.log('[F1] settings 시트 기록 실패: ' + writeErr.message);
}
return f1Result;
} catch(e) {
Logger.log('[F1] calcTradeQualityScorer_ 오류: ' + e.message);
return { status: 'ERROR', error: e.message, scored_count: 0, trade_quality: [], formula_id: 'TRADE_QUALITY_SCORER_V1' };
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// [PROPOSAL50] F2 — PATTERN_BLACKLIST_AUTO_V1
// 동일 ticker POOR/CRITICAL 3회 누적 → PATTERN_BLACKLIST_TRIGGERED
// 3회 연속 GOOD(75+) 달성 시 해제
// ═══════════════════════════════════════════════════════════════════════════════
/**
* calcPatternBlacklistAuto_
* trade_quality_json 배열을 받아 ticker별 POOR/CRITICAL 누적 횟수를 계산.
* 3회 이상이면 PATTERN_BLACKLIST_TRIGGERED, 3회 연속 GOOD 이상이면 해제.
* 결과를 settings 시트의 pattern_blacklist_json에 기록.
*/
function calcPatternBlacklistAuto_(tradeQualityHistory) {
try {
var history = Array.isArray(tradeQualityHistory) ? tradeQualityHistory : [];
// ticker별 그룹화
var tickerMap = {};
history.forEach(function(rec) {
var tk = String(rec.ticker || '').trim();
if (!tk) return;
if (!tickerMap[tk]) tickerMap[tk] = [];
tickerMap[tk].push({
grade: String(rec.grade || '').toUpperCase(),
score: typeof rec.score === 'number' ? rec.score : (parseFloat(String(rec.score || '')) || 0)
});
});
var blacklistEntries = [];
var triggeredCount = 0;
Object.keys(tickerMap).forEach(function(ticker) {
var records = tickerMap[ticker];
// POOR/CRITICAL 누적 카운트
var poorCriticalCount = records.filter(function(r) {
return r.grade === 'POOR' || r.grade === 'CRITICAL';
}).length;
// 해제 조건: 마지막 3건이 모두 GOOD(75+) 이상
var releaseMet = false;
if (records.length >= 3) {
var last3 = records.slice(-3);
releaseMet = last3.every(function(r) {
return (r.grade === 'GOOD' || r.grade === 'EXCELLENT') && r.score >= 75;
});
}
var status;
if (releaseMet && poorCriticalCount >= 3) {
status = 'CLEAR'; // 블랙리스트 해제
} else if (poorCriticalCount >= 3) {
status = 'TRIGGERED';
triggeredCount++;
} else {
status = 'CLEAR';
}
blacklistEntries.push({
ticker: ticker,
pattern_blacklist_status: status,
accumulated_poor_count: poorCriticalCount,
total_records: records.length,
release_condition_met: releaseMet,
saqg_override: status === 'TRIGGERED' ? 'EXCLUDED' : 'NO_CHANGE',
alpha_score_cap: status === 'TRIGGERED' ? 50 : null,
formula_id: 'PATTERN_BLACKLIST_AUTO_V1'
});
});
// settings 시트에 pattern_blacklist_json 기록 (wrapper 객체 형태로 저장)
try {
var ss = getSpreadsheet_();
var settingSh = ss.getSheetByName('settings');
if (settingSh) {
var sData = settingSh.getDataRange().getValues();
var updated = false;
var wrapperObj = {
status: 'OK',
triggered_count: triggeredCount,
total_tickers: blacklistEntries.length,
patterns: blacklistEntries,
pattern_count: blacklistEntries.length,
computed_at: new Date().toISOString(),
formula_id: 'PATTERN_BLACKLIST_AUTO_V1'
};
var serialized = JSON.stringify(wrapperObj);
for (var si = 0; si < sData.length; si++) {
if (String(sData[si][0] || '').trim() === 'pattern_blacklist_json') {
settingSh.getRange(si + 1, 2).setValue(serialized);
updated = true;
break;
}
}
if (!updated) settingSh.appendRow(['pattern_blacklist_json', serialized]);
}
} catch(writeErr) {
Logger.log('[F2] settings 시트 기록 실패: ' + writeErr.message);
}
Logger.log('[F2] calcPatternBlacklistAuto_ 완료: TRIGGERED=' + triggeredCount + '/' + blacklistEntries.length + '건');
return {
status: 'OK',
triggered_count: triggeredCount,
total_tickers: blacklistEntries.length,
patterns: blacklistEntries,
pattern_count: blacklistEntries.length,
formula_id: 'PATTERN_BLACKLIST_AUTO_V1'
};
} catch(e) {
Logger.log('[F2] calcPatternBlacklistAuto_ 오류: ' + e.message);
return { status: 'ERROR', error: e.message, triggered_count: 0, patterns: [], pattern_count: 0, formula_id: 'PATTERN_BLACKLIST_AUTO_V1' };
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// [PROPOSAL50] ALPHA_FEEDBACK_LOOP_V1
// monthly_history의 AEW_V1 성과 데이터를 분석해 SAQG_V1 필터 임계값 조정 권고 생성.
// 임계값 자동 변경 금지 — 권고(RECOMMENDATION)만 출력.
// ═══════════════════════════════════════════════════════════════════════════════
/**
* calcAlphaFeedbackLoop_
* alpha_evaluation_window_json (AEW_V1 결과) 에서 ELIGIBLE 케이스를 분석해
* SAQG F1/F2/F3 임계값 조정 권고를 생성한다.
* 10건 미만이면 DATA_INSUFFICIENT — 권고 생성 금지.
*/
function calcAlphaFeedbackLoop_() {
try {
var ss = getSpreadsheet_();
var aewRows = [];
// monthly_history 시트에서 AEW 데이터 수집
var mhSh = ss.getSheetByName('monthly_history');
if (mhSh) {
var mhData = mhSh.getDataRange().getValues();
if (mhData.length > 1) {
var mhHeader = mhData[0];
var COL = {};
mhHeader.forEach(function(h, i) { COL[String(h).trim()] = i; });
for (var i = 1; i < mhData.length; i++) {
var row = mhData[i];
var saqg = String(row[COL['saqg_v1'] != null ? COL['saqg_v1'] : -1] || '').toUpperCase();
var t20Sam = parseFloat(String(row[COL['t20_vs_samsung_pctp'] != null ? COL['t20_vs_samsung_pctp'] : -1] || ''));
var brtV = String(row[COL['brt_verdict'] != null ? COL['brt_verdict'] : -1] || '').toUpperCase();
var regime = String(row[COL['market_regime'] != null ? COL['market_regime'] : -1] || '');
if (!saqg) continue;
aewRows.push({ saqg_v1: saqg, t20_vs_samsung_pctp: isNaN(t20Sam) ? null : t20Sam, brt_verdict: brtV, market_regime: regime });
}
}
}
var eligibleRows = aewRows.filter(function(r) { return r.saqg_v1 === 'ELIGIBLE'; });
var casesAnalyzed = eligibleRows.length;
var now = new Date();
var asOf = now.toISOString().split('T')[0];
var analysisPeriod = asOf.substring(0, 7); // 'YYYY-MM'
if (casesAnalyzed < 10) {
Logger.log('[AFL] calcAlphaFeedbackLoop_: 데이터 부족(' + casesAnalyzed + '건) — 권고 생성 건너뜀');
return {
formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
as_of: asOf,
analysis_period: analysisPeriod,
status: 'DATA_INSUFFICIENT',
cases_analyzed: casesAnalyzed,
grade_count: 0,
eligible_t20_fail_rate: null,
eligible_t60_fail_rate: null,
recommended_filter_adjustments: [],
grade_summary: []
};
}
// T+20 알파 실패율 계산 (t20_vs_samsung_pctp < -3)
var t20WithData = eligibleRows.filter(function(r) { return r.t20_vs_samsung_pctp !== null; });
var t20FailRows = t20WithData.filter(function(r) { return r.t20_vs_samsung_pctp < -3; });
var t20PassRows = t20WithData.length - t20FailRows.length;
var t20FailRate = t20WithData.length > 0
? Math.round(t20FailRows.length / t20WithData.length * 1000) / 10
: null;
var t20PassRate = t20WithData.length > 0
? Math.round(t20PassRows / t20WithData.length * 1000) / 10
: null;
// BRT_VERDICT=BROKEN 케이스 비율
var brokenCount = eligibleRows.filter(function(r) { return r.brt_verdict === 'BROKEN'; }).length;
var brokenRate = eligibleRows.length > 0
? Math.round(brokenCount / eligibleRows.length * 1000) / 10 : 0;
// grade_summary — saqg_v1 값별로 집계
var gradeCounts = {};
aewRows.forEach(function(r) {
var g = r.saqg_v1 || 'UNKNOWN';
if (!gradeCounts[g]) gradeCounts[g] = { t20_total: 0, t20_pass: 0, t20_fail: 0 };
if (r.t20_vs_samsung_pctp !== null) {
gradeCounts[g].t20_total++;
if (r.t20_vs_samsung_pctp >= 0) gradeCounts[g].t20_pass++;
else gradeCounts[g].t20_fail++;
}
});
var gradeSummary = Object.keys(gradeCounts).map(function(g) {
var gd = gradeCounts[g];
var passRate = gd.t20_total > 0 ? Math.round(gd.t20_pass / gd.t20_total * 1000) / 10 : null;
var failRate = gd.t20_total > 0 ? Math.round(gd.t20_fail / gd.t20_total * 1000) / 10 : null;
return {
grade: g,
t20_total: gd.t20_total,
t20_pass: gd.t20_pass,
t20_pass_rate: passRate,
t20_fail_rate: failRate,
t60_total: 0, // T+60 데이터 미수집 — 향후 확장
t60_pass: 0,
t60_pass_rate: null,
t60_fail_rate: null,
status: gd.t20_total === 0 ? 'DATA_INSUFFICIENT' : 'OK'
};
});
// 권고 생성 — 렌더러 계약 필드명: filter_id, current, recommended, action, rationale
var recommendations = [];
if (t20FailRate !== null && t20FailRate > 50) {
recommendations.push({
filter_id: 'SAQG_F1_F2_F3',
current: 'CURRENT_THRESHOLDS',
recommended: 'TIGHTEN: F2 recovery_ratio 1.20 → 1.35',
action: 'TIGHTEN',
rationale: 'ELIGIBLE T+20 알파 실패율 ' + t20FailRate + '% > 50% 기준 초과'
});
}
if (t20PassRate !== null && t20PassRate > 70 && casesAnalyzed >= 12) {
recommendations.push({
filter_id: 'SAQG_F3',
current: 'excess_drawdown 5%p',
recommended: 'RELAX: excess_drawdown 5%p → 7%p',
action: 'RELAX',
rationale: 'ELIGIBLE T+20 성공률 ' + t20PassRate + '% > 70% (케이스 ' + casesAnalyzed + '건)'
});
}
if (brokenRate > 30) {
recommendations.push({
filter_id: 'BRT_VERDICT_GATE',
current: 'CURRENT_THRESHOLDS',
recommended: 'TIGHTEN: BRT_BROKEN 진입 차단 강화',
action: 'TIGHTEN',
rationale: 'ELIGIBLE 중 BRT_BROKEN 비율 ' + brokenRate + '% > 30%'
});
}
Logger.log('[AFL] calcAlphaFeedbackLoop_ 완료: cases=' + casesAnalyzed + ' t20FailRate=' + t20FailRate + '% recs=' + recommendations.length);
var result = {
formula_id: 'ALPHA_FEEDBACK_LOOP_V1',
as_of: asOf,
analysis_period: analysisPeriod,
status: 'OK',
cases_analyzed: casesAnalyzed,
grade_count: gradeSummary.length,
eligible_t20_fail_rate: t20FailRate,
eligible_t60_fail_rate: null,
t20_pass_rate: t20PassRate,
brt_broken_rate: brokenRate,
recommended_filter_adjustments: recommendations,
grade_summary: gradeSummary,
note: '임계값 자동 변경 금지 — 사용자 확인 후 settings 수동 반영'
};
// settings 시트에 기록
try {
var settingSh = ss.getSheetByName('settings');
if (settingSh) {
var sData = settingSh.getDataRange().getValues();
var updated = false;
var serialized = JSON.stringify(result);
for (var si = 0; si < sData.length; si++) {
if (String(sData[si][0] || '').trim() === 'alpha_feedback_json') {
settingSh.getRange(si + 1, 2).setValue(serialized);
updated = true;
break;
}
}
if (!updated) settingSh.appendRow(['alpha_feedback_json', serialized]);
}
} catch(writeErr) {
Logger.log('[AFL] settings 시트 기록 실패: ' + writeErr.message);
}
return result;
} catch(e) {
Logger.log('[AFL] calcAlphaFeedbackLoop_ 오류: ' + e.message);
return { status: 'ERROR', error: e.message, cases_analyzed: 0, recommended_filter_adjustments: [], formula_id: 'ALPHA_FEEDBACK_LOOP_V1' };
}
}
/** AFL 일간 하네스 호출 래퍼 — calcAlphaFeedbackLoop_ 위임 */
function runAlphaFeedbackLoop_() {
return calcAlphaFeedbackLoop_();
}
/**
* AFL 캐시 읽기 — settings 시트에서 마지막 저장된 alpha_feedback_json 반환.
* calcAlphaFeedbackLoop_ 오류 시 fallback으로 사용.
*/
function getAlphaFeedbackJson_() {
try {
var ss = getSpreadsheet_();
var sh = ss.getSheetByName('settings');
if (!sh) return { status: 'SETTINGS_NOT_FOUND', formula_id: 'ALPHA_FEEDBACK_LOOP_V1' };
var data = sh.getDataRange().getValues();
for (var i = 0; i < data.length; i++) {
if (String(data[i][0] || '').trim() === 'alpha_feedback_json') {
var raw = data[i][1];
if (!raw) break;
try { return JSON.parse(String(raw)); } catch(pe) { break; }
}
}
} catch(e) {
Logger.log('[AFL] getAlphaFeedbackJson_ 읽기 실패: ' + e.message);
}
return { status: 'CACHE_EMPTY', formula_id: 'ALPHA_FEEDBACK_LOOP_V1' };
}
// FORMULA_STUB: EXPECTED_EDGE_V1 — 기댓값 공식 (calcExpectedEdge_) GAS 미구현, Python pipeline 산출
// --- Source: src/gas_adapter_parts/gdf_06_rebalance.gs ---
// gdf_06_rebalance.gs — REBALANCE_ENGINE_V1 (GAS)
//
// runRebalanceSheet_(): data_feed + account_snapshot 라이브 데이터 기반
// bucket drift → 레짐 적응 밴드 → 비용효익 게이트 → 3단계 분할 실행 계획
// GatherTradingData.xlsx > rebalance 시트에 4섹션(SUMMARY/BUCKETS/TICKERS/ORDERS) 출력.
// ── 버킷 설정 (gdf_01_price_metrics.gs THRESHOLDS 와 동기화) ─────────────────
const RB_BUCKET_CONFIG = {
Core: { target: 66.0, min: 60.0, max: 72.0 },
Satellite: { target: 17.5, min: 10.0, max: 25.0 },
Cash: { target: 16.5, min: 10.0, max: 22.0 },
};
// 코어 주도주 (isCoreLeader 기준, gdc_02_account_satellite.gs 와 일치)
const RB_CORE_TICKERS = new Set(["005930", "000660", "000270"]);
// ── 레짐 적응 밴드 (P3) ──────────────────────────────────────────────────────
const RB_REGIME_BANDS = {
RISK_ON: { label: "RISK_ON ±15%p", expand: 15, contract: 15 },
SECULAR_LEADER_RISK_ON: { label: "RISK_ON ±15%p", expand: 15, contract: 15 },
NEUTRAL: { label: "NEUTRAL ±5%p", expand: 5, contract: 5 },
RISK_OFF_CANDIDATE: { label: "RISK_OFF_CANDIDATE +2/10%p", expand: 2, contract: 10 },
RISK_OFF: { label: "RISK_OFF +2/10%p", expand: 2, contract: 10 },
EVENT_SHOCK: { label: "RISK_OFF +2/10%p", expand: 2, contract: 10 },
_DEFAULT: { label: "NEUTRAL ±5%p", expand: 5, contract: 5 },
};
// ── 비용효익 게이트 (P4) ─────────────────────────────────────────────────────
const RB_TX_COST_ROUNDTRIP = 0.0070; // 0.35% × 2
const RB_COST_BENEFIT_THRESHOLD = 0.0050; // 0.50%p
const RB_MIN_DRIFT_PCT = (RB_TX_COST_ROUNDTRIP + RB_COST_BENEFIT_THRESHOLD) * 100; // 1.20%p
const RB_LIMIT_PRICE_DISCOUNT = 0.002; // 매도 지정가 = 종가 × (1 - 0.2%)
// ── 3단계 분할 비율 (P5) ─────────────────────────────────────────────────────
const RB_STAGE_RATIOS = [0.30, 0.30, 0.40];
// ═══════════════════════════════════════════════════════════════════════════════
// Public entry point
// ═══════════════════════════════════════════════════════════════════════════════
/**
* GatherTradingData.xlsx > rebalance 시트에 4섹션 리밸런싱 계획을 기록한다.
* 메뉴 또는 runDataFeed 후 자동 호출 가능.
*/
function runRebalanceSheet_() {
const tag = "runRebalanceSheet_";
const startMs = Date.now();
try {
// 1. 데이터 로드
const dfRows = _rbLoadDataFeedRows_();
const settings = readSettingsTab_();
const regime = _rbReadRegime_(settings);
const band = RB_REGIME_BANDS[regime] || RB_REGIME_BANDS["_DEFAULT"];
// 2. 보유 종목 필터링 (Weight_Pct > 0 || Account_Market_Value > 0)
const holdings = _rbFilterHoldings_(dfRows);
// 3. 버킷별 현재 비중 집계
const buckets = _rbComputeBuckets_(holdings, band);
// 4. 종목별 분석
const tickers = _rbComputeTickers_(holdings, band);
// 5. ORDERS 생성
const orders = _rbComputeOrders_(tickers);
// 6. SUMMARY 생성
const summary = _rbComputeSummary_(holdings, buckets, regime, band, orders.length);
// 7. 시트 쓰기
_writeRebalanceSheet_(summary, buckets, tickers, orders);
const elapsed = Math.round((Date.now() - startMs) / 100) / 10;
Logger.log(`[${tag}] 완료: holdings=${holdings.length} orders=${orders.length} elapsed=${elapsed}s`);
} catch (e) {
Logger.log(`[${tag}][ERROR] 오류: ${e.message}\n${e.stack}`);
throw e;
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// 데이터 로드
// ═══════════════════════════════════════════════════════════════════════════════
function _rbLoadDataFeedRows_() {
const raw = sheetToJson("data_feed");
if (!Array.isArray(raw) || raw.length === 0) {
throw new Error("data_feed 시트가 비어 있거나 로드 실패");
}
return raw;
}
function _rbReadRegime_(settings) {
const raw = (settings["REGIME_PRELIM"] || settings["regime_prelim"] || "").trim().toUpperCase();
return raw in RB_REGIME_BANDS ? raw : "_DEFAULT";
}
// ═══════════════════════════════════════════════════════════════════════════════
// 보유 종목 필터링
// ═══════════════════════════════════════════════════════════════════════════════
function _rbFilterHoldings_(dfRows) {
return dfRows
.map(row => {
const ticker = String(row["Ticker"] ?? "").trim();
if (!ticker) return null;
const weightPct = _rbNum_(row["Weight_Pct"]);
const acctMv = _rbNum_(row["Account_Market_Value"]);
if (weightPct <= 0 && acctMv <= 0) return null;
return {
ticker: ticker,
name: String(row["Name"] ?? ""),
bucket: _rbAssignBucket_(ticker, row),
weightPct: weightPct,
acctMvKrw: acctMv,
holdingQty: _rbInt_(row["Account_Holding_Qty"]),
close: _rbNum_(row["Close"]),
finalAction: String(row["Final_Action"] ?? ""),
sellReason: String(row["Sell_Reason"] ?? ""),
forceSignal: _rbDetectForce_(row),
};
})
.filter(h => h !== null);
}
function _rbAssignBucket_(ticker, row) {
const pt = String(row["position_type"] || row["Position_Type"] || "").trim().toLowerCase();
if (pt === "core") return "Core";
if (pt === "satellite") return "Satellite";
return RB_CORE_TICKERS.has(ticker) ? "Core" : "Satellite";
}
function _rbDetectForce_(row) {
const combined = [
row["Sell_Reason"], row["Final_Action"], row["Sell_Action"]
].join(" ").toUpperCase();
if (combined.includes("ABS_FLOOR")) return "ABS_FLOOR";
if (combined.includes("TIME_STOP") || combined.includes("TIME_EXIT") || combined.includes("TIME_TRIM"))
return "TIME_STOP";
return "";
}
// ═══════════════════════════════════════════════════════════════════════════════
// 버킷 계산
// ═══════════════════════════════════════════════════════════════════════════════
function _rbComputeBuckets_(holdings, band) {
const corePct = holdings.filter(h => h.bucket === "Core").reduce((s, h) => s + h.weightPct, 0);
const satPct = holdings.filter(h => h.bucket === "Satellite").reduce((s, h) => s + h.weightPct, 0);
const cashPct = Math.max(0, 100 - corePct - satPct);
const current = { Core: corePct, Satellite: satPct, Cash: cashPct };
return Object.entries(RB_BUCKET_CONFIG).map(([bname, bcfg]) => {
const target = bcfg.target;
const cur = _rb2_(current[bname] || 0);
const drift = _rb2_(cur - target);
const bandMin = _rb2_(target - band.contract);
const bandMax = _rb2_(target + band.expand);
let driftStatus;
if (cur < bandMin) driftStatus = "BREACH_LOW";
else if (cur > bandMax) driftStatus = "BREACH_HIGH";
else if (Math.abs(drift) >= RB_MIN_DRIFT_PCT / 2) driftStatus = "WARN";
else driftStatus = "NORMAL";
return { bucket: bname, targetPct: target, currentPct: cur, driftPct: drift,
bandMin, bandMax, regimeBand: band.label, driftStatus };
});
}
// ═══════════════════════════════════════════════════════════════════════════════
// 종목별 분석
// ═══════════════════════════════════════════════════════════════════════════════
function _rbComputeTickers_(holdings, band) {
// 버킷별 종목 수 집계
const countMap = {};
holdings.forEach(h => { countMap[h.bucket] = (countMap[h.bucket] || 0) + 1; });
return holdings.map(h => {
const bcfg = RB_BUCKET_CONFIG[h.bucket] || RB_BUCKET_CONFIG["Satellite"];
const nTickers = countMap[h.bucket] || 1;
const targetPct = _rb2_(bcfg.target / nTickers);
const currentPct = _rb2_(h.weightPct);
const drift = _rb2_(currentPct - targetPct);
const bandMin = _rb2_(targetPct - band.contract);
const bandMax = _rb2_(targetPct + band.expand);
const force = h.forceSignal;
let driftStatus, action, gateStatus;
if (force) {
driftStatus = "FORCE_" + force;
action = "SELL";
gateStatus = "FORCE_OVERRIDE";
} else if (currentPct > bandMax) {
driftStatus = "BREACH_HIGH";
action = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "SELL" : "WATCH";
gateStatus = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "PASS" : "BLOCKED_BY_COST";
} else if (currentPct < bandMin) {
driftStatus = "BREACH_LOW";
action = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "BUY" : "WATCH";
gateStatus = Math.abs(drift) >= RB_MIN_DRIFT_PCT ? "PASS" : "BLOCKED_BY_COST";
} else if (Math.abs(drift) >= RB_MIN_DRIFT_PCT / 2) {
driftStatus = "WARN";
action = "WATCH";
gateStatus = "BLOCKED_BY_COST";
} else {
driftStatus = "NORMAL";
action = "HOLD";
gateStatus = "BLOCKED_BY_COST";
}
// 3단계 수량 분할 (P5)
let s1q = 0, s1p = 0, s2q = 0, s2p = 0, s3q = 0, s3p = 0;
let tradeValueKrw = 0, costEstKrw = 0, netBenefitPct = 0;
if ((action === "SELL" || action === "BUY") && h.holdingQty > 0 && h.close > 0) {
let adjustQty;
if (action === "SELL" && currentPct > 0) {
const adjustRatio = Math.min(Math.abs(drift) / currentPct, 1.0);
adjustQty = Math.max(1, Math.round(h.holdingQty * adjustRatio));
} else {
adjustQty = Math.max(1, Math.round(h.holdingQty * 0.10));
}
const stages = _rbStageSplit_(adjustQty);
const limitP = _rbLimitPrice_(h.close, action);
[s1q, s2q, s3q] = stages;
[s1p, s2p, s3p] = [limitP, limitP, limitP];
tradeValueKrw = _rb2_((s1q + s2q + s3q) * limitP);
costEstKrw = _rb2_(tradeValueKrw * RB_TX_COST_ROUNDTRIP);
netBenefitPct = _rb2_(Math.abs(drift) - RB_TX_COST_ROUNDTRIP * 100);
}
return { ticker: h.ticker, name: h.name, bucket: h.bucket,
targetPct, currentPct, driftPct: drift, bandMin, bandMax,
regimeBand: band.label, driftStatus, forceSignal: force,
gateStatus, action,
stage1Qty: s1q, stage1Price: s1p,
stage2Qty: s2q, stage2Price: s2p,
stage3Qty: s3q, stage3Price: s3p,
tradeValueKrw, costEstKrw, netBenefitPct, close: h.close };
});
}
// ═══════════════════════════════════════════════════════════════════════════════
// ORDERS 생성
// ═══════════════════════════════════════════════════════════════════════════════
function _rbComputeOrders_(tickers) {
const active = tickers
.filter(t => t.gateStatus === "PASS" || t.gateStatus === "FORCE_OVERRIDE")
.sort((a, b) => {
const pa = a.gateStatus === "FORCE_OVERRIDE" ? 0 : 1;
const pb = b.gateStatus === "FORCE_OVERRIDE" ? 0 : 1;
if (pa !== pb) return pa - pb;
return Math.abs(b.driftPct) - Math.abs(a.driftPct);
});
const orders = [];
let orderNo = 1;
active.forEach(t => {
const stageDefs = [
{ stage: 1, qty: t.stage1Qty, price: t.stage1Price },
{ stage: 2, qty: t.stage2Qty, price: t.stage2Price },
{ stage: 3, qty: t.stage3Qty, price: t.stage3Price },
];
stageDefs.forEach(({ stage, qty, price }) => {
if (qty <= 0) return;
const reason = t.forceSignal || t.driftStatus;
orders.push({
orderNo, ticker: t.ticker, name: t.name, bucket: t.bucket,
action: t.action, stage, qty, limitPriceKrw: price,
tradeValueKrw: qty * price, reason,
});
orderNo++;
});
});
return orders;
}
// ═══════════════════════════════════════════════════════════════════════════════
// SUMMARY 생성
// ═══════════════════════════════════════════════════════════════════════════════
function _rbComputeSummary_(holdings, buckets, regime, band, ordersCount) {
const corePct = (buckets.find(b => b.bucket === "Core") || {}).currentPct || 0;
const satPct = (buckets.find(b => b.bucket === "Satellite") || {}).currentPct || 0;
const cashPct = (buckets.find(b => b.bucket === "Cash") || {}).currentPct || 0;
const rebalNeeded = buckets.some(b => b.driftStatus.startsWith("BREACH"));
const totalKrw = holdings.reduce((s, h) => s + h.acctMvKrw, 0);
const nowKst = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
return {
Run_Date: nowKst,
Regime: regime,
Regime_Band: band.label,
Total_Portfolio_KRW: totalKrw,
Core_Pct: corePct,
Satellite_Pct: satPct,
Cash_Pct: cashPct,
Target_Core_Pct: RB_BUCKET_CONFIG.Core.target,
Target_Sat_Pct: RB_BUCKET_CONFIG.Satellite.target,
Target_Cash_Pct: RB_BUCKET_CONFIG.Cash.target,
Rebalance_Needed: rebalNeeded,
Holdings_Count: holdings.length,
Orders_Count: ordersCount,
Min_Actionable_Drift_Pct: RB_MIN_DRIFT_PCT,
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// 시트 쓰기 — 4섹션 멀티섹션 레이아웃
// ═══════════════════════════════════════════════════════════════════════════════
function _writeRebalanceSheet_(summary, buckets, tickers, orders) {
const ss = getSpreadsheet_();
let sheet = ss.getSheetByName("rebalance");
if (!sheet) {
sheet = ss.insertSheet("rebalance");
} else {
sheet.clearContents();
}
const rows = [];
const nowKst = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
rows.push([`updated: ${nowKst} KST`]);
// ── SUMMARY 섹션 ──────────────────────────────────────────────────────────
rows.push(["=== SUMMARY ==="]);
Object.entries(summary).forEach(([k, v]) => rows.push([k, v]));
rows.push([""]);
// ── BUCKETS 섹션 ─────────────────────────────────────────────────────────
rows.push(["=== BUCKETS ==="]);
rows.push(["Bucket","Target_Pct","Current_Pct","Drift_Pct","Band_Min","Band_Max","Regime_Band","Drift_Status"]);
buckets.forEach(b => rows.push([
b.bucket, b.targetPct, b.currentPct, b.driftPct,
b.bandMin, b.bandMax, b.regimeBand, b.driftStatus,
]));
rows.push([""]);
// ── TICKERS 섹션 ─────────────────────────────────────────────────────────
rows.push(["=== TICKERS ==="]);
rows.push([
"Ticker","Name","Bucket","Target_Pct","Current_Pct","Drift_Pct",
"Band_Min","Band_Max","Regime_Band","Drift_Status","Force_Signal","Gate_Status","Action",
"Stage1_Qty","Stage1_Price","Stage2_Qty","Stage2_Price","Stage3_Qty","Stage3_Price",
"Trade_Value_KRW","Cost_Est_KRW","Net_Benefit_Pct","Close",
]);
tickers.forEach(t => rows.push([
t.ticker, t.name, t.bucket, t.targetPct, t.currentPct, t.driftPct,
t.bandMin, t.bandMax, t.regimeBand, t.driftStatus, t.forceSignal, t.gateStatus, t.action,
t.stage1Qty, t.stage1Price, t.stage2Qty, t.stage2Price, t.stage3Qty, t.stage3Price,
t.tradeValueKrw, t.costEstKrw, t.netBenefitPct, t.close,
]));
rows.push([""]);
// ── ORDERS 섹션 ──────────────────────────────────────────────────────────
rows.push(["=== ORDERS ==="]);
rows.push(["Order_No","Ticker","Name","Bucket","Action","Stage","Qty","Limit_Price_KRW","Trade_Value_KRW","Reason"]);
orders.forEach(o => rows.push([
o.orderNo, o.ticker, o.name, o.bucket, o.action,
o.stage, o.qty, o.limitPriceKrw, o.tradeValueKrw, o.reason,
]));
// 한 번에 쓰기
if (rows.length > 0) {
const maxCols = Math.max(...rows.map(r => r.length));
const padded = rows.map(r => {
while (r.length < maxCols) r.push("");
return r;
});
sheet.getRange(1, 1, padded.length, maxCols).setValues(padded);
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// 내부 유틸
// ═══════════════════════════════════════════════════════════════════════════════
function _rbNum_(v) {
const n = parseFloat(v);
return isNaN(n) ? 0 : n;
}
function _rbInt_(v) {
const n = parseInt(v, 10);
return isNaN(n) ? 0 : n;
}
function _rb2_(v) {
return Math.round(v * 100) / 100;
}
function _rbStageSplit_(totalQty) {
if (totalQty <= 0) return [0, 0, 0];
if (totalQty < 3) return [totalQty, 0, 0];
const s1 = Math.max(1, Math.floor(totalQty * RB_STAGE_RATIOS[0]));
const s2 = Math.max(1, Math.floor(totalQty * RB_STAGE_RATIOS[1]));
const s3 = Math.max(0, totalQty - s1 - s2);
return [s1, s2, s3];
}
function _rbLimitPrice_(close, action) {
if (close <= 0) return 0;
return action === "SELL" ? Math.round(close * (1 - RB_LIMIT_PRICE_DISCOUNT)) : Math.round(close);
}
// ── WBS-5.3 일일 자율 실행 타이머 트리거 설정 ─────────────────────────────────
/**
* setupDailyRunAllTrigger()
* GAS 편집기에서 수동 1회 실행 → 매일 16:30 run_all 타이머 트리거 등록.
* 중복 트리거 방지: 동일 함수명 트리거가 존재하면 먼저 삭제.
*/
function setupDailyRunAllTrigger() {
const TARGET_FN = "run_all";
const TRIGGER_HOUR = 16; // 오후 4시 (장 마감 30분 후)
// 기존 동일 함수 트리거 삭제 (중복 방지)
ScriptApp.getProjectTriggers().forEach(t => {
if (t.getHandlerFunction() === TARGET_FN) {
ScriptApp.deleteTrigger(t);
Logger.log("[WBS-5.3] 기존 트리거 삭제: " + TARGET_FN);
}
});
// 일일 타이머 트리거 등록 (매일 16:00~17:00 사이 실행)
ScriptApp.newTrigger(TARGET_FN)
.timeBased()
.atHour(TRIGGER_HOUR)
.everyDays(1)
.inTimezone("Asia/Seoul")
.create();
Logger.log("[WBS-5.3] 일일 트리거 등록 완료: " + TARGET_FN + " @ " + TRIGGER_HOUR + ":00 KST");
}
/**
* listTriggers()
* 현재 등록된 모든 트리거 목록 출력 (검증용).
*/
function listTriggers() {
ScriptApp.getProjectTriggers().forEach(t => {
Logger.log(
"trigger: fn=" + t.getHandlerFunction() +
" type=" + t.getEventType() +
" source=" + t.getTriggerSource()
);
});
}