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