Files
MBN_STOCK_WEBVIEW/Web/trading-halt-workflow.js

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
};
});