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

521
Web/overseas-workflow.js Normal file
View File

@@ -0,0 +1,521 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnOverseasWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const DEFAULT_FADE_DURATION = 6;
const DEFAULT_MAXIMUM_RESULTS = 100;
const MAXIMUM_RESULTS = 500;
const MAXIMUM_QUERY_LENGTH = 64;
const SCHEMA_VERSION = 1;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const symbolPattern = /^[A-Za-z0-9@._$:-]{1,64}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const bridgeContracts = Object.freeze({
industry: Object.freeze({
requestType: "search-overseas-industries",
resultType: "overseas-industry-results",
errorType: "overseas-industry-error",
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
maximumResults: MAXIMUM_RESULTS,
maximumQueryLength: MAXIMUM_QUERY_LENGTH
}),
stock: Object.freeze({
requestType: "search-world-stocks",
resultType: "world-stock-search-results",
errorType: "world-stock-search-error",
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
maximumResults: MAXIMUM_RESULTS,
maximumQueryLength: MAXIMUM_QUERY_LENGTH
})
});
const sourceContract = Object.freeze({
originalControl: "Control/UC5.cs",
industry: Object.freeze({
fdtc: "0",
nationCodes: Object.freeze(["US"]),
lookupField: "F_KNAM",
currentCutCode: "5001"
}),
stock: Object.freeze({
fdtc: "1",
nationCodes: Object.freeze(["US", "TW"]),
lookupField: "F_INPUT_NAME",
currentCutCode: "5001",
candleCutCodes: Object.freeze(["8061", "8040", "8046", "8051", "8056"])
})
});
function action(id, label, builderKey, code, kind, periodDays = null) {
return Object.freeze({
id,
label,
builderKey,
code,
aliases: Object.freeze([code]),
kind,
periodDays
});
}
const industryActions = Object.freeze([
action("industry-current", "\uBBF8\uAD6D \uD574\uC678\uC5C5\uC885 \uD604\uC7AC\uAC00 1\uC5F4\uD310", "s5001", "5001", "current")
]);
const stockActions = Object.freeze([
action("stock-current", "\uD574\uC678\uC885\uBAA9 \uD604\uC7AC\uAC00 1\uC5F4\uD310", "s5001", "5001", "current"),
action("stock-candle-5d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 5\uC77C", "s8010", "8061", "candle", 5),
action("stock-candle-20d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 20\uC77C", "s8010", "8040", "candle", 20),
action("stock-candle-60d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 60\uC77C", "s8010", "8046", "candle", 60),
action("stock-candle-120d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 120\uC77C", "s8010", "8051", "candle", 120),
action("stock-candle-240d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 240\uC77C", "s8010", "8056", "candle", 240)
]);
const actionsById = new Map(
[...industryActions, ...stockActions].map(value => [value.id, value]));
function hasExactKeys(value, expected) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const actual = Object.keys(value).sort();
const required = [...expected].sort();
return actual.length === required.length && actual.every((key, index) => key === required[index]);
}
function isSafeCanonicalText(value, maximumLength, allowEmpty = false) {
return typeof value === "string" &&
value.length <= maximumLength &&
(allowEmpty || value.length > 0) &&
value === value.trim() &&
!unsafeTextPattern.test(value);
}
function normalizeQuery(value) {
if (typeof value !== "string" || unsafeTextPattern.test(value)) return null;
const query = value.trim();
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query) ? query : null;
}
function normalizeSymbol(value) {
return isSafeCanonicalText(value, 64) && symbolPattern.test(value) ? value : null;
}
function normalizeIndustry(value) {
if (!hasExactKeys(value, [
"koreanName", "inputName", "symbol", "nationCode", "fdtc"
])) return null;
if (!isSafeCanonicalText(value.koreanName, 200) || value.koreanName.includes("|") ||
!isSafeCanonicalText(value.inputName, 200) || value.inputName.includes("|") ||
!normalizeSymbol(value.symbol) || value.nationCode !== "US" || value.fdtc !== "0") {
return null;
}
return Object.freeze({
koreanName: value.koreanName,
inputName: value.inputName,
symbol: value.symbol,
nationCode: "US",
fdtc: "0"
});
}
function normalizeStock(value) {
if (!hasExactKeys(value, ["inputName", "symbol", "nationCode", "fdtc"])) return null;
if (!isSafeCanonicalText(value.inputName, 200) || value.inputName.includes("|") ||
!normalizeSymbol(value.symbol) || !["US", "TW"].includes(value.nationCode) ||
value.fdtc !== "1") {
return null;
}
return Object.freeze({
inputName: value.inputName,
symbol: value.symbol,
nationCode: value.nationCode,
fdtc: "1"
});
}
function createSearchRequest(requestId, query, maximumResults) {
if (!isSafeCanonicalText(requestId, 128) || !identifierPattern.test(requestId)) {
throw new TypeError("A safe overseas search request id is required.");
}
const normalizedQuery = normalizeQuery(query ?? "");
if (normalizedQuery === null) {
throw new TypeError(`An overseas search query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`);
}
if (maximumResults !== undefined &&
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
throw new RangeError(`Overseas results must be limited from 1 through ${MAXIMUM_RESULTS}.`);
}
const result = { requestId, query: normalizedQuery };
if (maximumResults !== undefined) result.maximumResults = maximumResults;
return Object.freeze(result);
}
function createIndustrySearchRequest(requestId, query = "", maximumResults) {
return createSearchRequest(requestId, query, maximumResults);
}
function createStockSearchRequest(requestId, query = "", maximumResults) {
return createSearchRequest(requestId, query, maximumResults);
}
function compareUpper(left, right) {
const upperLeft = left.toLocaleUpperCase("en-US");
const upperRight = right.toLocaleUpperCase("en-US");
if (upperLeft < upperRight) return -1;
if (upperLeft > upperRight) return 1;
return 0;
}
function compareOrdinal(left, right) {
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
function compareIndustries(left, right) {
return compareUpper(left.koreanName, right.koreanName) ||
compareOrdinal(left.inputName, right.inputName) ||
compareOrdinal(left.symbol, right.symbol);
}
function compareStocks(left, right) {
return compareUpper(left.inputName, right.inputName) ||
compareOrdinal(left.symbol, right.symbol) ||
compareOrdinal(left.nationCode, right.nationCode);
}
function normalizeSearchResponse(value, expectedRequest, normalizeItem, compare, duplicateKeys) {
if (!hasExactKeys(value, [
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
]) || !isSafeCanonicalText(value.requestId, 128) || !identifierPattern.test(value.requestId) ||
normalizeQuery(value.query) !== value.query || !isSafeCanonicalText(value.retrievedAt, 64) ||
!Number.isFinite(Date.parse(value.retrievedAt)) || !Number.isInteger(value.totalRowCount) ||
value.totalRowCount < 0 || value.totalRowCount > MAXIMUM_RESULTS ||
typeof value.truncated !== "boolean" || !Array.isArray(value.results) ||
value.results.length !== value.totalRowCount) {
return null;
}
if (expectedRequest) {
let expected;
try {
expected = createSearchRequest(
expectedRequest.requestId,
expectedRequest.query,
expectedRequest.maximumResults);
} catch {
return null;
}
const expectedLimit = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS;
if (value.requestId !== expected.requestId || value.query !== expected.query ||
value.results.length > expectedLimit) {
return null;
}
}
const results = [];
const keySets = duplicateKeys.map(() => new Set());
for (const raw of value.results) {
const item = normalizeItem(raw);
if (!item) return null;
for (let index = 0; index < duplicateKeys.length; index += 1) {
const key = duplicateKeys[index](item);
if (keySets[index].has(key)) return null;
keySets[index].add(key);
}
if (results.length && compare(results[results.length - 1], item) > 0) return null;
results.push(item);
}
return Object.freeze({
requestId: value.requestId,
query: value.query,
retrievedAt: value.retrievedAt,
totalRowCount: value.totalRowCount,
truncated: value.truncated,
results: Object.freeze(results)
});
}
function normalizeIndustrySearchResponse(value, expectedRequest) {
return normalizeSearchResponse(
value,
expectedRequest,
normalizeIndustry,
compareIndustries,
[item => item.koreanName, item => item.inputName, item => item.symbol]);
}
function normalizeStockSearchResponse(value, expectedRequest) {
return normalizeSearchResponse(
value,
expectedRequest,
normalizeStock,
compareStocks,
[item => item.inputName, item => `${item.inputName}\u0000${item.symbol}\u0000${item.nationCode}`]);
}
function normalizeSearchError(value, expectedRequest) {
if (!hasExactKeys(value, ["requestId", "query", "message"]) ||
!isSafeCanonicalText(value.requestId, 128) || !identifierPattern.test(value.requestId) ||
normalizeQuery(value.query) !== value.query || !isSafeCanonicalText(value.message, 512)) {
return null;
}
if (expectedRequest &&
(value.requestId !== expectedRequest.requestId ||
value.query !== normalizeQuery(expectedRequest.query))) return null;
return Object.freeze({ requestId: value.requestId, query: value.query, message: value.message });
}
function normalizeIndustrySearchError(value, expectedRequest) {
return normalizeSearchError(value, expectedRequest);
}
function normalizeStockSearchError(value, expectedRequest) {
return normalizeSearchError(value, expectedRequest);
}
function requireOptions(options) {
if (!options || typeof options !== "object" || Array.isArray(options) ||
!isSafeCanonicalText(options.id, 128) || !identifierPattern.test(options.id)) {
throw new TypeError("Safe overseas playlist options are required.");
}
const fadeDuration = options.fadeDuration === undefined
? DEFAULT_FADE_DURATION
: options.fadeDuration;
const ma5 = options.ma5 === undefined ? false : options.ma5;
const ma20 = options.ma20 === undefined ? false : options.ma20;
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
throw new RangeError("Fade duration must be an integer from 0 through 60.");
}
if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
throw new TypeError("Moving-average flags must be boolean values.");
}
return Object.freeze({ id: options.id, fadeDuration, ma5, ma20 });
}
function encodeIndustryIdentity(value) {
return `${value.inputName}|${value.symbol}|${value.nationCode}|${value.fdtc}`;
}
function encodeStockIdentity(value) {
return `${value.symbol}|${value.nationCode}|${value.fdtc}`;
}
function movingAverageFlags(actionValue, options) {
const enabled = actionValue.kind === "candle";
const movingAverages = Object.freeze({
ma5: enabled && options.ma5,
ma20: enabled && options.ma20
});
const flags = [];
if (movingAverages.ma5) flags.push("MA5");
if (movingAverages.ma20) flags.push("MA20");
return Object.freeze({ movingAverages, graphicType: flags.join(",") });
}
function buildOverseasIndustrySelection(industryValue) {
const industry = normalizeIndustry(industryValue);
if (!industry) throw new TypeError("A valid US overseas-industry identity is required.");
return Object.freeze({
builderKey: "s5001",
code: "5001",
aliases: Object.freeze(["5001"]),
selection: Object.freeze({
groupCode: "FOREIGN_INDUSTRY",
subject: industry.koreanName,
graphicType: "\u0031\uC5F4\uD310\uAE30\uBCF8",
subtype: "CURRENT",
dataCode: encodeIndustryIdentity(industry)
}),
movingAverages: Object.freeze({ ma5: false, ma20: false })
});
}
function buildOverseasStockSelection(stockValue, actionId, options = {}) {
const stock = normalizeStock(stockValue);
const selectedAction = actionsById.get(actionId);
if (!stock) throw new TypeError("A valid US/TW overseas-stock identity is required.");
if (!selectedAction || !stockActions.includes(selectedAction)) {
throw new RangeError("The overseas-stock action is unknown.");
}
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.");
}
const flags = movingAverageFlags(selectedAction, { ma5, ma20 });
return Object.freeze({
builderKey: selectedAction.builderKey,
code: selectedAction.code,
aliases: selectedAction.aliases,
selection: Object.freeze({
groupCode: "FOREIGN_STOCK",
subject: stock.inputName,
graphicType: selectedAction.kind === "current"
? "\u0031\uC5F4\uD310\uAE30\uBCF8"
: flags.graphicType,
subtype: selectedAction.kind === "current" ? "CURRENT" : "PRICE",
dataCode: encodeStockIdentity(stock)
}),
movingAverages: flags.movingAverages
});
}
function createOverseasIndustryPlaylistEntry(industryValue, options) {
const industry = normalizeIndustry(industryValue);
if (!industry) throw new TypeError("A valid US overseas-industry identity is required.");
const normalizedOptions = requireOptions(options);
const mapping = buildOverseasIndustrySelection(industry);
const actionValue = industryActions[0];
return {
id: normalizedOptions.id,
builderKey: mapping.builderKey,
code: mapping.code,
aliases: [...mapping.aliases],
title: industry.koreanName,
detail: actionValue.label,
category: "plate",
market: "overseas",
symbol: industry.symbol,
nationCode: industry.nationCode,
fdtc: industry.fdtc,
selection: mapping.selection,
enabled: true,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: mapping.selection.graphicType,
subtype: mapping.selection.subtype,
operator: Object.freeze({
source: "legacy-overseas-workflow",
schemaVersion: SCHEMA_VERSION,
kind: "industry",
actionId: actionValue.id,
identity: industry,
movingAverages: mapping.movingAverages
})
};
}
function createOverseasStockPlaylistEntry(stockValue, actionId, options) {
const stock = normalizeStock(stockValue);
if (!stock) throw new TypeError("A valid US/TW overseas-stock identity is required.");
const selectedAction = actionsById.get(actionId);
if (!selectedAction || !stockActions.includes(selectedAction)) {
throw new RangeError("The overseas-stock action is unknown.");
}
const normalizedOptions = requireOptions(options);
const mapping = buildOverseasStockSelection(stock, actionId, normalizedOptions);
return {
id: normalizedOptions.id,
builderKey: mapping.builderKey,
code: mapping.code,
aliases: [...mapping.aliases],
title: stock.inputName,
detail: selectedAction.label,
category: selectedAction.kind === "candle" ? "chart" : "stock",
market: "overseas",
symbol: stock.symbol,
nationCode: stock.nationCode,
fdtc: stock.fdtc,
selection: mapping.selection,
enabled: true,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: mapping.selection.graphicType,
subtype: mapping.selection.subtype,
operator: Object.freeze({
source: "legacy-overseas-workflow",
schemaVersion: SCHEMA_VERSION,
kind: "stock",
actionId,
identity: stock,
movingAverages: mapping.movingAverages
})
};
}
function sameSelection(left, right) {
return hasExactKeys(left, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) &&
hasExactKeys(right, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) &&
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
.every(key => left[key] === right[key]);
}
function sameAliases(value, recreated) {
return Array.isArray(value.aliases) && value.aliases.length === 1 &&
value.aliases[0] === recreated.code;
}
function restoreOverseasPlaylistEntry(value) {
if (!value || typeof value !== "object" || Array.isArray(value) ||
typeof value.enabled !== "boolean") return null;
const operator = value.operator;
if (!hasExactKeys(operator, [
"source", "schemaVersion", "kind", "actionId", "identity", "movingAverages"
]) || operator.source !== "legacy-overseas-workflow" ||
operator.schemaVersion !== SCHEMA_VERSION ||
!hasExactKeys(operator.movingAverages, ["ma5", "ma20"]) ||
typeof operator.movingAverages.ma5 !== "boolean" ||
typeof operator.movingAverages.ma20 !== "boolean") return null;
let recreated;
try {
recreated = operator.kind === "industry"
? createOverseasIndustryPlaylistEntry(operator.identity, {
id: value.id,
fadeDuration: value.fadeDuration,
ma5: operator.movingAverages.ma5,
ma20: operator.movingAverages.ma20
})
: operator.kind === "stock"
? createOverseasStockPlaylistEntry(operator.identity, operator.actionId, {
id: value.id,
fadeDuration: value.fadeDuration,
ma5: operator.movingAverages.ma5,
ma20: operator.movingAverages.ma20
})
: null;
} catch {
return null;
}
if (!recreated || operator.actionId !== recreated.operator.actionId) return null;
const scalars = [
"id", "builderKey", "code", "title", "detail", "category", "market",
"symbol", "nationCode", "fdtc", "fadeDuration", "graphicType", "subtype"
];
if (!scalars.every(key => value[key] === recreated[key]) ||
!sameAliases(value, recreated) || !sameSelection(value.selection, recreated.selection) ||
JSON.stringify(operator) !== JSON.stringify(recreated.operator)) return null;
return { ...recreated, enabled: value.enabled };
}
function isActionAllowed(kind, actionId, identity) {
if (kind === "industry") {
return actionId === "industry-current" && Boolean(normalizeIndustry(identity));
}
return kind === "stock" && Boolean(normalizeStock(identity)) &&
stockActions.some(value => value.id === actionId);
}
return {
bridgeContracts,
sourceContract,
industryActions,
stockActions,
normalizeIndustry,
normalizeStock,
createIndustrySearchRequest,
createStockSearchRequest,
normalizeIndustrySearchResponse,
normalizeStockSearchResponse,
normalizeIndustrySearchError,
normalizeStockSearchError,
isActionAllowed,
buildOverseasIndustrySelection,
buildOverseasStockSelection,
createOverseasIndustryPlaylistEntry,
createOverseasStockPlaylistEntry,
restoreOverseasPlaylistEntry,
refreshOverseasPlaylistEntry: restoreOverseasPlaylistEntry
};
});