Complete legacy operator UI and playout migration

This commit is contained in:
2026-07-12 02:05:49 +09:00
parent d2e16bf0c2
commit ffb8f43c19
131 changed files with 48473 additions and 228 deletions

326
Web/industry-workflow.js Normal file
View File

@@ -0,0 +1,326 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnIndustryWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const DEFAULT_FADE_DURATION = 6;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const codePattern = /^[A-Za-z0-9]{1,32}$/;
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
const markets = Object.freeze(["kospi", "kosdaq"]);
const runtimeAssets = Object.freeze({
kospi: Object.freeze({
relativePath: "bin/Debug/Res/업종_코스피.ini",
sha256: "F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52"
}),
kosdaq: Object.freeze({
relativePath: "bin/Debug/Res/업종_코스닥.ini",
sha256: "F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4"
})
});
const periodCuts = [
["5일", "8061", "FiveDays"], ["20일", "8040", "TwentyDays"],
["60일", "8046", "SixtyDays"], ["120일", "8051", "OneHundredTwentyDays"],
["240일", "8056", "TwoHundredFortyDays"]
];
const candleCuts = [["일봉", "8035"], ...periodCuts.map(([label, code]) => [label, code])];
function cut(id, label, section, kind, details = {}) {
return Object.freeze({ id, label, section, kind, ...details });
}
const cuts = Object.freeze([
cut("one-column", "1열판", "업종 판", "single", { builderKey: "s5001" }),
cut("two-column", "2열판", "업종 판", "pair", { builderKey: "s8018" }),
...periodCuts.map(([label, , period]) =>
cut(`yield-${label}`, `수익률그래프_${label}`, "수익률 그래프", "yield", {
builderKey: "s5086", period
})),
...candleCuts.map(([label, code]) =>
cut(`candle-${label}`, `캔들그래프_${label}`, "캔들 그래프", "candle", {
builderKey: "s8010", code, mode: "PRICE"
})),
...candleCuts.map(([label, code]) =>
cut(`candle-volume-${label}`, `캔들그래프(거래량)_${label}`, "캔들 그래프 · 거래량", "candle", {
builderKey: "s8010", code, mode: "VOLUME"
})),
cut("five-row", "5단 표그래프", "시장 전체", "fixed", { builderKey: "s5074" }),
cut("square", "네모그래프", "시장 전체", "fixed", { builderKey: "s8001" }),
cut("sector", "섹터지수", "시장 전체", "fixed", { builderKey: "s5078" })
]);
if (cuts.length !== 22 || new Set(cuts.map(value => value.id)).size !== 22) {
throw new Error("Each legacy industry tab must contain exactly 22 cut actions.");
}
const cutsById = new Map(cuts.map(value => [value.id, value]));
function cleanText(value, maximumLength = 128) {
if (typeof value !== "string") return null;
const text = value.trim();
return text && text.length <= maximumLength && !controlCharacterPattern.test(text) ? text : null;
}
function normalizeMarket(value) {
const text = String(value || "").trim().toLocaleLowerCase("en-US");
return markets.includes(text) ? text : null;
}
function normalizeIndustry(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const market = normalizeMarket(value.market);
const name = cleanText(value.name ?? value.industryName);
const code = cleanText(value.code ?? value.industryCode, 32);
if (!market || !name || !code || !codePattern.test(code)) return null;
return Object.freeze({ market, name, code });
}
function localDateText(now = new Date()) {
const year = String(now.getFullYear()).padStart(4, "0");
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function requireOptions(options) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Industry playlist options are required.");
}
const id = cleanText(options.id);
if (!id || !identifierPattern.test(id)) throw new TypeError("A safe industry playlist id is required.");
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.");
}
const now = options.now instanceof Date && !Number.isNaN(options.now.valueOf()) ? options.now : new Date();
const ma5 = options.ma5 === undefined ? false : options.ma5;
const ma20 = options.ma20 === undefined ? false : options.ma20;
if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
throw new TypeError("Moving-average flags must be boolean values.");
}
return { id, fadeDuration, now, ma5, ma20 };
}
function mappingFor(market, selectedCut, selected, primary, secondary, now, movingAverages) {
const industryGroup = market === "kospi" ? "INDUSTRY_KOSPI" : "INDUSTRY_KOSDAQ";
const candleGroup = market === "kospi" ? "KOSPI_INDUSTRY" : "KOSDAQ_INDUSTRY";
const pagedGroup = market === "kospi" ? "PAGED_DOMESTIC_KOSPI" : "PAGED_DOMESTIC_KOSDAQ";
const marketName = market === "kospi" ? "KOSPI" : "KOSDAQ";
switch (selectedCut.kind) {
case "single":
if (!selected) throw new RangeError("Select one industry first.");
return {
code: "5001",
selection: {
groupCode: industryGroup, subject: selected.name,
graphicType: "1열판", subtype: "CURRENT", dataCode: ""
}
};
case "pair":
if (!primary || !secondary || primary.name.includes("-") || secondary.name.includes("-")) {
throw new RangeError("Select two delimiter-safe industries first.");
}
return {
code: market === "kospi" ? "8018" : "8032",
selection: {
groupCode: industryGroup, subject: `${primary.name}-${secondary.name}`,
graphicType: "2열판", subtype: "", dataCode: ""
}
};
case "yield":
if (!selected) throw new RangeError("Select one industry first.");
return {
code: "5086",
selection: {
groupCode: industryGroup, subject: selected.name,
graphicType: "YIELD_GRAPH", subtype: selectedCut.period, dataCode: ""
}
};
case "candle":
if (!selected) throw new RangeError("Select one industry first.");
const flags = [];
if (movingAverages.ma5) flags.push("MA5");
if (movingAverages.ma20) flags.push("MA20");
return {
code: selectedCut.code,
selection: {
groupCode: candleGroup, subject: selected.name,
graphicType: flags.join(","), subtype: selectedCut.mode, dataCode: ""
}
};
case "fixed":
if (selectedCut.id === "five-row") {
return {
code: "5074",
selection: {
groupCode: pagedGroup, subject: "INDEX",
graphicType: "", subtype: "INDUSTRY", dataCode: localDateText(now)
}
};
}
if (selectedCut.id === "square") {
return {
code: market === "kospi" ? "8001" : "8002",
selection: {
groupCode: industryGroup, subject: marketName,
graphicType: "네모그래프", subtype: "", dataCode: ""
}
};
}
return {
code: "5078",
selection: {
groupCode: industryGroup, subject: marketName,
graphicType: "SECTOR_INDEX", subtype: "", dataCode: ""
}
};
default:
throw new Error("The selected industry cut is unsupported.");
}
}
function isCutAllowed(marketValue, cutId, selectedValue, primaryValue, secondaryValue) {
const market = normalizeMarket(marketValue);
const selectedCut = cutsById.get(cutId);
const selected = normalizeIndustry(selectedValue);
const primary = normalizeIndustry(primaryValue);
const secondary = normalizeIndustry(secondaryValue);
if (!market || !selectedCut) return false;
if (selected && selected.market !== market) return false;
if (primary && primary.market !== market) return false;
if (secondary && secondary.market !== market) return false;
if (selectedCut.kind === "fixed") return true;
if (selectedCut.kind === "pair") {
return Boolean(primary && secondary && !primary.name.includes("-") && !secondary.name.includes("-"));
}
return Boolean(selected);
}
function createIndustryPlaylistEntry(marketValue, cutId, options) {
const market = normalizeMarket(marketValue);
const selectedCut = cutsById.get(cutId);
if (!market || !selectedCut) throw new RangeError("The industry action is unknown.");
const selected = normalizeIndustry(options?.selected);
const pair = Array.isArray(options?.pair) ? options.pair : [];
const primary = normalizeIndustry(pair[0]);
const secondary = normalizeIndustry(pair[1]);
if ((selected && selected.market !== market) || (primary && primary.market !== market) ||
(secondary && secondary.market !== market)) {
throw new RangeError("The selected industry market is inconsistent.");
}
if (!isCutAllowed(market, cutId, selected, primary, secondary)) {
throw new RangeError(selectedCut.kind === "pair" ? "Select two industries first." : "Select one industry first.");
}
const normalized = requireOptions(options);
const movingAverages = Object.freeze({
ma5: selectedCut.kind === "candle" && normalized.ma5,
ma20: selectedCut.kind === "candle" && normalized.ma20
});
const mapping = mappingFor(
market,
selectedCut,
selected,
primary,
secondary,
normalized.now,
movingAverages);
const selection = Object.freeze(mapping.selection);
return {
id: normalized.id,
builderKey: selectedCut.builderKey,
code: mapping.code,
aliases: [mapping.code],
title: selectedCut.kind === "fixed"
? `${market === "kospi" ? "코스피" : "코스닥"} ${selectedCut.label}`
: (selectedCut.kind === "pair" ? `${primary.name}-${secondary.name}` : selected.name),
detail: selectedCut.label,
category: selectedCut.kind === "candle" || selectedCut.kind === "yield" ? "chart" : "plate",
market,
selection,
enabled: true,
fadeDuration: normalized.fadeDuration,
graphicType: selection.graphicType,
subtype: selection.subtype,
operator: Object.freeze({
source: "legacy-industry-workflow",
cutId: selectedCut.id,
market,
...(selectedCut.kind === "pair" ? { pair: Object.freeze([primary, secondary]) } : {}),
...(selectedCut.kind !== "pair" && selectedCut.kind !== "fixed" ? { selected } : {}),
assetPath: runtimeAssets[market].relativePath,
assetSha256: runtimeAssets[market].sha256,
movingAverages
})
};
}
function sameSelection(left, right, allowDynamicDate) {
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
return ["groupCode", "subject", "graphicType", "subtype", "dataCode"].every(key => {
if (allowDynamicDate && key === "dataCode") return typeof left[key] === "string" && datePattern.test(left[key]);
return typeof left[key] === "string" && left[key] === right[key];
});
}
function restoreIndustryPlaylistEntry(value, options = {}) {
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-industry-workflow") return null;
const market = normalizeMarket(operator.market);
const selectedCut = cutsById.get(operator.cutId);
if (!market || !selectedCut || operator.assetPath !== runtimeAssets[market].relativePath ||
operator.assetSha256 !== runtimeAssets[market].sha256) return null;
let movingAverages = operator.movingAverages;
if (movingAverages === undefined) {
if (selectedCut.kind === "candle") {
const graphicType = value?.selection?.graphicType;
if (!["", "MA5", "MA20", "MA5,MA20"].includes(graphicType)) return null;
movingAverages = {
ma5: graphicType === "MA5" || graphicType === "MA5,MA20",
ma20: graphicType === "MA20" || graphicType === "MA5,MA20"
};
} else {
movingAverages = { ma5: false, ma20: false };
}
}
if (!movingAverages || typeof movingAverages !== "object" || Array.isArray(movingAverages) ||
typeof movingAverages.ma5 !== "boolean" || typeof movingAverages.ma20 !== "boolean" ||
Object.keys(movingAverages).length !== 2 ||
(selectedCut.kind !== "candle" && (movingAverages.ma5 || movingAverages.ma20))) return null;
let recreated;
try {
recreated = createIndustryPlaylistEntry(market, selectedCut.id, {
id: value.id,
fadeDuration: value.fadeDuration,
selected: operator.selected,
pair: operator.pair,
now: options.now,
ma5: movingAverages.ma5,
ma20: movingAverages.ma20
});
} catch {
return null;
}
const scalars = ["id", "builderKey", "code", "title", "detail", "category", "market", "fadeDuration"];
if (!scalars.every(key => value[key] === recreated[key]) ||
!Array.isArray(value.aliases) || value.aliases.length !== 1 || value.aliases[0] !== recreated.code ||
!sameSelection(value.selection, recreated.selection, selectedCut.id === "five-row")) return null;
return { ...recreated, enabled: value.enabled };
}
return {
markets,
runtimeAssets,
cuts,
normalizeIndustry,
isCutAllowed,
localDateText,
createIndustryPlaylistEntry,
restoreIndustryPlaylistEntry,
refreshIndustryPlaylistEntry: restoreIndustryPlaylistEntry
};
});