351 lines
14 KiB
JavaScript
351 lines
14 KiB
JavaScript
(function (root, factory) {
|
|
"use strict";
|
|
|
|
const api = Object.freeze(factory());
|
|
if (typeof module === "object" && module.exports) module.exports = api;
|
|
if (root) root.MbnLegacyForeignIndexCandleWorkflow = api;
|
|
})(typeof globalThis === "object" ? globalThis : this, function () {
|
|
"use strict";
|
|
|
|
const SCHEMA_VERSION = 1;
|
|
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
|
const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
|
const pendingValues = new WeakSet();
|
|
const resolvedValues = new WeakSet();
|
|
const materializedEntries = new WeakSet();
|
|
const failureCodes = new Set([
|
|
"INVALID_ROW",
|
|
"IDENTITY_NOT_FOUND",
|
|
"IDENTITY_AMBIGUOUS",
|
|
"DATABASE_DATA_INVALID",
|
|
"RESTORE_FAILED"
|
|
]);
|
|
|
|
const targetProfiles = Object.freeze([
|
|
Object.freeze({
|
|
key: "Dow",
|
|
inputName: "다우존스",
|
|
symbol: "DJI@DJI",
|
|
nationCode: "US",
|
|
foreignDomesticCode: "0"
|
|
}),
|
|
Object.freeze({
|
|
key: "Nasdaq",
|
|
inputName: "나스닥",
|
|
symbol: "NAS@IXIC",
|
|
nationCode: "US",
|
|
foreignDomesticCode: "0"
|
|
}),
|
|
Object.freeze({
|
|
key: "Sp500",
|
|
inputName: "S&P500",
|
|
symbol: "SPI@SPX",
|
|
nationCode: "US",
|
|
foreignDomesticCode: "0"
|
|
})
|
|
]);
|
|
const targetsByInputName = new Map(targetProfiles.map(value => [value.inputName, value]));
|
|
const targetsByKey = new Map(targetProfiles.map(value => [value.key, value]));
|
|
const periodProfiles = Object.freeze([
|
|
Object.freeze({ legacy: "5일", days: 5, cutCode: "8061" }),
|
|
Object.freeze({ legacy: "20일", days: 20, cutCode: "8040" }),
|
|
Object.freeze({ legacy: "60일", days: 60, cutCode: "8046" }),
|
|
Object.freeze({ legacy: "120일", days: 120, cutCode: "8051" }),
|
|
Object.freeze({ legacy: "240일", days: 240, cutCode: "8056" })
|
|
]);
|
|
const periodsByLegacy = new Map(periodProfiles.map(value => [value.legacy, value]));
|
|
const periodsByDays = new Map(periodProfiles.map(value => [value.days, value]));
|
|
|
|
const bridgeContract = Object.freeze({
|
|
resultType: "named-foreign-index-candle-restore-results",
|
|
errorType: "named-foreign-index-candle-restore-error"
|
|
});
|
|
|
|
function isPlainObject(value) {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function hasExactKeys(value, keys) {
|
|
if (!isPlainObject(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 safeText(value, maximumLength, allowEmpty = false) {
|
|
return typeof value === "string" && value.length <= maximumLength &&
|
|
(allowEmpty || value.length > 0) && value === value.trim() &&
|
|
!value.includes("^") && !unsafeTextPattern.test(value);
|
|
}
|
|
|
|
function requireOptions(options) {
|
|
if (!hasExactKeys(options, ["id", "fadeDuration"]) ||
|
|
!requestIdPattern.test(options.id) ||
|
|
!Number.isInteger(options.fadeDuration) ||
|
|
options.fadeDuration < 0 || options.fadeDuration > 60) {
|
|
return null;
|
|
}
|
|
return Object.freeze({ id: options.id, fadeDuration: options.fadeDuration });
|
|
}
|
|
|
|
function rawSelection(raw) {
|
|
return Object.freeze({
|
|
groupCode: raw.groupCode,
|
|
subject: raw.subject,
|
|
graphicType: raw.graphicType,
|
|
subtype: raw.subtype,
|
|
dataCode: raw.dataCode
|
|
});
|
|
}
|
|
|
|
function classify(raw, options) {
|
|
const normalizedOptions = requireOptions(options);
|
|
if (!normalizedOptions || !isPlainObject(raw) ||
|
|
!Number.isInteger(raw.itemIndex) || raw.itemIndex < 0 || raw.itemIndex >= 1000 ||
|
|
typeof raw.enabled !== "boolean" || raw.groupCode !== "해외지수" ||
|
|
raw.graphicType !== "캔들그래프" || raw.pageText !== "1/1" || raw.dataCode !== "" ||
|
|
!safeText(raw.subject, 200) || !safeText(raw.subtype, 256) ||
|
|
!safeText(raw.pageText, 9) || !safeText(raw.dataCode, 1024, true)) return null;
|
|
const target = targetsByInputName.get(raw.subject);
|
|
const period = periodsByLegacy.get(raw.subtype);
|
|
if (!target || !period) return null;
|
|
|
|
const pending = Object.freeze({
|
|
itemIndex: raw.itemIndex,
|
|
enabled: raw.enabled,
|
|
targetKey: target.key,
|
|
inputName: target.inputName,
|
|
symbol: target.symbol,
|
|
nationCode: target.nationCode,
|
|
foreignDomesticCode: target.foreignDomesticCode,
|
|
periodDays: period.days,
|
|
cutCode: period.cutCode,
|
|
entry: Object.freeze({ id: normalizedOptions.id }),
|
|
fadeDuration: normalizedOptions.fadeDuration,
|
|
rawSelection: rawSelection(raw)
|
|
});
|
|
pendingValues.add(pending);
|
|
return pending;
|
|
}
|
|
|
|
function normalizeOutcome(value, pending, verifiedAt) {
|
|
if (!pendingValues.has(pending) || !isPlainObject(value) ||
|
|
value.itemIndex !== pending.itemIndex) return null;
|
|
if (value.status === "failed") {
|
|
if (!hasExactKeys(value, ["itemIndex", "status", "failure"]) ||
|
|
!failureCodes.has(value.failure)) return null;
|
|
return Object.freeze({
|
|
itemIndex: value.itemIndex,
|
|
status: "failed",
|
|
failure: value.failure
|
|
});
|
|
}
|
|
if (value.status !== "resolved" || !hasExactKeys(value, [
|
|
"itemIndex", "status", "targetKey", "inputName", "symbol", "nationCode",
|
|
"foreignDomesticCode", "sceneCode", "valueType", "periodDays", "cutCode"
|
|
])) return null;
|
|
if (value.targetKey !== pending.targetKey || value.inputName !== pending.inputName ||
|
|
value.symbol !== pending.symbol || value.nationCode !== pending.nationCode ||
|
|
value.foreignDomesticCode !== pending.foreignDomesticCode ||
|
|
value.sceneCode !== "s8010" || value.valueType !== "PRICE" ||
|
|
value.periodDays !== pending.periodDays || value.cutCode !== pending.cutCode) return null;
|
|
const resolved = Object.freeze({
|
|
itemIndex: value.itemIndex,
|
|
status: "resolved",
|
|
targetKey: value.targetKey,
|
|
inputName: value.inputName,
|
|
symbol: value.symbol,
|
|
nationCode: value.nationCode,
|
|
foreignDomesticCode: value.foreignDomesticCode,
|
|
sceneCode: value.sceneCode,
|
|
valueType: value.valueType,
|
|
periodDays: value.periodDays,
|
|
cutCode: value.cutCode,
|
|
verifiedAt
|
|
});
|
|
resolvedValues.add(resolved);
|
|
return resolved;
|
|
}
|
|
|
|
function normalizeResponse(value, expectedRequestId, pendingRows) {
|
|
if (!requestIdPattern.test(expectedRequestId) || !Array.isArray(pendingRows) ||
|
|
pendingRows.length < 1 || pendingRows.length > 1000 ||
|
|
pendingRows.some(pending => !pendingValues.has(pending)) ||
|
|
!hasExactKeys(value, ["requestId", "retrievedAt", "totalRowCount", "rows"]) ||
|
|
value.requestId !== expectedRequestId ||
|
|
typeof value.retrievedAt !== "string" || !isoTimestampPattern.test(value.retrievedAt) ||
|
|
!Number.isFinite(Date.parse(value.retrievedAt)) ||
|
|
value.totalRowCount !== pendingRows.length || !Array.isArray(value.rows) ||
|
|
value.rows.length !== pendingRows.length) return null;
|
|
const rows = [];
|
|
for (let index = 0; index < pendingRows.length; index += 1) {
|
|
const outcome = normalizeOutcome(value.rows[index], pendingRows[index], value.retrievedAt);
|
|
if (!outcome) return null;
|
|
rows.push(outcome);
|
|
}
|
|
return Object.freeze({
|
|
requestId: value.requestId,
|
|
retrievedAt: value.retrievedAt,
|
|
rows: Object.freeze(rows)
|
|
});
|
|
}
|
|
|
|
function normalizeError(value, expectedRequestId) {
|
|
if (!requestIdPattern.test(expectedRequestId) ||
|
|
!hasExactKeys(value, ["requestId", "code", "message", "retryable"]) ||
|
|
value.requestId !== expectedRequestId ||
|
|
!["PLAYOUT_PRIORITY", "DATABASE_UNAVAILABLE", "RESTORE_FAILED"].includes(value.code) ||
|
|
!safeText(value.message, 1024) || value.retryable !== false) return null;
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function buildEntry(pending, outcome) {
|
|
const code = outcome.cutCode;
|
|
const selection = Object.freeze({
|
|
groupCode: "FOREIGN_INDEX",
|
|
subject: outcome.inputName,
|
|
graphicType: "",
|
|
subtype: "PRICE",
|
|
dataCode: `${outcome.symbol}|${outcome.nationCode}|${outcome.foreignDomesticCode}`
|
|
});
|
|
return Object.freeze({
|
|
id: pending.entry.id,
|
|
builderKey: "s8010",
|
|
code,
|
|
aliases: Object.freeze([code]),
|
|
title: `${outcome.inputName} 캔들 ${outcome.periodDays}일`,
|
|
detail: `해외지수 · ${outcome.inputName} · ${outcome.periodDays}일`,
|
|
category: "chart",
|
|
market: "overseas",
|
|
selection,
|
|
enabled: pending.enabled,
|
|
fadeDuration: pending.fadeDuration,
|
|
graphicType: selection.graphicType,
|
|
subtype: selection.subtype,
|
|
operator: Object.freeze({
|
|
source: "legacy-foreign-index-candle-workflow",
|
|
schemaVersion: SCHEMA_VERSION,
|
|
targetKey: outcome.targetKey,
|
|
inputName: outcome.inputName,
|
|
symbol: outcome.symbol,
|
|
nationCode: outcome.nationCode,
|
|
foreignDomesticCode: outcome.foreignDomesticCode,
|
|
periodDays: outcome.periodDays,
|
|
cutCode: outcome.cutCode,
|
|
verifiedAt: outcome.verifiedAt
|
|
})
|
|
});
|
|
}
|
|
|
|
function materialize(pending, outcome) {
|
|
if (!pendingValues.has(pending) || !resolvedValues.has(outcome) ||
|
|
outcome.itemIndex !== pending.itemIndex || outcome.targetKey !== pending.targetKey ||
|
|
outcome.periodDays !== pending.periodDays || outcome.cutCode !== pending.cutCode) return null;
|
|
const entry = buildEntry(pending, outcome);
|
|
materializedEntries.add(entry);
|
|
return entry;
|
|
}
|
|
|
|
function restorePlaylistEntry(value) {
|
|
if (!isPlainObject(value) || !hasExactKeys(value, [
|
|
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
|
|
"selection", "enabled", "fadeDuration", "graphicType", "subtype", "operator"
|
|
]) || !requestIdPattern.test(value.id) || value.builderKey !== "s8010" ||
|
|
!Array.isArray(value.aliases) || value.aliases.length !== 1 || value.aliases[0] !== value.code ||
|
|
typeof value.enabled !== "boolean" || !Number.isInteger(value.fadeDuration) ||
|
|
value.fadeDuration < 0 || value.fadeDuration > 60 || !isPlainObject(value.operator) ||
|
|
!hasExactKeys(value.operator, [
|
|
"source", "schemaVersion", "targetKey", "inputName", "symbol", "nationCode",
|
|
"foreignDomesticCode", "periodDays", "cutCode", "verifiedAt"
|
|
]) || value.operator.source !== "legacy-foreign-index-candle-workflow" ||
|
|
value.operator.schemaVersion !== SCHEMA_VERSION ||
|
|
typeof value.operator.verifiedAt !== "string" ||
|
|
!isoTimestampPattern.test(value.operator.verifiedAt) ||
|
|
!Number.isFinite(Date.parse(value.operator.verifiedAt))) return null;
|
|
const target = targetsByKey.get(value.operator.targetKey);
|
|
const period = periodsByDays.get(value.operator.periodDays);
|
|
if (!target || !period || value.operator.inputName !== target.inputName ||
|
|
value.operator.symbol !== target.symbol || value.operator.nationCode !== target.nationCode ||
|
|
value.operator.foreignDomesticCode !== target.foreignDomesticCode ||
|
|
value.operator.cutCode !== period.cutCode || value.code !== period.cutCode) return null;
|
|
const pending = {
|
|
entry: { id: value.id },
|
|
enabled: value.enabled,
|
|
fadeDuration: value.fadeDuration
|
|
};
|
|
const outcome = {
|
|
cutCode: period.cutCode,
|
|
inputName: target.inputName,
|
|
symbol: target.symbol,
|
|
nationCode: target.nationCode,
|
|
foreignDomesticCode: target.foreignDomesticCode,
|
|
periodDays: period.days,
|
|
targetKey: target.key,
|
|
verifiedAt: value.operator.verifiedAt
|
|
};
|
|
const rebuilt = buildEntry(pending, outcome);
|
|
const scalarKeys = [
|
|
"id", "builderKey", "code", "title", "detail", "category", "market",
|
|
"enabled", "fadeDuration", "graphicType", "subtype"
|
|
];
|
|
if (!scalarKeys.every(key => value[key] === rebuilt[key]) ||
|
|
!hasExactKeys(value.selection, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) ||
|
|
!Object.keys(rebuilt.selection).every(key => value.selection[key] === rebuilt.selection[key])) return null;
|
|
return rebuilt;
|
|
}
|
|
|
|
function legacySelectionForEntry(entry) {
|
|
const restored = restorePlaylistEntry(entry);
|
|
if (!restored) return null;
|
|
const period = periodsByDays.get(restored.operator.periodDays);
|
|
return period ? Object.freeze({
|
|
groupCode: "해외지수",
|
|
subject: restored.operator.inputName,
|
|
graphicType: "캔들그래프",
|
|
subtype: period.legacy,
|
|
dataCode: ""
|
|
}) : null;
|
|
}
|
|
|
|
function withEnabled(entry, enabled) {
|
|
if (!materializedEntries.has(entry) || typeof enabled !== "boolean") return null;
|
|
if (entry.enabled === enabled) return entry;
|
|
const replacement = Object.freeze({ ...entry, enabled });
|
|
materializedEntries.add(replacement);
|
|
return replacement;
|
|
}
|
|
|
|
function matchesRaw(entry, raw) {
|
|
const signature = legacySelectionForEntry(entry);
|
|
return Boolean(signature && raw && typeof raw.enabled === "boolean" &&
|
|
entry.enabled === raw.enabled && raw.pageText === "1/1" &&
|
|
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
|
|
.every(key => signature[key] === raw[key]));
|
|
}
|
|
|
|
function isPending(value) {
|
|
return pendingValues.has(value);
|
|
}
|
|
|
|
function isMaterializedEntry(value) {
|
|
return materializedEntries.has(value);
|
|
}
|
|
|
|
return {
|
|
bridgeContract,
|
|
targetProfiles,
|
|
periodProfiles,
|
|
classify,
|
|
normalizeResponse,
|
|
normalizeError,
|
|
materialize,
|
|
restorePlaylistEntry,
|
|
withEnabled,
|
|
legacySelectionForEntry,
|
|
matchesRaw,
|
|
isPending,
|
|
isMaterializedEntry
|
|
};
|
|
});
|