Files
MBN_STOCK_WEBVIEW/Web/named-foreign-stock-restore-workflow.js

403 lines
15 KiB
JavaScript

(function (root, factory) {
"use strict";
const overseas = typeof module === "object" && module.exports
? require("./overseas-workflow.js")
: root.MbnOverseasWorkflow;
const api = Object.freeze(factory(overseas));
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnNamedForeignStockRestoreWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function (overseas) {
"use strict";
if (!overseas) throw new Error("Overseas workflow is unavailable.");
const SCHEMA_VERSION = 1;
const SOURCE = "legacy-named-foreign-stock-workflow";
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 actionProfiles = Object.freeze([
Object.freeze({
actionId: "stock-current",
graphicType: "1열판기본",
subtype: "",
builderKey: "s5001",
cutCode: "5001",
valueType: "CURRENT",
periodDays: null
}),
Object.freeze({
actionId: "stock-candle-5d",
graphicType: "캔들그래프",
subtype: "5일",
builderKey: "s8010",
cutCode: "8061",
valueType: "PRICE",
periodDays: 5
}),
Object.freeze({
actionId: "stock-candle-20d",
graphicType: "캔들그래프",
subtype: "20일",
builderKey: "s8010",
cutCode: "8040",
valueType: "PRICE",
periodDays: 20
}),
Object.freeze({
actionId: "stock-candle-60d",
graphicType: "캔들그래프",
subtype: "60일",
builderKey: "s8010",
cutCode: "8046",
valueType: "PRICE",
periodDays: 60
}),
Object.freeze({
actionId: "stock-candle-120d",
graphicType: "캔들그래프",
subtype: "120일",
builderKey: "s8010",
cutCode: "8051",
valueType: "PRICE",
periodDays: 120
}),
Object.freeze({
actionId: "stock-candle-240d",
graphicType: "캔들그래프",
subtype: "240일",
builderKey: "s8010",
cutCode: "8056",
valueType: "PRICE",
periodDays: 240
})
]);
const profilesByAction = new Map(actionProfiles.map(value => [value.actionId, value]));
const profilesByRaw = new Map(actionProfiles.map(value => [
`${value.graphicType}\u0000${value.subtype}`,
value
]));
const bridgeContract = Object.freeze({
resultType: "named-foreign-stock-restore-results",
errorType: "named-foreign-stock-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", "ma5", "ma20"]) ||
!requestIdPattern.test(options.id) ||
!Number.isInteger(options.fadeDuration) ||
options.fadeDuration < 0 || options.fadeDuration > 60 ||
typeof options.ma5 !== "boolean" || typeof options.ma20 !== "boolean") return null;
return Object.freeze({ ...options });
}
function rawSelection(raw) {
return Object.freeze({
groupCode: raw.groupCode,
subject: raw.subject,
graphicType: raw.graphicType,
subtype: raw.subtype,
dataCode: raw.dataCode
});
}
function profileForRaw(raw) {
return profilesByRaw.get(`${raw?.graphicType}\u0000${raw?.subtype}`) || null;
}
function safeRaw(raw) {
return isPlainObject(raw) && Number.isInteger(raw.itemIndex) &&
raw.itemIndex >= 0 && raw.itemIndex < 1000 &&
typeof raw.enabled === "boolean" && raw.groupCode === "해외종목" &&
safeText(raw.subject, 200) && raw.pageText === "1/1" && raw.dataCode === "" &&
safeText(raw.graphicType, 256) && safeText(raw.subtype, 256, true) &&
safeText(raw.pageText, 9) && safeText(raw.dataCode, 1024, true) &&
Boolean(profileForRaw(raw));
}
function classify(raw, options) {
const normalizedOptions = requireOptions(options);
const profile = profileForRaw(raw);
if (!normalizedOptions || !safeRaw(raw) || !profile) return null;
const movingAverages = Object.freeze(profile.builderKey === "s8010"
? { ma5: normalizedOptions.ma5, ma20: normalizedOptions.ma20 }
: { ma5: false, ma20: false });
const pending = Object.freeze({
itemIndex: raw.itemIndex,
enabled: raw.enabled,
actionId: profile.actionId,
builderKey: profile.builderKey,
cutCode: profile.cutCode,
valueType: profile.valueType,
periodDays: profile.periodDays,
inputName: raw.subject,
entry: Object.freeze({ id: normalizedOptions.id }),
fadeDuration: normalizedOptions.fadeDuration,
movingAverages,
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({ ...value });
}
if (value.status !== "resolved" || !hasExactKeys(value, [
"itemIndex", "status", "actionId", "inputName", "symbol", "nationCode",
"foreignDomesticCode", "builderKey", "cutCode", "valueType", "periodDays"
]) || value.actionId !== pending.actionId || value.inputName !== pending.inputName ||
value.foreignDomesticCode !== "1" || value.builderKey !== pending.builderKey ||
value.cutCode !== pending.cutCode || value.valueType !== pending.valueType ||
value.periodDays !== pending.periodDays) return null;
const identity = overseas.normalizeStock({
inputName: value.inputName,
symbol: value.symbol,
nationCode: value.nationCode,
fdtc: value.foreignDomesticCode
});
if (!identity) return null;
const resolved = Object.freeze({
itemIndex: value.itemIndex,
status: "resolved",
actionId: value.actionId,
inputName: identity.inputName,
symbol: identity.symbol,
nationCode: identity.nationCode,
foreignDomesticCode: identity.fdtc,
builderKey: value.builderKey,
cutCode: value.cutCode,
valueType: value.valueType,
periodDays: value.periodDays,
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) {
return 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
? Object.freeze({ ...value })
: null;
}
function customOperator(canonical, raw, verifiedAt) {
return Object.freeze({
source: SOURCE,
schemaVersion: SCHEMA_VERSION,
kind: "stock",
actionId: canonical.operator.actionId,
identity: canonical.operator.identity,
movingAverages: canonical.operator.movingAverages,
rawSelection: raw,
verifiedAt
});
}
function buildEntry(pending, outcome) {
const canonical = overseas.createOverseasStockPlaylistEntry({
inputName: outcome.inputName,
symbol: outcome.symbol,
nationCode: outcome.nationCode,
fdtc: outcome.foreignDomesticCode
}, outcome.actionId, {
id: pending.entry.id,
fadeDuration: pending.fadeDuration,
ma5: pending.movingAverages.ma5,
ma20: pending.movingAverages.ma20
});
if (canonical.builderKey !== outcome.builderKey || canonical.code !== outcome.cutCode) return null;
return Object.freeze({
...canonical,
enabled: pending.enabled,
operator: customOperator(canonical, pending.rawSelection, outcome.verifiedAt)
});
}
function materialize(pending, outcome) {
if (!pendingValues.has(pending) || !resolvedValues.has(outcome) ||
outcome.itemIndex !== pending.itemIndex || outcome.actionId !== pending.actionId) return null;
let entry;
try {
entry = buildEntry(pending, outcome);
} catch {
return null;
}
if (!entry) return null;
materializedEntries.add(entry);
return entry;
}
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 restorePlaylistEntry(value) {
if (!hasExactKeys(value, [
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
"symbol", "nationCode", "fdtc", "selection", "enabled", "fadeDuration",
"graphicType", "subtype", "operator"
]) || typeof value.enabled !== "boolean" ||
!isPlainObject(value.operator) || !hasExactKeys(value.operator, [
"source", "schemaVersion", "kind", "actionId", "identity", "movingAverages",
"rawSelection", "verifiedAt"
]) || value.operator.source !== SOURCE ||
value.operator.schemaVersion !== SCHEMA_VERSION || value.operator.kind !== "stock" ||
!profilesByAction.has(value.operator.actionId) ||
!hasExactKeys(value.operator.identity, ["inputName", "symbol", "nationCode", "fdtc"]) ||
!hasExactKeys(value.operator.movingAverages, ["ma5", "ma20"]) ||
typeof value.operator.movingAverages.ma5 !== "boolean" ||
typeof value.operator.movingAverages.ma20 !== "boolean" ||
typeof value.operator.verifiedAt !== "string" ||
!isoTimestampPattern.test(value.operator.verifiedAt) ||
!Number.isFinite(Date.parse(value.operator.verifiedAt))) return null;
const identity = overseas.normalizeStock(value.operator.identity);
const raw = value.operator.rawSelection;
const profile = profileForRaw(raw);
if (!identity || !profile || profile.actionId !== value.operator.actionId ||
!hasExactKeys(raw, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) ||
raw.groupCode !== "해외종목" || raw.subject !== identity.inputName || raw.dataCode !== "" ||
!safeText(raw.subject, 200) ||
(profile.builderKey !== "s8010" &&
(value.operator.movingAverages.ma5 || value.operator.movingAverages.ma20))) return null;
let canonical;
try {
canonical = overseas.createOverseasStockPlaylistEntry(
identity,
profile.actionId,
{
id: value.id,
fadeDuration: value.fadeDuration,
ma5: value.operator.movingAverages.ma5,
ma20: value.operator.movingAverages.ma20
});
} catch {
return null;
}
const rebuilt = Object.freeze({
...canonical,
enabled: value.enabled,
operator: customOperator(canonical, raw, value.operator.verifiedAt)
});
const scalarKeys = [
"id", "builderKey", "code", "title", "detail", "category", "market", "symbol",
"nationCode", "fdtc", "enabled", "fadeDuration", "graphicType", "subtype"
];
if (!scalarKeys.every(key => value[key] === rebuilt[key]) ||
!Array.isArray(value.aliases) || value.aliases.length !== 1 ||
value.aliases[0] !== rebuilt.code || !sameSelection(value.selection, rebuilt.selection) ||
JSON.stringify(value.operator) !== JSON.stringify(rebuilt.operator)) return null;
materializedEntries.add(rebuilt);
return rebuilt;
}
function legacySelectionForEntry(entry) {
const restored = restorePlaylistEntry(entry);
return restored ? restored.operator.rawSelection : 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 && safeRaw(raw) && entry.enabled === raw.enabled &&
["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 {
source: SOURCE,
bridgeContract,
actionProfiles,
classify,
normalizeResponse,
normalizeError,
materialize,
restorePlaylistEntry,
withEnabled,
legacySelectionForEntry,
matchesRaw,
isPending,
isMaterializedEntry
};
});