Files
MBN_STOCK_WEBVIEW/Web/operator-workflow.js

441 lines
20 KiB
JavaScript

(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnOperatorWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const DEFAULT_FADE_DURATION = 6;
const MAX_TEXT_LENGTH = 128;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
const runtimeStockAsset = Object.freeze({
relativePath: "bin/Debug/Res/종목.ini",
sha256: "45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20"
});
function freezeCut(cut) {
return Object.freeze({
...cut,
section: cut.group,
alias: cut.aliases[0],
cutCode: cut.aliases[0],
aliases: Object.freeze([...cut.aliases])
});
}
// bin/Debug/Res/종목.ini (SHA-256 45DFB180...940409E20), in runtime order.
// The two '-' separator rows are deliberately absent. The source RES copy differs
// at the after-hours label, so the operator-visible runtime asset is authoritative.
const stockCuts = Object.freeze([
freezeCut({ id: "basic-expected", label: "1열판기본_예상체결가", group: "1열판 기본", detail: "예상체결가", builderKey: "s5001", aliases: ["5001"], kind: "basic", subtype: "EXPECTED_OPENING" }),
freezeCut({ id: "basic-current", label: "1열판기본_현재가", group: "1열판 기본", detail: "현재가", builderKey: "s5001", aliases: ["5001", "N5001"], kind: "basic", subtype: "CURRENT" }),
freezeCut({ id: "basic-after-hours", label: "1열판기본_시간외단일가", group: "1열판 기본", detail: "시간외단일가", builderKey: "s5001", aliases: ["5001"], kind: "basic", subtype: "AFTER_HOURS_SINGLE_PRICE" }),
freezeCut({ id: "detail-open", label: "1열판상세_시가", group: "1열판 상세", detail: "시가", builderKey: "s5006", aliases: ["5006"], kind: "open-detail", subtype: "시가" }),
freezeCut({ id: "detail-face-value", label: "1열판상세_액면가", group: "1열판 상세", detail: "액면가", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "FaceValue" }),
freezeCut({ id: "detail-pbr", label: "1열판상세_PBR", group: "1열판 상세", detail: "PBR", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "Valuation" }),
freezeCut({ id: "detail-volume", label: "1열판상세_거래량", group: "1열판 상세", detail: "거래량", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "Volume" }),
freezeCut({ id: "yield-5d", label: "수익률그래프_5일", group: "수익률 그래프", detail: "5일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "5일" }),
freezeCut({ id: "yield-20d", label: "수익률그래프_20일", group: "수익률 그래프", detail: "20일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "20일" }),
freezeCut({ id: "yield-60d", label: "수익률그래프_60일", group: "수익률 그래프", detail: "60일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "60일" }),
freezeCut({ id: "yield-120d", label: "수익률그래프_120일", group: "수익률 그래프", detail: "120일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "120일" }),
freezeCut({ id: "yield-240d", label: "수익률그래프_240일", group: "수익률 그래프", detail: "240일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "240일" }),
freezeCut({ id: "candle-daily", label: "캔들그래프_일봉", group: "캔들 그래프", detail: "일봉", builderKey: "s8010", aliases: ["8035"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-5d", label: "캔들그래프_5일", group: "캔들 그래프", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-20d", label: "캔들그래프_20일", group: "캔들 그래프", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-60d", label: "캔들그래프_60일", group: "캔들 그래프", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-120d", label: "캔들그래프_120일", group: "캔들 그래프", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-240d", label: "캔들그래프_240일", group: "캔들 그래프", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-volume-daily", label: "캔들그래프(거래량)_일봉", group: "캔들 그래프 · 거래량", detail: "일봉", builderKey: "s8010", aliases: ["8035"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-volume-5d", label: "캔들그래프(거래량)_5일", group: "캔들 그래프 · 거래량", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-volume-20d", label: "캔들그래프(거래량)_20일", group: "캔들 그래프 · 거래량", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-volume-60d", label: "캔들그래프(거래량)_60일", group: "캔들 그래프 · 거래량", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-volume-120d", label: "캔들그래프(거래량)_120일", group: "캔들 그래프 · 거래량", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-volume-240d", label: "캔들그래프(거래량)_240일", group: "캔들 그래프 · 거래량", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-expected-5d", label: "캔들그래프(예상체결가)_5일", group: "캔들 그래프 · 예상체결가", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "EXPECTED" }),
freezeCut({ id: "candle-expected-20d", label: "캔들그래프(예상체결가)_20일", group: "캔들 그래프 · 예상체결가", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "EXPECTED" }),
freezeCut({ id: "candle-expected-60d", label: "캔들그래프(예상체결가)_60일", group: "캔들 그래프 · 예상체결가", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "EXPECTED" }),
freezeCut({ id: "candle-expected-120d", label: "캔들그래프(예상체결가)_120일", group: "캔들 그래프 · 예상체결가", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "EXPECTED" }),
freezeCut({ id: "candle-expected-240d", label: "캔들그래프(예상체결가)_240일", group: "캔들 그래프 · 예상체결가", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "EXPECTED" }),
freezeCut({ id: "order-book", label: "호가창_표그래프", group: "표 그래프", detail: "호가창", builderKey: "s8003", aliases: ["8003"], kind: "order-book", subtype: "TABLE_GRAPH" }),
freezeCut({ id: "traders", label: "거래원_표그래프", group: "표 그래프", detail: "거래원", builderKey: "s5037", aliases: ["5037"], kind: "traders", subtype: "TABLE_GRAPH" })
]);
if (stockCuts.length !== 31 || new Set(stockCuts.map(cut => cut.id)).size !== 31) {
throw new Error("The legacy stock workflow must contain exactly 31 unique cut variants.");
}
function manualAction(id, label, builderKey, alias, table, prerequisite) {
return Object.freeze({
id,
label,
builderKey,
alias,
cutCode: alias,
aliases: Object.freeze([alias]),
available: false,
autoAdd: false,
status: "unavailable",
prerequisiteTable: table,
prerequisites: Object.freeze([
`선택 종목에 대한 ${table} 수동 입력 데이터`,
prerequisite,
"수동 데이터 검증과 명시적 플레이리스트 추가 흐름"
])
});
}
// These buttons opened GraphE/INPUT_* manual-data workflows in the source program.
// A stock search result alone is not sufficient input, so they must never auto-add a cut.
const manualStockActions = Object.freeze([
manualAction("manual-revenue-mix", "주요매출 구성", "s5076", "5076", "INPUT_PIE", "구성 항목과 비중 및 기준일 입력"),
manualAction("manual-growth-metrics", "성장성 지표", "s5079", "5079", "INPUT_GROW", "성장성 지표 값과 분기 입력"),
manualAction("manual-sales", "매출액", "s5080", "5080", "INPUT_SELL", "기간별 매출액 수동 값 입력"),
manualAction("manual-operating-profit", "영업이익", "s5081", "5081", "INPUT_PROFIT", "기간별 영업이익 수동 값 입력")
]);
const cutsById = new Map(stockCuts.map(cut => [cut.id, cut]));
function valueFrom(raw, names) {
for (const name of names) {
if (Object.prototype.hasOwnProperty.call(raw, name) && raw[name] !== undefined && raw[name] !== null) {
return raw[name];
}
}
const keys = Object.keys(raw);
for (const name of names) {
const key = keys.find(candidate => candidate.toLocaleUpperCase("en-US") === name.toLocaleUpperCase("en-US"));
if (key !== undefined && raw[key] !== undefined && raw[key] !== null) return raw[key];
}
return undefined;
}
function cleanText(value) {
if (typeof value !== "string") return null;
const text = value.trim();
return text.length > 0 && text.length <= MAX_TEXT_LENGTH && !controlCharacterPattern.test(text)
? text
: null;
}
function normalizeMarket(value) {
const text = String(value ?? "")
.trim()
.toLocaleUpperCase("en-US")
.replace(/[\s-]+/g, "_");
const isNxt = text.includes("NXT");
if (["KOSPI", "KOSPI_STOCK", "코스피", "코스피종목", "NXT_KOSPI", "KOSPI_NXT", "코스피_NXT", "NXT코스피"].includes(text)) {
return { market: "kospi", isNxt };
}
if (["KOSDAQ", "KOSDAQ_STOCK", "코스닥", "코스닥종목", "NXT_KOSDAQ", "KOSDAQ_NXT", "코스닥_NXT", "NXT코스닥"].includes(text)) {
return { market: "kosdaq", isNxt };
}
return null;
}
function normalizeBoolean(value) {
if (value === true || value === false) return value;
if (value === 1 || value === "1") return true;
if (value === 0 || value === "0") return false;
return null;
}
function validateStockResult(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { valid: false, error: "stock-object-required", value: null };
}
const marketValue = valueFrom(raw, ["market", "MARKET", "marketName", "MARKET_NAME", "groupCode", "GROUP_CODE"]);
const normalizedMarket = normalizeMarket(marketValue);
if (!normalizedMarket) return { valid: false, error: "unsupported-market", value: null };
const rawStockName = cleanText(valueFrom(raw, [
"stockName", "STOCK_NAME", "F_STOCK_NAME", "F_STOCK_WANNAME", "F_KNAM", "F_INPUT_NAME", "NAME", "WANNAME"
]));
const rawDisplayName = cleanText(valueFrom(raw, [
"displayName", "DISPLAY_NAME", "F_STOCK_WANNAME", "F_STOCK_NAME", "F_KNAM", "F_INPUT_NAME", "NAME", "WANNAME"
]));
if (!rawStockName && !rawDisplayName) {
return { valid: false, error: "stock-name-required", value: null };
}
const rawCode = valueFrom(raw, [
"stockCode", "STOCK_CODE", "F_STOCK_CODE", "F_SYMB", "SYMB", "F_PART_CODE", "CODE"
]);
const stockCode = typeof rawCode === "number" && Number.isSafeInteger(rawCode) && rawCode >= 0
? String(rawCode)
: cleanText(rawCode);
if (!stockCode || !stockCodePattern.test(stockCode)) {
return { valid: false, error: "invalid-stock-code", value: null };
}
const explicitNxtValue = valueFrom(raw, ["isNxt", "IS_NXT", "nxt", "NXT"]);
const explicitNxt = explicitNxtValue === undefined ? null : normalizeBoolean(explicitNxtValue);
if (explicitNxtValue !== undefined && explicitNxt === null) {
return { valid: false, error: "invalid-nxt-flag", value: null };
}
const nameImpliesNxt = `${rawStockName || ""} ${rawDisplayName || ""}`.includes("(NXT)");
const inferredNxt = normalizedMarket.isNxt || nameImpliesNxt;
if (explicitNxt === false && inferredNxt) {
return { valid: false, error: "inconsistent-nxt-selection", value: null };
}
const isNxt = explicitNxt === true || inferredNxt;
const withoutNxtSuffix = value => value.replace(/\s*\(NXT\)\s*/g, "").trim();
const stockName = cleanText(withoutNxtSuffix(rawStockName || rawDisplayName));
if (!stockName) return { valid: false, error: "stock-name-required", value: null };
const baseDisplayName = cleanText(rawDisplayName || rawStockName);
if (!baseDisplayName) return { valid: false, error: "display-name-required", value: null };
const displayName = isNxt
? `${withoutNxtSuffix(baseDisplayName)}(NXT)`
: baseDisplayName;
return {
valid: true,
error: null,
value: Object.freeze({
market: normalizedMarket.market,
stockName,
displayName,
stockCode,
isNxt
})
};
}
function normalizeStockResult(raw) {
const validation = validateStockResult(raw);
return validation.valid ? validation.value : null;
}
function getStockCut(cutId) {
return typeof cutId === "string" ? cutsById.get(cutId) || null : null;
}
function isStockCutAllowed(stock, cutId) {
const normalized = normalizeStockResult(stock);
const cut = getStockCut(cutId);
return Boolean(normalized && cut && (!normalized.isNxt || cut.id === "basic-current"));
}
function requireOptions(options) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Stock playlist options are required.");
}
const id = cleanText(options.id);
if (!id || !identifierPattern.test(id)) {
throw new TypeError("A safe stock playlist entry id is required.");
}
const ma5 = options.ma5 === undefined ? false : normalizeBoolean(options.ma5);
const ma20 = options.ma20 === undefined ? false : normalizeBoolean(options.ma20);
if (ma5 === null || ma20 === null) {
throw new TypeError("Moving-average flags must be boolean values.");
}
const fadeDuration = options.fadeDuration === undefined
? DEFAULT_FADE_DURATION
: Number(options.fadeDuration);
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
throw new RangeError("Fade duration must be an integer from 0 through 60.");
}
return { id, ma5, ma20, fadeDuration };
}
function marketGroup(stock, stockSuffix) {
const prefix = stock.isNxt ? "NXT_" : "";
return `${prefix}${stock.market.toLocaleUpperCase("en-US")}${stockSuffix}`;
}
function selectionFor(stock, cut, movingAverages) {
const common = {
subject: stock.displayName,
dataCode: stock.stockCode
};
switch (cut.kind) {
case "basic":
return {
groupCode: marketGroup(stock, ""),
...common,
graphicType: "1열판기본",
subtype: cut.subtype
};
case "open-detail":
return {
groupCode: marketGroup(stock, "_STOCK"),
...common,
graphicType: "1열판상세",
subtype: cut.subtype
};
case "detail":
return {
groupCode: marketGroup(stock, "_STOCK"),
...common,
graphicType: "1열판상세",
subtype: cut.subtype
};
case "yield":
return {
groupCode: marketGroup(stock, ""),
...common,
graphicType: "수익률그래프",
subtype: cut.subtype
};
case "candle": {
const flags = [];
if (movingAverages.ma5) flags.push("MA5");
if (movingAverages.ma20) flags.push("MA20");
return {
groupCode: marketGroup(stock, "_STOCK"),
...common,
graphicType: flags.join(","),
subtype: cut.subtype
};
}
case "order-book":
return {
groupCode: marketGroup(stock, ""),
...common,
graphicType: "ORDER_BOOK",
subtype: "TABLE_GRAPH"
};
case "traders":
return {
groupCode: marketGroup(stock, ""),
...common,
graphicType: "TRADER",
subtype: "TABLE_GRAPH"
};
default:
throw new Error("The stock cut mapping is unavailable.");
}
}
function createStockPlaylistEntry(stock, cutId, options) {
const normalized = normalizeStockResult(stock);
if (!normalized) throw new TypeError("A valid selected stock is required.");
const cut = getStockCut(cutId);
if (!cut) throw new RangeError("The selected stock cut is unknown.");
if (!isStockCutAllowed(normalized, cut.id)) {
throw new RangeError("NXT stocks support only 1열판기본_현재가.");
}
const normalizedOptions = requireOptions(options);
const movingAverages = Object.freeze({
ma5: cut.kind === "candle" && normalizedOptions.ma5,
ma20: cut.kind === "candle" && normalizedOptions.ma20
});
const selection = Object.freeze(selectionFor(normalized, cut, movingAverages));
const code = normalized.isNxt ? "N5001" : cut.aliases[0];
const operator = Object.freeze({
source: "legacy-stock-workflow",
cutId: cut.id,
legacyLabel: cut.label,
legacyGroup: cut.group,
legacyDetail: cut.detail,
stock: normalized,
movingAverages
});
return {
id: normalizedOptions.id,
builderKey: cut.builderKey,
code,
aliases: [...cut.aliases],
title: normalized.displayName,
detail: cut.label,
category: "stock",
market: normalized.market,
stockName: normalized.stockName,
displayName: normalized.displayName,
stockCode: normalized.stockCode,
isNxt: normalized.isNxt,
selection,
enabled: true,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: selection.graphicType,
subtype: selection.subtype,
operator
};
}
function sameStringArray(left, right) {
return Array.isArray(left) && Array.isArray(right) &&
left.length === right.length && left.every((value, index) => value === right[index]);
}
function sameSelection(left, right) {
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
return ["groupCode", "subject", "graphicType", "subtype", "dataCode"]
.every(key => typeof left[key] === "string" && left[key] === right[key]);
}
function restoreStockPlaylistEntry(value) {
if (!value || typeof value !== "object" || Array.isArray(value) ||
typeof value.enabled !== "boolean") {
return null;
}
const operator = value.operator;
if (!operator || typeof operator !== "object" ||
operator.source !== "legacy-stock-workflow" ||
typeof operator.cutId !== "string" ||
!operator.movingAverages || typeof operator.movingAverages !== "object" ||
typeof operator.movingAverages.ma5 !== "boolean" ||
typeof operator.movingAverages.ma20 !== "boolean") {
return null;
}
let recreated;
try {
recreated = createStockPlaylistEntry(operator.stock, operator.cutId, {
id: value.id,
ma5: operator.movingAverages.ma5,
ma20: operator.movingAverages.ma20,
fadeDuration: value.fadeDuration
});
} catch {
return null;
}
const trustedScalarKeys = [
"id", "builderKey", "code", "title", "detail", "category", "market",
"stockName", "displayName", "stockCode", "isNxt", "fadeDuration",
"graphicType", "subtype"
];
if (!trustedScalarKeys.every(key => value[key] === recreated[key]) ||
!sameStringArray(value.aliases, recreated.aliases) ||
!sameSelection(value.selection, recreated.selection) ||
operator.legacyLabel !== recreated.operator.legacyLabel ||
operator.legacyGroup !== recreated.operator.legacyGroup ||
operator.legacyDetail !== recreated.operator.legacyDetail) {
return null;
}
return { ...recreated, enabled: value.enabled };
}
return {
runtimeStockAsset,
stockCuts,
manualStockActions,
normalizeMarket,
validateStockResult,
normalizeStockResult,
getStockCut,
isStockCutAllowed,
createStockPlaylistEntry,
restoreStockPlaylistEntry
};
});