407 lines
16 KiB
JavaScript
407 lines
16 KiB
JavaScript
(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 tradingHaltIdentity(value) {
|
|
const normalized = normalizeTradingHalt(value);
|
|
return normalized ? `${normalized.market}\u0000${normalized.stockCode}` : "";
|
|
}
|
|
|
|
function nextTradingHaltNameSort(direction) {
|
|
return direction === "descending" ? "ascending" : "descending";
|
|
}
|
|
|
|
function compareCanonicalText(left, right) {
|
|
if (left < right) return -1;
|
|
if (left > right) return 1;
|
|
return 0;
|
|
}
|
|
|
|
function sortTradingHaltsForDisplay(values, direction) {
|
|
if (!Array.isArray(values)) throw new TypeError("Trading-halt display rows are required.");
|
|
if (direction !== null && direction !== "ascending" && direction !== "descending") {
|
|
throw new TypeError("Trading-halt name sort must be ascending, descending, or null.");
|
|
}
|
|
if (direction === null) return [...values];
|
|
|
|
const nameDirection = direction === "ascending" ? 1 : -1;
|
|
return values.map((value, sourceIndex) => {
|
|
const normalized = normalizeTradingHalt(value);
|
|
if (!normalized) throw new TypeError("A valid trading-halt display row is required.");
|
|
return { value, normalized, sourceIndex };
|
|
}).sort((left, right) => {
|
|
const nameOrder = compareCanonicalText(
|
|
left.normalized.displayName,
|
|
right.normalized.displayName);
|
|
if (nameOrder !== 0) return nameOrder * nameDirection;
|
|
|
|
const marketOrder = compareCanonicalText(left.normalized.market, right.normalized.market);
|
|
if (marketOrder !== 0) return marketOrder;
|
|
const stockCodeOrder = compareCanonicalText(
|
|
left.normalized.stockCode,
|
|
right.normalized.stockCode);
|
|
return stockCodeOrder !== 0 ? stockCodeOrder : left.sourceIndex - right.sourceIndex;
|
|
}).map(item => item.value);
|
|
}
|
|
|
|
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,
|
|
tradingHaltIdentity,
|
|
nextTradingHaltNameSort,
|
|
sortTradingHaltsForDisplay,
|
|
createTradingHaltSearchRequest,
|
|
normalizeTradingHaltSearchResponse,
|
|
normalizeTradingHaltError,
|
|
getAction,
|
|
buildTradingHaltSelection,
|
|
createTradingHaltPlaylistEntry,
|
|
restoreTradingHaltPlaylistEntry,
|
|
refreshTradingHaltPlaylistEntry: restoreTradingHaltPlaylistEntry
|
|
};
|
|
});
|