섹터 유니버스 분리와 월간 갱신 정합화
This commit is contained in:
+402
-27
@@ -1,5 +1,5 @@
|
||||
// gas_lib.gs - Common utilities & static features
|
||||
// Last Updated: 2026-06-14 20:48:30 KST
|
||||
// Last Updated: 2026-06-15 02:20:50 KST
|
||||
// Math/KRX utils, sheet I/O, sector flow, Web API, static runners
|
||||
// GAS global scope: functions in gas_data_feed.gs / gas_data_collect.gs callable directly
|
||||
//
|
||||
@@ -593,7 +593,14 @@ const DEFAULT_SECTOR_UNIVERSE_V2 = [
|
||||
{ code: "062040", name: "산일전기", weight: 0.10 },
|
||||
{ code: "298040", name: "효성중공업", weight: 0.10 },
|
||||
]},
|
||||
{ sector: "방산", proxyTicker: "012450", proxyName: "한화에어로스페이스", proxyType: "대표주", baseTicker: "069500", constituents: [
|
||||
{ sector: "전력설비", proxyTicker: "491820", proxyName: "HANARO 전력설비투자", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "010120", name: "LS ELECTRIC", weight: 0.28 },
|
||||
{ code: "267260", name: "HD현대일렉트릭", weight: 0.28 },
|
||||
{ code: "298040", name: "효성중공업", weight: 0.18 },
|
||||
{ code: "006260", name: "LS", weight: 0.14 },
|
||||
{ code: "099440", name: "두산에너빌리티", weight: 0.12 },
|
||||
]},
|
||||
{ sector: "방산", proxyTicker: "463250", proxyName: "TIGER K방산&우주", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "012450", name: "한화에어로스페이스", weight: 0.45 },
|
||||
{ code: "079550", name: "LIG넥스원", weight: 0.25 },
|
||||
{ code: "047810", name: "한국항공우주", weight: 0.15 },
|
||||
@@ -605,23 +612,49 @@ const DEFAULT_SECTOR_UNIVERSE_V2 = [
|
||||
{ code: "009540", name: "HD한국조선해양", weight: 0.20 },
|
||||
{ code: "494670", name: "TIGER 조선TOP10", weight: 0.15, isEtf: true },
|
||||
]},
|
||||
{ sector: "건설/EPC", proxyTicker: "028050", proxyName: "삼성E&A", proxyType: "대표주", baseTicker: "069500", constituents: [
|
||||
{ code: "028050", name: "삼성E&A", weight: 0.40 },
|
||||
{ code: "000720", name: "현대건설", weight: 0.30 },
|
||||
{ code: "006360", name: "GS건설", weight: 0.20 },
|
||||
{ code: "047040", name: "대우건설", weight: 0.10 },
|
||||
{ sector: "건설", proxyTicker: "117700", proxyName: "KODEX 건설", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "000720", name: "현대건설", weight: 0.35 },
|
||||
{ code: "006360", name: "GS건설", weight: 0.25 },
|
||||
{ code: "047040", name: "대우건설", weight: 0.20 },
|
||||
{ code: "294870", name: "HDC현대산업개발", weight: 0.20 },
|
||||
]},
|
||||
{ sector: "플랜트/EPC", proxyTicker: "454320", proxyName: "HANARO CAPEX설비투자iSelect", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "028050", name: "삼성E&A", weight: 0.35 },
|
||||
{ code: "010120", name: "LS ELECTRIC", weight: 0.20 },
|
||||
{ code: "267260", name: "HD현대일렉트릭", weight: 0.20 },
|
||||
{ code: "298040", name: "효성중공업", weight: 0.15 },
|
||||
{ code: "099440", name: "두산에너빌리티", weight: 0.10 },
|
||||
]},
|
||||
{ sector: "자동차", proxyTicker: "091180", proxyName: "TIGER 자동차", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "005380", name: "현대차", weight: 0.45 },
|
||||
{ code: "000270", name: "기아", weight: 0.40 },
|
||||
{ code: "012330", name: "현대모비스", weight: 0.15 },
|
||||
]},
|
||||
{ sector: "금융/은행", proxyTicker: "091170", proxyName: "KODEX 은행", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ sector: "은행", proxyTicker: "091170", proxyName: "KODEX 은행", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "105560", name: "KB금융", weight: 0.30 },
|
||||
{ code: "055550", name: "신한지주", weight: 0.30 },
|
||||
{ code: "086790", name: "하나금융지주", weight: 0.20 },
|
||||
{ code: "316140", name: "우리금융지주", weight: 0.10 },
|
||||
{ code: "003540", name: "대신증권", weight: 0.10 },
|
||||
{ code: "024110", name: "기업은행", weight: 0.10 },
|
||||
]},
|
||||
{ sector: "증권", proxyTicker: "0111J0", proxyName: "HANARO 증권고배당TOP3플러스", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "071050", name: "한국금융지주", weight: 0.2135 },
|
||||
{ code: "006800", name: "미래에셋증권", weight: 0.1934 },
|
||||
{ code: "005940", name: "NH투자증권", weight: 0.1911 },
|
||||
{ code: "016360", name: "삼성증권", weight: 0.1434 },
|
||||
{ code: "039490", name: "키움증권", weight: 0.1373 },
|
||||
]},
|
||||
{ sector: "지주회사", proxyTicker: "307520", proxyName: "TIGER 지주회사", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "180640", name: "한진칼", weight: 0.1535 },
|
||||
{ code: "267250", name: "HD현대", weight: 0.0943 },
|
||||
{ code: "034730", name: "SK", weight: 0.0884 },
|
||||
{ code: "000150", name: "두산", weight: 0.0878 },
|
||||
{ code: "005490", name: "POSCO홀딩스", weight: 0.0763 },
|
||||
{ code: "003550", name: "LG", weight: 0.0752 },
|
||||
{ code: "006260", name: "LS", weight: 0.0705 },
|
||||
{ code: "078930", name: "GS", weight: 0.0498 },
|
||||
{ code: "001040", name: "CJ", weight: 0.0477 },
|
||||
{ code: "010060", name: "OCI홀딩스", weight: 0.0240 },
|
||||
]},
|
||||
{ sector: "2차전지", proxyTicker: "305720", proxyName: "KODEX 2차전지산업", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "373220", name: "LG에너지솔루션", weight: 0.40 },
|
||||
@@ -635,12 +668,29 @@ const DEFAULT_SECTOR_UNIVERSE_V2 = [
|
||||
{ code: "128940", name: "한미약품", weight: 0.15 },
|
||||
{ code: "000100", name: "유한양행", weight: 0.10 },
|
||||
]},
|
||||
{ sector: "원전", proxyTicker: "099440", proxyName: "두산에너빌리티", proxyType: "대표주", baseTicker: "069500", constituents: [
|
||||
{ sector: "원전", proxyTicker: "434730", proxyName: "HANARO 원자력iSelect", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "099440", name: "두산에너빌리티", weight: 0.45 },
|
||||
{ code: "023450", name: "한전기술", weight: 0.25 },
|
||||
{ code: "015760", name: "한국전력", weight: 0.20 },
|
||||
{ code: "071320", name: "지역난방공사", weight: 0.10 },
|
||||
]},
|
||||
{ sector: "로보틱스", proxyTicker: "0190C0", proxyName: "RISE 현대차고정피지컬AI", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "005380", name: "현대차", weight: 0.2402 },
|
||||
{ code: "012330", name: "현대모비스", weight: 0.1588 },
|
||||
{ code: "011070", name: "LG이노텍", weight: 0.1450 },
|
||||
{ code: "000270", name: "기아", weight: 0.1234 },
|
||||
{ code: "307950", name: "현대오토에버", weight: 0.0899 },
|
||||
{ code: "277810", name: "레인보우로보틱스", weight: 0.0673 },
|
||||
{ code: "064400", name: "LG씨엔에스", weight: 0.0519 },
|
||||
{ code: "454910", name: "두산로보틱스", weight: 0.0367 },
|
||||
{ code: "108490", name: "로보티즈", weight: 0.0240 },
|
||||
{ code: "058610", name: "에스피지", weight: 0.0173 },
|
||||
{ code: "010620", name: "현대미포", weight: 0.0135 },
|
||||
{ code: "009540", name: "HD한국조선해양", weight: 0.0135 },
|
||||
{ code: "011210", name: "현대위아", weight: 0.0109 },
|
||||
{ code: "121600", name: "나노신소재", weight: 0.0040 },
|
||||
{ code: "028050", name: "삼성E&A", weight: 0.0034 },
|
||||
]},
|
||||
{ sector: "소비재", proxyTicker: "139220", proxyName: "TIGER 생활소비재", proxyType: "ETF", baseTicker: "069500", constituents: [
|
||||
{ code: "028260", name: "삼성물산", weight: 0.35 },
|
||||
{ code: "097950", name: "CJ제일제당", weight: 0.25 },
|
||||
@@ -663,6 +713,7 @@ function normalizeSectorName_(sector) {
|
||||
if (s === "바이오/헬스케어") return "바이오";
|
||||
if (s === "원전/에너지") return "원전";
|
||||
if (s === "소비재/유통") return "소비재";
|
||||
if (s === "건설/EPC") return "플랜트/EPC";
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -679,17 +730,52 @@ function readSectorUniverse_() {
|
||||
const sheet = ss.getSheetByName("sector_universe");
|
||||
if (!sheet) {
|
||||
writeDefaultSectorUniverseSheet_();
|
||||
return DEFAULT_SECTOR_UNIVERSE_V2;
|
||||
return DEFAULT_SECTOR_UNIVERSE_V2.map(sector => ({
|
||||
...sector,
|
||||
source: sector.source || "DEFAULT_TEMPLATE",
|
||||
sourceUrl: sector.sourceUrl || "",
|
||||
sourceAsOf: sector.sourceAsOf || "",
|
||||
constituents: sector.constituents.map(c => ({
|
||||
...c,
|
||||
source: c.source || sector.source || "DEFAULT_TEMPLATE",
|
||||
sourceUrl: c.sourceUrl || sector.sourceUrl || "",
|
||||
sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "",
|
||||
})),
|
||||
}));
|
||||
}
|
||||
const data = sheet.getDataRange().getValues();
|
||||
if (data.length < 3) {
|
||||
writeDefaultSectorUniverseSheet_();
|
||||
return DEFAULT_SECTOR_UNIVERSE_V2;
|
||||
return DEFAULT_SECTOR_UNIVERSE_V2.map(sector => ({
|
||||
...sector,
|
||||
source: sector.source || "DEFAULT_TEMPLATE",
|
||||
sourceUrl: sector.sourceUrl || "",
|
||||
sourceAsOf: sector.sourceAsOf || "",
|
||||
constituents: sector.constituents.map(c => ({
|
||||
...c,
|
||||
source: c.source || sector.source || "DEFAULT_TEMPLATE",
|
||||
sourceUrl: c.sourceUrl || sector.sourceUrl || "",
|
||||
sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "",
|
||||
})),
|
||||
}));
|
||||
}
|
||||
const hdr = data[1].map(h => String(h).trim());
|
||||
const idx = name => hdr.indexOf(name);
|
||||
const required = ["Sector","Proxy_Ticker","Constituent_Code","Weight"];
|
||||
if (required.some(h => idx(h) < 0)) return DEFAULT_SECTOR_UNIVERSE_V2;
|
||||
if (required.some(h => idx(h) < 0)) {
|
||||
return DEFAULT_SECTOR_UNIVERSE_V2.map(sector => ({
|
||||
...sector,
|
||||
source: sector.source || "DEFAULT_TEMPLATE",
|
||||
sourceUrl: sector.sourceUrl || "",
|
||||
sourceAsOf: sector.sourceAsOf || "",
|
||||
constituents: sector.constituents.map(c => ({
|
||||
...c,
|
||||
source: c.source || sector.source || "DEFAULT_TEMPLATE",
|
||||
sourceUrl: c.sourceUrl || sector.sourceUrl || "",
|
||||
sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "",
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
const map = {};
|
||||
for (let i = 2; i < data.length; i++) {
|
||||
@@ -706,6 +792,9 @@ function readSectorUniverse_() {
|
||||
proxyName: idx("Proxy_Name") >= 0 ? String(data[i][idx("Proxy_Name")] ?? "").trim() : "",
|
||||
proxyType: idx("Proxy_Type") >= 0 ? String(data[i][idx("Proxy_Type")] ?? "").trim() : "",
|
||||
baseTicker: idx("Base_Ticker") >= 0 ? normalizeTickerCode(data[i][idx("Base_Ticker")]) : "069500",
|
||||
source: idx("Source") >= 0 ? String(data[i][idx("Source")] ?? "").trim() : "SHEET_INPUT",
|
||||
sourceUrl: idx("Source_URL") >= 0 ? String(data[i][idx("Source_URL")] ?? "").trim() : "",
|
||||
sourceAsOf: idx("Source_AsOf") >= 0 ? String(data[i][idx("Source_AsOf")] ?? "").trim() : "",
|
||||
constituents: [],
|
||||
};
|
||||
}
|
||||
@@ -714,16 +803,59 @@ function readSectorUniverse_() {
|
||||
name: idx("Constituent_Name") >= 0 ? String(data[i][idx("Constituent_Name")] ?? "").trim() : "",
|
||||
weight,
|
||||
isEtf: idx("Is_ETF") >= 0 ? boolFromSheet_(data[i][idx("Is_ETF")], false) : false,
|
||||
source: idx("Source") >= 0 ? String(data[i][idx("Source")] ?? "").trim() : "SHEET_INPUT",
|
||||
transportMode: idx("Transport_Mode") >= 0 ? String(data[i][idx("Transport_Mode")] ?? "").trim() : "",
|
||||
sourceUrl: idx("Source_URL") >= 0 ? String(data[i][idx("Source_URL")] ?? "").trim() : "",
|
||||
sourceAsOf: idx("Source_AsOf") >= 0 ? String(data[i][idx("Source_AsOf")] ?? "").trim() : "",
|
||||
});
|
||||
}
|
||||
const sectors = Object.values(map).filter(s => s.proxyTicker && s.constituents.length > 0);
|
||||
return sectors.length ? sectors : DEFAULT_SECTOR_UNIVERSE_V2;
|
||||
const sectorSet = new Set(sectors.map(s => s.sector));
|
||||
for (const fallback of DEFAULT_SECTOR_UNIVERSE_V2) {
|
||||
if (!fallback || !fallback.sector || sectorSet.has(fallback.sector)) continue;
|
||||
sectors.push({
|
||||
sector: fallback.sector,
|
||||
proxyTicker: fallback.proxyTicker,
|
||||
proxyName: fallback.proxyName,
|
||||
proxyType: fallback.proxyType,
|
||||
baseTicker: fallback.baseTicker || "069500",
|
||||
source: fallback.source || "DEFAULT_TEMPLATE",
|
||||
transportMode: fallback.transportMode || ((fallback.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (fallback.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"),
|
||||
sourceUrl: fallback.sourceUrl || "",
|
||||
sourceAsOf: fallback.sourceAsOf || "",
|
||||
constituents: fallback.constituents.map(c => ({
|
||||
code: c.code,
|
||||
name: c.name || "",
|
||||
weight: c.weight,
|
||||
isEtf: Boolean(c.isEtf),
|
||||
source: c.source || fallback.source || "DEFAULT_TEMPLATE",
|
||||
transportMode: c.transportMode || ((c.source || fallback.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (c.source || fallback.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"),
|
||||
sourceUrl: c.sourceUrl || fallback.sourceUrl || "",
|
||||
sourceAsOf: c.sourceAsOf || fallback.sourceAsOf || "",
|
||||
})),
|
||||
});
|
||||
}
|
||||
return sectors.length ? sectors : DEFAULT_SECTOR_UNIVERSE_V2.map(sector => ({
|
||||
...sector,
|
||||
source: sector.source || "DEFAULT_TEMPLATE",
|
||||
transportMode: sector.transportMode || ((sector.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (sector.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"),
|
||||
sourceUrl: sector.sourceUrl || "",
|
||||
sourceAsOf: sector.sourceAsOf || "",
|
||||
constituents: sector.constituents.map(c => ({
|
||||
...c,
|
||||
source: c.source || sector.source || "DEFAULT_TEMPLATE",
|
||||
transportMode: c.transportMode || ((c.source || sector.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (c.source || sector.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"),
|
||||
sourceUrl: c.sourceUrl || sector.sourceUrl || "",
|
||||
sourceAsOf: c.sourceAsOf || sector.sourceAsOf || "",
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function writeDefaultSectorUniverseSheet_() {
|
||||
const headers = [
|
||||
"Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Base_Ticker",
|
||||
"Constituent_Code","Constituent_Name","Weight","Is_ETF","Enabled","Effective_Date","Source"
|
||||
"Constituent_Code","Constituent_Name","Weight","Is_ETF","Enabled","Effective_Date","Source","Transport_Mode",
|
||||
"Source_URL","Source_AsOf"
|
||||
];
|
||||
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
|
||||
const rows = [];
|
||||
@@ -741,7 +873,10 @@ function writeDefaultSectorUniverseSheet_() {
|
||||
c.isEtf ? "Y" : "N",
|
||||
"Y",
|
||||
today,
|
||||
"sector_universe(DEFAULT_SECTOR_UNIVERSE_V2)",
|
||||
sector.source || c.source || "DEFAULT_TEMPLATE",
|
||||
sector.transportMode || c.transportMode || (((sector.source || c.source || "DEFAULT_TEMPLATE") === "NAVER_ETF_PAGE" || (sector.source || c.source || "DEFAULT_TEMPLATE") === "REPRESENTATIVE_STOCK_PROXY") ? "HTML_SERVER_RENDERED" : "MANUAL_OR_TEMPLATE"),
|
||||
sector.sourceUrl || c.sourceUrl || "",
|
||||
sector.sourceAsOf || c.sourceAsOf || "",
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -762,6 +897,228 @@ function sectorUseMode_(quality) {
|
||||
return "INVALID";
|
||||
}
|
||||
|
||||
function parseDateOnly_(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return null;
|
||||
const norm = text.replace(/\./g, "-").slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(norm)) return null;
|
||||
const parsed = new Date(norm + "T00:00:00+09:00");
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function calcSectorUniverseRefreshAudit_(universe) {
|
||||
const today = new Date();
|
||||
const rows = [];
|
||||
const sourceKindCounts = { NAVER_ETF_PAGE: 0, NAVER_ETF_PAGE_FAIL_LAYOUT_CHANGED: 0, NAVER_ETF_PAGE_FAIL: 0, REPRESENTATIVE_STOCK_PROXY: 0, SHEET_INPUT: 0, DEFAULT_TEMPLATE: 0, OTHER: 0 };
|
||||
const transportModeCounts = { HTML_SERVER_RENDERED: 0, MANUAL_OR_TEMPLATE: 0, LAYOUT_CHANGED: 0, UNKNOWN: 0 };
|
||||
let currentCount = 0;
|
||||
let dueCount = 0;
|
||||
let overdueCount = 0;
|
||||
let missingCount = 0;
|
||||
let templateCount = 0;
|
||||
let sheetInputCount = 0;
|
||||
let naverSourceCount = 0;
|
||||
let layoutChangedCount = 0;
|
||||
let missingSourceUrlCount = 0;
|
||||
let staleSectorCount = 0;
|
||||
let oldestSourceAsOf = null;
|
||||
let newestSourceAsOf = null;
|
||||
|
||||
for (const sector of universe || []) {
|
||||
const sectorRows = Array.isArray(sector?.constituents) ? sector.constituents : [];
|
||||
const sourceKind = String(sector?.source || "SHEET_INPUT").trim() || "SHEET_INPUT";
|
||||
if (Object.prototype.hasOwnProperty.call(sourceKindCounts, sourceKind)) {
|
||||
sourceKindCounts[sourceKind] += 1;
|
||||
} else {
|
||||
sourceKindCounts.OTHER += 1;
|
||||
}
|
||||
const transportMode = String(sector?.transportMode || "").trim() ||
|
||||
(sourceKind === "NAVER_ETF_PAGE" || sourceKind === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED" :
|
||||
sourceKind === "NAVER_ETF_PAGE_FAIL_LAYOUT_CHANGED" ? "LAYOUT_CHANGED" :
|
||||
(sourceKind === "DEFAULT_TEMPLATE" || sourceKind === "SHEET_INPUT" ? "MANUAL_OR_TEMPLATE" : "UNKNOWN"));
|
||||
if (Object.prototype.hasOwnProperty.call(transportModeCounts, transportMode)) {
|
||||
transportModeCounts[transportMode] += 1;
|
||||
} else {
|
||||
transportModeCounts.UNKNOWN += 1;
|
||||
}
|
||||
|
||||
const sourceUrl = String(sector?.sourceUrl || "").trim();
|
||||
const sourceAsOf = String(sector?.sourceAsOf || "").trim();
|
||||
const parsed = parseDateOnly_(sourceAsOf);
|
||||
const ageDays = parsed ? Math.floor((today.getTime() - parsed.getTime()) / 86400000) : null;
|
||||
if (parsed) {
|
||||
oldestSourceAsOf = oldestSourceAsOf && oldestSourceAsOf < parsed ? oldestSourceAsOf : parsed;
|
||||
newestSourceAsOf = newestSourceAsOf && newestSourceAsOf > parsed ? newestSourceAsOf : parsed;
|
||||
}
|
||||
|
||||
let status = "INVALID";
|
||||
const reasons = [];
|
||||
if (sourceKind === "DEFAULT_TEMPLATE") {
|
||||
status = "TEMPLATE";
|
||||
templateCount += 1;
|
||||
reasons.push("DEFAULT_TEMPLATE");
|
||||
} else if (sourceKind === "REPRESENTATIVE_STOCK_PROXY") {
|
||||
if (!sourceUrl) {
|
||||
status = "MISSING";
|
||||
missingCount += 1;
|
||||
missingSourceUrlCount += 1;
|
||||
reasons.push("Source_URL_MISSING");
|
||||
} else if (ageDays === null) {
|
||||
status = "MISSING";
|
||||
missingCount += 1;
|
||||
reasons.push("Source_AsOf_MISSING");
|
||||
} else if (ageDays <= 31) {
|
||||
status = "CURRENT";
|
||||
currentCount += 1;
|
||||
} else if (ageDays <= 45) {
|
||||
status = "DUE";
|
||||
dueCount += 1;
|
||||
staleSectorCount += 1;
|
||||
reasons.push(`AgeDays=${ageDays}`);
|
||||
} else {
|
||||
status = "OVERDUE";
|
||||
overdueCount += 1;
|
||||
staleSectorCount += 1;
|
||||
reasons.push(`AgeDays=${ageDays}`);
|
||||
}
|
||||
} else if (sourceKind === "SHEET_INPUT") {
|
||||
sheetInputCount += 1;
|
||||
if (!sourceUrl) {
|
||||
status = "MISSING";
|
||||
missingCount += 1;
|
||||
missingSourceUrlCount += 1;
|
||||
reasons.push("Source_URL_MISSING");
|
||||
} else if (ageDays === null) {
|
||||
status = "MISSING";
|
||||
missingCount += 1;
|
||||
reasons.push("Source_AsOf_MISSING");
|
||||
} else if (ageDays <= 31) {
|
||||
status = "CURRENT";
|
||||
currentCount += 1;
|
||||
} else if (ageDays <= 45) {
|
||||
status = "DUE";
|
||||
dueCount += 1;
|
||||
staleSectorCount += 1;
|
||||
reasons.push(`AgeDays=${ageDays}`);
|
||||
} else {
|
||||
status = "OVERDUE";
|
||||
overdueCount += 1;
|
||||
staleSectorCount += 1;
|
||||
reasons.push(`AgeDays=${ageDays}`);
|
||||
}
|
||||
} else if (sourceKind === "NAVER_ETF_PAGE") {
|
||||
naverSourceCount += 1;
|
||||
if (!sourceUrl) {
|
||||
status = "MISSING";
|
||||
missingCount += 1;
|
||||
missingSourceUrlCount += 1;
|
||||
reasons.push("Source_URL_MISSING");
|
||||
} else if (ageDays === null) {
|
||||
status = "MISSING";
|
||||
missingCount += 1;
|
||||
reasons.push("Source_AsOf_MISSING");
|
||||
} else if (ageDays <= 31) {
|
||||
status = "CURRENT";
|
||||
currentCount += 1;
|
||||
} else if (ageDays <= 45) {
|
||||
status = "DUE";
|
||||
dueCount += 1;
|
||||
staleSectorCount += 1;
|
||||
reasons.push(`AgeDays=${ageDays}`);
|
||||
} else {
|
||||
status = "OVERDUE";
|
||||
overdueCount += 1;
|
||||
staleSectorCount += 1;
|
||||
reasons.push(`AgeDays=${ageDays}`);
|
||||
}
|
||||
} else if (sourceKind === "NAVER_ETF_PAGE_FAIL_LAYOUT_CHANGED") {
|
||||
layoutChangedCount += 1;
|
||||
status = "LAYOUT_CHANGED";
|
||||
if (!sourceUrl) {
|
||||
missingSourceUrlCount += 1;
|
||||
reasons.push("Source_URL_MISSING");
|
||||
}
|
||||
if (ageDays === null) {
|
||||
reasons.push("Source_AsOf_MISSING");
|
||||
} else {
|
||||
staleSectorCount += 1;
|
||||
reasons.push(`AgeDays=${ageDays}`);
|
||||
}
|
||||
} else {
|
||||
status = "INVALID";
|
||||
reasons.push("SOURCE_KIND_UNKNOWN");
|
||||
if (!sourceUrl) missingSourceUrlCount += 1;
|
||||
}
|
||||
if (!sourceUrl) reasons.push("Source_URL_MISSING");
|
||||
if (ageDays !== null && ageDays < 0) reasons.push("FUTURE_DATE");
|
||||
|
||||
rows.push({
|
||||
sector: sector.sector || "",
|
||||
proxy_ticker: sector.proxyTicker || "",
|
||||
proxy_name: sector.proxyName || "",
|
||||
proxy_type: sector.proxyType || "",
|
||||
source_kind: sourceKind,
|
||||
transport_mode: transportMode,
|
||||
source_url: sourceUrl,
|
||||
source_asof: sourceAsOf,
|
||||
age_days: ageDays === null ? "" : ageDays,
|
||||
constituent_count: sectorRows.length,
|
||||
stock_count: sectorRows.filter(c => !c.isEtf).length,
|
||||
etf_count: sectorRows.filter(c => c.isEtf).length,
|
||||
weight_sum: sectorRows.reduce((a, c) => a + (Number(c.weight) || 0), 0),
|
||||
status: status,
|
||||
refresh_reason: reasons.length ? reasons.join(";") : "OK",
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort((a, b) => {
|
||||
if (a.status === "CURRENT" && b.status !== "CURRENT") return -1;
|
||||
if (a.status !== "CURRENT" && b.status === "CURRENT") return 1;
|
||||
return String(a.sector || "").localeCompare(String(b.sector || ""));
|
||||
});
|
||||
|
||||
return {
|
||||
formula_id: "sector_universe_refresh_audit_v1",
|
||||
gate: (templateCount > 0 || missingSourceUrlCount > 0 || overdueCount > 0 || staleSectorCount > 0) ? "FAIL" : (sheetInputCount > 0 ? "WARN" : "PASS"),
|
||||
summary: {
|
||||
sector_count: (universe || []).length,
|
||||
current_count: currentCount,
|
||||
due_count: dueCount,
|
||||
overdue_count: overdueCount,
|
||||
missing_count: missingCount,
|
||||
template_count: templateCount,
|
||||
sheet_input_count: sheetInputCount,
|
||||
naver_source_count: naverSourceCount,
|
||||
layout_changed_count: layoutChangedCount,
|
||||
missing_source_url_count: missingSourceUrlCount,
|
||||
stale_sector_count: staleSectorCount,
|
||||
oldest_source_asof: oldestSourceAsOf ? Utilities.formatDate(oldestSourceAsOf, "Asia/Seoul", "yyyy-MM-dd") : "",
|
||||
newest_source_asof: newestSourceAsOf ? Utilities.formatDate(newestSourceAsOf, "Asia/Seoul", "yyyy-MM-dd") : "",
|
||||
source_kind_counts: sourceKindCounts,
|
||||
transport_mode_counts: transportModeCounts,
|
||||
ajax_mode: "NO",
|
||||
transport_model: "HTML_SERVER_RENDERED",
|
||||
},
|
||||
rows: rows,
|
||||
};
|
||||
}
|
||||
|
||||
function writeSectorUniverseRefreshAuditSheet_(audit) {
|
||||
if (!audit || typeof audit !== "object") return 0;
|
||||
const headers = [
|
||||
"sector", "proxy_ticker", "proxy_name", "proxy_type", "source_kind", "transport_mode",
|
||||
"source_url", "source_asof", "age_days", "constituent_count",
|
||||
"stock_count", "etf_count", "weight_sum", "status", "refresh_reason",
|
||||
];
|
||||
const rows = Array.isArray(audit.rows)
|
||||
? audit.rows.map(function(r) {
|
||||
return headers.map(function(h) { return r[h] ?? ""; });
|
||||
})
|
||||
: [];
|
||||
writeToSheet("sector_universe_refresh_audit", headers, rows);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
function scoreSmartMoneyNorm_(v) {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
if (v >= 0.15) return 25;
|
||||
@@ -955,7 +1312,7 @@ function runSectorFlowV3() {
|
||||
const etfRawMap = buildEtfRawMap_(buildEtfRawRows_(universe));
|
||||
const today = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd");
|
||||
const headers = [
|
||||
"Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Coverage_Weight",
|
||||
"Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Universe_Source","Transport_Mode","Coverage_Weight",
|
||||
"Sector_Ret5D","Sector_Ret20D","Sector_RS_20D",
|
||||
"SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW","SmartMoney_5D_Norm",
|
||||
"Flow_Breadth_5D","Flow_Rows_Min","Stale_Count",
|
||||
@@ -1031,6 +1388,9 @@ function runSectorFlowV3() {
|
||||
const etfNavRisk = sector.proxyType === "ETF" ? (etfRaw?.navRisk ?? "NAV_DATA_MISSING") : "NOT_ETF";
|
||||
const etfLiquidityStatus = sector.proxyType === "ETF" ? (etfRaw?.liquidityStatus ?? "WARN") : "NOT_ETF";
|
||||
const etfExecutionUse = sector.proxyType === "ETF" ? (etfRaw?.executionUse ?? "WATCH_ONLY") : "NOT_ETF";
|
||||
const transportMode = sector.source === "NAVER_ETF_PAGE" ? "HTML_SERVER_RENDERED"
|
||||
: (sector.source === "REPRESENTATIVE_STOCK_PROXY" ? "HTML_SERVER_RENDERED"
|
||||
: (sector.source === "DEFAULT_TEMPLATE" ? "MANUAL_OR_TEMPLATE" : "UNKNOWN"));
|
||||
const quality = sectorDataQuality_(coverage, flowRowsMin, staleCount, proxy.ok, Number.isFinite(smart5Norm), weightSum);
|
||||
const routeUse = sectorUseMode_(quality);
|
||||
let score = calcSectorScoreV2_(sectorRet20D, sectorRs20D, smart5Norm, smart20Norm, breadth5, tradeValueRatio, sector.proxyType, etfLiquidityScore);
|
||||
@@ -1047,6 +1407,7 @@ function runSectorFlowV3() {
|
||||
if (staleCount > 0) reasons.push(`Stale_Count=${staleCount}`);
|
||||
if (!proxy.ok) reasons.push("Proxy_Price_FAIL");
|
||||
if (!Number.isFinite(smart5Norm)) reasons.push("SmartMoney_Norm_MISSING");
|
||||
if ((sector.source || "DEFAULT_TEMPLATE") === "DEFAULT_TEMPLATE") reasons.push("Universe_Source=DEFAULT_TEMPLATE");
|
||||
if (sector.proxyType === "ETF" && etfNavRisk === "NAV_DATA_MISSING") reasons.push("ETF_NAV_DATA_MISSING");
|
||||
if (sector.proxyType === "ETF" && etfLiquidityStatus !== "OK") reasons.push(`ETF_Liquidity=${etfLiquidityStatus}`);
|
||||
if (sector.proxyType === "ETF" && etfExecutionUse !== "TRADE_OK") reasons.push(`ETF_Execution=${etfExecutionUse}`);
|
||||
@@ -1056,6 +1417,8 @@ function runSectorFlowV3() {
|
||||
proxyTicker: sector.proxyTicker,
|
||||
proxyName: sector.proxyName,
|
||||
proxyType: sector.proxyType || "대표주",
|
||||
universeSource: sector.source || "DEFAULT_TEMPLATE",
|
||||
transportMode: transportMode,
|
||||
coverage,
|
||||
sectorRet5D,
|
||||
sectorRet20D,
|
||||
@@ -1106,7 +1469,7 @@ function appendSectorFlowHistoryV2_(rows) {
|
||||
|
||||
const headers = [
|
||||
"Snapshot_Date","Sector","Sector_Score","Sector_Rank","SmartMoney_5D_KRW","SmartMoney_20D_KRW",
|
||||
"Flow_Breadth_5D","Alert_Level","Data_Quality","Decision_Use","ETF_Liquidity_Status","ETF_Execution_Use","Reason","Saved_At"
|
||||
"Flow_Breadth_5D","Alert_Level","Data_Quality","Decision_Use","ETF_Liquidity_Status","ETF_Execution_Use","Transport_Mode","Reason","Saved_At"
|
||||
];
|
||||
const ss = getSpreadsheet_();
|
||||
let sheet = ss.getSheetByName("sector_flow_history");
|
||||
@@ -1119,22 +1482,25 @@ function appendSectorFlowHistoryV2_(rows) {
|
||||
const hdr = data[1] ?? headers;
|
||||
const dateIdx = hdr.indexOf("Snapshot_Date");
|
||||
const sectorIdx = hdr.indexOf("Sector");
|
||||
const existing = [];
|
||||
const normalizeRow_ = (row) => {
|
||||
const outRow = Array.isArray(row) ? row.slice(0, headers.length) : [];
|
||||
while (outRow.length < headers.length) outRow.push("");
|
||||
return outRow;
|
||||
};
|
||||
const byKey = {};
|
||||
for (let i = 2; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
const d = normalizeSheetDateString_(row[dateIdx]);
|
||||
const s = String(row[sectorIdx] ?? "").trim();
|
||||
if (!d || !s) continue;
|
||||
byKey[`${d}|${s}`] = row;
|
||||
existing.push(row);
|
||||
byKey[`${d}|${s}`] = normalizeRow_(row);
|
||||
}
|
||||
const savedAt = Utilities.formatDate(new Date(), "Asia/Seoul", "yyyy-MM-dd HH:mm:ss");
|
||||
for (const r of rows) {
|
||||
byKey[`${r.asOfDate}|${r.sector}`] = [
|
||||
byKey[`${r.asOfDate}|${r.sector}`] = normalizeRow_([
|
||||
r.asOfDate, r.sector, r.score, r.rank, Math.round(r.smart5), Math.round(r.smart20),
|
||||
roundNum(r.breadth5, 4), r.alert, r.quality, r.routeUse, r.etfLiquidityStatus, r.etfExecutionUse, r.reason, savedAt
|
||||
];
|
||||
roundNum(r.breadth5, 4), r.alert, r.quality, r.routeUse, r.etfLiquidityStatus, r.etfExecutionUse, r.transportMode || "", r.reason, savedAt
|
||||
]);
|
||||
}
|
||||
const out = Object.values(byKey).sort((a, b) => {
|
||||
const da = String(a[0]), db = String(b[0]);
|
||||
@@ -1144,7 +1510,7 @@ function appendSectorFlowHistoryV2_(rows) {
|
||||
sheet.clearContents();
|
||||
sheet.getRange(1, 1).setValue(`updated: ${savedAt} KST`);
|
||||
sheet.getRange(2, 1, 1, headers.length).setValues([headers]);
|
||||
if (out.length) sheet.getRange(3, 1, out.length, headers.length).setValues(out);
|
||||
if (out.length) sheet.getRange(3, 1, out.length, headers.length).setValues(out.map(normalizeRow_));
|
||||
}
|
||||
|
||||
function normalizeSheetDateString_(value) {
|
||||
@@ -1235,7 +1601,7 @@ function readW2LegacySectorFlow_() {
|
||||
|
||||
function writeLegacySectorFlowFromStage2_(stage2Rows) {
|
||||
const headers = [
|
||||
"Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Coverage_Weight",
|
||||
"Sector","Proxy_Ticker","Proxy_Name","Proxy_Type","Universe_Source","Coverage_Weight",
|
||||
"Sector_Ret5D","Sector_Ret10D","Sector_Ret20D","Sector_RS_20D",
|
||||
"SmartMoney_5D_KRW","SmartMoney_20D_KRW","Sector_AvgTradeValue_20D_KRW",
|
||||
"SmartMoney_5D_Norm","SmartMoney_20D_Norm","Flow_Breadth_5D","Flow_Rows_Min","Stale_Count",
|
||||
@@ -1277,7 +1643,7 @@ function writeLegacySectorFlowFromStage2_(stage2Rows) {
|
||||
const frg20Alias = Number.isFinite(r.smart20) ? r.smart20 / 2 : "";
|
||||
const inst20Alias = Number.isFinite(r.smart20) ? r.smart20 / 2 : "";
|
||||
return [
|
||||
r.sector, r.proxyTicker, r.proxyName, r.proxyType, r.coverage,
|
||||
r.sector, r.proxyTicker, r.proxyName, r.proxyType, r.universeSource, r.coverage,
|
||||
r.sectorRet5D, r.proxyRet10D, r.sectorRet20D, r.sectorRs20D,
|
||||
r.smart5, r.smart20, r.avgTv20Krw,
|
||||
r.smart5Norm, r.smart20Norm, r.breadth5, r.flowRowsMin, r.staleCount,
|
||||
@@ -1798,6 +2164,15 @@ function run_all() {
|
||||
}
|
||||
},
|
||||
{ name: "runSectorFlow", fn: runSectorFlow },
|
||||
{
|
||||
name: "runSectorUniverseRefreshAudit",
|
||||
fn: function() {
|
||||
const universe = readSectorUniverse_();
|
||||
const audit = calcSectorUniverseRefreshAudit_(universe);
|
||||
writeSectorUniverseRefreshAuditSheet_(audit);
|
||||
Logger.log("[RUN_ALL] sector_universe_refresh_audit gate=" + audit.gate + " rows=" + (audit.rows || []).length);
|
||||
}
|
||||
},
|
||||
{ name: "runDataFeed", fn: runDataFeed },
|
||||
{ name: "runCoreSatelliteFlow_", fn: runCoreSatelliteFlow_ },
|
||||
{ name: "runEventRisk", fn: runEventRisk },
|
||||
|
||||
Reference in New Issue
Block a user