Complete legacy operator UI and playout migration
This commit is contained in:
361
Web/trading-halt-workflow.js
Normal file
361
Web/trading-halt-workflow.js
Normal file
@@ -0,0 +1,361 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnTradingHaltWorkflow = 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 stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
const markets = Object.freeze(["kospi", "kosdaq"]);
|
||||
|
||||
const bridgeContract = Object.freeze({
|
||||
requestType: "search-trading-halts",
|
||||
resultType: "trading-halt-results",
|
||||
errorType: "trading-halt-error",
|
||||
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
|
||||
maximumResults: MAXIMUM_RESULTS,
|
||||
maximumQueryLength: MAXIMUM_QUERY_LENGTH
|
||||
});
|
||||
|
||||
const sourceContract = Object.freeze({
|
||||
originalControl: "Control/UC7.cs",
|
||||
currentCutCode: "5001",
|
||||
candleCutCode: "8051",
|
||||
candlePeriodDays: 120,
|
||||
currentResolverGroups: Object.freeze({ kospi: "KOSPI", kosdaq: "KOSDAQ" }),
|
||||
candleResolverGroups: Object.freeze({ kospi: "KOSPI_STOCK", kosdaq: "KOSDAQ_STOCK" })
|
||||
});
|
||||
|
||||
function action(id, label, builderKey, code, kind) {
|
||||
return Object.freeze({
|
||||
id,
|
||||
label,
|
||||
builderKey,
|
||||
code,
|
||||
aliases: Object.freeze([code]),
|
||||
kind
|
||||
});
|
||||
}
|
||||
|
||||
const actions = Object.freeze([
|
||||
action("current", "\uAC70\uB798\uC815\uC9C0-\uD604\uC7AC\uAC00", "s5001", "5001", "current"),
|
||||
action("candle-120d", "\uCE94\uB4E4\uADF8\uB798\uD504-\uAC70\uB798\uC815\uC9C0 · 120\uC77C", "s8010", "8051", "candle")
|
||||
]);
|
||||
const actionsById = new Map(actions.map(value => [value.id, value]));
|
||||
|
||||
function hasExactKeys(value, keys) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function isSafeCanonicalText(value, maximumLength) {
|
||||
return typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
value.length <= maximumLength &&
|
||||
value === value.trim() &&
|
||||
!unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function normalizeQuery(value) {
|
||||
if (typeof value !== "string" || value.length > MAXIMUM_QUERY_LENGTH || unsafeTextPattern.test(value)) {
|
||||
return null;
|
||||
}
|
||||
const query = value.trim();
|
||||
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query) ? query : null;
|
||||
}
|
||||
|
||||
function normalizeTradingHalt(value) {
|
||||
if (!hasExactKeys(value, ["market", "stockCode", "displayName"]) ||
|
||||
!markets.includes(value.market) ||
|
||||
!isSafeCanonicalText(value.stockCode, 32) ||
|
||||
!stockCodePattern.test(value.stockCode) ||
|
||||
!isSafeCanonicalText(value.displayName, 200)) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
market: value.market,
|
||||
stockCode: value.stockCode,
|
||||
displayName: value.displayName
|
||||
});
|
||||
}
|
||||
|
||||
function createTradingHaltSearchRequest(requestId, query = "", maximumResults) {
|
||||
if (!isSafeCanonicalText(requestId, 128) || !identifierPattern.test(requestId)) {
|
||||
throw new TypeError("A safe trading-halt request id is required.");
|
||||
}
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
if (normalizedQuery === null) {
|
||||
throw new TypeError(`A trading-halt query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`);
|
||||
}
|
||||
if (maximumResults !== undefined &&
|
||||
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
|
||||
throw new RangeError(`Trading-halt results must be limited from 1 through ${MAXIMUM_RESULTS}.`);
|
||||
}
|
||||
const request = { requestId, query: normalizedQuery };
|
||||
if (maximumResults !== undefined) request.maximumResults = maximumResults;
|
||||
return Object.freeze(request);
|
||||
}
|
||||
|
||||
function compareTradingHalts(left, right) {
|
||||
const marketOrder = markets.indexOf(left.market) - markets.indexOf(right.market);
|
||||
if (marketOrder !== 0) return marketOrder;
|
||||
if (left.displayName < right.displayName) return -1;
|
||||
if (left.displayName > right.displayName) return 1;
|
||||
if (left.stockCode < right.stockCode) return -1;
|
||||
if (left.stockCode > right.stockCode) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeTradingHaltSearchResponse(value, expectedRequest) {
|
||||
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 !== undefined) {
|
||||
let expected;
|
||||
try {
|
||||
expected = createTradingHaltSearchRequest(
|
||||
expectedRequest?.requestId,
|
||||
expectedRequest?.query,
|
||||
expectedRequest?.maximumResults);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (value.requestId !== expected.requestId || value.query !== expected.query ||
|
||||
(expected.maximumResults !== undefined && value.results.length > expected.maximumResults)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const identities = new Set();
|
||||
const lookupKeys = new Set();
|
||||
for (const raw of value.results) {
|
||||
const item = normalizeTradingHalt(raw);
|
||||
if (!item) return null;
|
||||
const identity = `${item.market}\u0000${item.stockCode}`;
|
||||
const lookupKey = `${item.market}\u0000${item.displayName}`;
|
||||
if (identities.has(identity) || lookupKeys.has(lookupKey)) return null;
|
||||
if (results.length && compareTradingHalts(results[results.length - 1], item) > 0) return null;
|
||||
identities.add(identity);
|
||||
lookupKeys.add(lookupKey);
|
||||
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 normalizeTradingHaltError(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 getAction(actionId) {
|
||||
return typeof actionId === "string" ? actionsById.get(actionId) || null : null;
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new TypeError("Trading-halt playlist options are required.");
|
||||
}
|
||||
if (!isSafeCanonicalText(options.id, 128) || !identifierPattern.test(options.id)) {
|
||||
throw new TypeError("A safe trading-halt playlist id is required.");
|
||||
}
|
||||
const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : options.fadeDuration;
|
||||
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
||||
throw new RangeError("Fade duration must be an integer from 0 through 60.");
|
||||
}
|
||||
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 Object.freeze({ id: options.id, fadeDuration, ma5, ma20 });
|
||||
}
|
||||
|
||||
function buildTradingHaltSelection(actionId, tradingHalt, options = {}) {
|
||||
const selectedAction = getAction(actionId);
|
||||
const selected = normalizeTradingHalt(tradingHalt);
|
||||
if (!selectedAction) throw new RangeError("The trading-halt action is unknown.");
|
||||
if (!selected) throw new TypeError("A valid trading-halt selection is required.");
|
||||
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 movingAverages = Object.freeze({
|
||||
ma5: selectedAction.kind === "candle" && ma5,
|
||||
ma20: selectedAction.kind === "candle" && ma20
|
||||
});
|
||||
const flags = [];
|
||||
if (movingAverages.ma5) flags.push("MA5");
|
||||
if (movingAverages.ma20) flags.push("MA20");
|
||||
const selection = selectedAction.kind === "current"
|
||||
? Object.freeze({
|
||||
groupCode: sourceContract.currentResolverGroups[selected.market],
|
||||
subject: selected.displayName,
|
||||
graphicType: "\u0031\uC5F4\uD310\uAE30\uBCF8",
|
||||
subtype: "HALTED",
|
||||
dataCode: selected.stockCode
|
||||
})
|
||||
: Object.freeze({
|
||||
groupCode: sourceContract.candleResolverGroups[selected.market],
|
||||
subject: selected.displayName,
|
||||
graphicType: flags.join(","),
|
||||
subtype: "TRADING_HALT",
|
||||
dataCode: selected.stockCode
|
||||
});
|
||||
return Object.freeze({
|
||||
builderKey: selectedAction.builderKey,
|
||||
code: selectedAction.code,
|
||||
aliases: selectedAction.aliases,
|
||||
selection,
|
||||
movingAverages
|
||||
});
|
||||
}
|
||||
|
||||
function createTradingHaltPlaylistEntry(tradingHalt, actionId, options) {
|
||||
const selected = normalizeTradingHalt(tradingHalt);
|
||||
if (!selected) throw new TypeError("A valid trading-halt selection is required.");
|
||||
const selectedAction = getAction(actionId);
|
||||
if (!selectedAction) throw new RangeError("The trading-halt action is unknown.");
|
||||
const normalizedOptions = requireOptions(options);
|
||||
const mapping = buildTradingHaltSelection(actionId, selected, normalizedOptions);
|
||||
return {
|
||||
id: normalizedOptions.id,
|
||||
builderKey: mapping.builderKey,
|
||||
code: mapping.code,
|
||||
aliases: [...mapping.aliases],
|
||||
title: selected.displayName,
|
||||
detail: selectedAction.label,
|
||||
category: selectedAction.kind === "candle" ? "chart" : "stock",
|
||||
market: selected.market,
|
||||
stockCode: selected.stockCode,
|
||||
displayName: selected.displayName,
|
||||
selection: mapping.selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
graphicType: mapping.selection.graphicType,
|
||||
subtype: mapping.selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-trading-halt-workflow",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
actionId: selectedAction.id,
|
||||
tradingHalt: selected,
|
||||
movingAverages: mapping.movingAverages,
|
||||
resolverGroup: mapping.selection.groupCode
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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 restoreTradingHaltPlaylistEntry(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-trading-halt-workflow" ||
|
||||
operator.schemaVersion !== SCHEMA_VERSION ||
|
||||
!actionsById.has(operator.actionId) ||
|
||||
!operator.movingAverages || typeof operator.movingAverages !== "object" ||
|
||||
typeof operator.movingAverages.ma5 !== "boolean" ||
|
||||
typeof operator.movingAverages.ma20 !== "boolean") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let recreated;
|
||||
try {
|
||||
recreated = createTradingHaltPlaylistEntry(operator.tradingHalt, operator.actionId, {
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration,
|
||||
ma5: operator.movingAverages.ma5,
|
||||
ma20: operator.movingAverages.ma20
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scalars = [
|
||||
"id", "builderKey", "code", "title", "detail", "category", "market",
|
||||
"stockCode", "displayName", "fadeDuration", "graphicType", "subtype"
|
||||
];
|
||||
if (!scalars.every(key => value[key] === recreated[key]) ||
|
||||
!sameStringArray(value.aliases, recreated.aliases) ||
|
||||
!sameSelection(value.selection, recreated.selection) ||
|
||||
operator.movingAverages.ma5 !== recreated.operator.movingAverages.ma5 ||
|
||||
operator.movingAverages.ma20 !== recreated.operator.movingAverages.ma20 ||
|
||||
operator.resolverGroup !== recreated.operator.resolverGroup) {
|
||||
return null;
|
||||
}
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
bridgeContract,
|
||||
sourceContract,
|
||||
markets,
|
||||
actions,
|
||||
tradingHaltActions: actions,
|
||||
normalizeTradingHalt,
|
||||
normalizeTradingHaltResult: normalizeTradingHalt,
|
||||
createTradingHaltSearchRequest,
|
||||
normalizeTradingHaltSearchResponse,
|
||||
normalizeTradingHaltError,
|
||||
getAction,
|
||||
buildTradingHaltSelection,
|
||||
createTradingHaltPlaylistEntry,
|
||||
restoreTradingHaltPlaylistEntry,
|
||||
refreshTradingHaltPlaylistEntry: restoreTradingHaltPlaylistEntry
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user