Complete legacy operator UI and playout migration
This commit is contained in:
5218
Web/app.js
5218
Web/app.js
File diff suppressed because it is too large
Load Diff
146
Web/candle-options-workflow.js
Normal file
146
Web/candle-options-workflow.js
Normal file
@@ -0,0 +1,146 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnCandleOptionsWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const CANDLE_BUILDER_KEY = "s8010";
|
||||
const supportedSources = Object.freeze([
|
||||
"legacy-stock-workflow",
|
||||
"legacy-fixed-workflow",
|
||||
"legacy-industry-workflow",
|
||||
"legacy-overseas-workflow",
|
||||
"legacy-trading-halt-workflow"
|
||||
]);
|
||||
|
||||
function normalizeOptions(ma5, ma20) {
|
||||
if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
|
||||
throw new TypeError("Global moving-average flags must be boolean values.");
|
||||
}
|
||||
return Object.freeze({ ma5, ma20 });
|
||||
}
|
||||
|
||||
function expectedGraphicType(ma5, ma20) {
|
||||
const options = normalizeOptions(ma5, ma20);
|
||||
const flags = [];
|
||||
if (options.ma5) flags.push("MA5");
|
||||
if (options.ma20) flags.push("MA20");
|
||||
return flags.join(",");
|
||||
}
|
||||
|
||||
function hasAdapters(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) &&
|
||||
value.stock && value.fixed && value.industry && value.overseas && value.tradingHalt;
|
||||
}
|
||||
|
||||
function recreateCandleEntry(value, ma5, ma20, adapters, now = new Date()) {
|
||||
const movingAverages = normalizeOptions(ma5, ma20);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
||||
value.builderKey !== CANDLE_BUILDER_KEY || typeof value.enabled !== "boolean" ||
|
||||
!hasAdapters(adapters)) return null;
|
||||
const operator = value.operator;
|
||||
if (!operator || typeof operator !== "object" || !supportedSources.includes(operator.source)) return null;
|
||||
const options = {
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration,
|
||||
ma5: movingAverages.ma5,
|
||||
ma20: movingAverages.ma20
|
||||
};
|
||||
let recreated = null;
|
||||
try {
|
||||
switch (operator.source) {
|
||||
case "legacy-stock-workflow": {
|
||||
const cut = adapters.stock.getStockCut(operator.cutId);
|
||||
if (cut?.kind !== "candle") return null;
|
||||
recreated = adapters.stock.createStockPlaylistEntry(operator.stock, cut.id, options);
|
||||
break;
|
||||
}
|
||||
case "legacy-fixed-workflow": {
|
||||
const action = adapters.fixed.actions.find(item => item.id === operator.actionId);
|
||||
if (action?.builderKey !== CANDLE_BUILDER_KEY || action.available !== true) return null;
|
||||
recreated = adapters.fixed.createFixedPlaylistEntry(action.id, { ...options, now });
|
||||
break;
|
||||
}
|
||||
case "legacy-industry-workflow": {
|
||||
const cut = adapters.industry.cuts.find(item => item.id === operator.cutId);
|
||||
if (cut?.kind !== "candle") return null;
|
||||
recreated = adapters.industry.createIndustryPlaylistEntry(operator.market, cut.id, {
|
||||
...options,
|
||||
selected: operator.selected,
|
||||
pair: operator.pair,
|
||||
now
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "legacy-overseas-workflow": {
|
||||
const action = adapters.overseas.stockActions.find(item => item.id === operator.actionId);
|
||||
if (operator.kind !== "stock" || action?.kind !== "candle") return null;
|
||||
recreated = adapters.overseas.createOverseasStockPlaylistEntry(
|
||||
operator.identity,
|
||||
action.id,
|
||||
options);
|
||||
break;
|
||||
}
|
||||
case "legacy-trading-halt-workflow": {
|
||||
const action = adapters.tradingHalt.getAction(operator.actionId);
|
||||
if (action?.kind !== "candle") return null;
|
||||
recreated = adapters.tradingHalt.createTradingHaltPlaylistEntry(
|
||||
operator.tradingHalt,
|
||||
action.id,
|
||||
options);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return recreated ? { ...recreated, enabled: value.enabled } : null;
|
||||
}
|
||||
|
||||
function entryAlreadyMatches(value, ma5, ma20) {
|
||||
const graphicType = expectedGraphicType(ma5, ma20);
|
||||
return value?.selection?.graphicType === graphicType &&
|
||||
value?.graphicType === graphicType &&
|
||||
value?.operator?.movingAverages?.ma5 === ma5 &&
|
||||
value?.operator?.movingAverages?.ma20 === ma20;
|
||||
}
|
||||
|
||||
function synchronizePlaylist(values, ma5, ma20, adapters, now = new Date()) {
|
||||
normalizeOptions(ma5, ma20);
|
||||
if (!Array.isArray(values)) throw new TypeError("A playlist array is required.");
|
||||
const invalidIds = [];
|
||||
let changed = false;
|
||||
const playlist = values.map(value => {
|
||||
if (value?.builderKey !== CANDLE_BUILDER_KEY) return value;
|
||||
if (entryAlreadyMatches(value, ma5, ma20)) return value;
|
||||
const recreated = recreateCandleEntry(value, ma5, ma20, adapters, now);
|
||||
if (!recreated) {
|
||||
invalidIds.push(typeof value?.id === "string" ? value.id : "");
|
||||
return value;
|
||||
}
|
||||
changed = true;
|
||||
return recreated;
|
||||
});
|
||||
return Object.freeze({
|
||||
playlist: Object.freeze(playlist),
|
||||
changed,
|
||||
valid: invalidIds.length === 0,
|
||||
invalidIds: Object.freeze(invalidIds)
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
candleBuilderKey: CANDLE_BUILDER_KEY,
|
||||
supportedSources,
|
||||
normalizeOptions,
|
||||
expectedGraphicType,
|
||||
recreateCandleEntry,
|
||||
synchronizePlaylist
|
||||
};
|
||||
});
|
||||
496
Web/comparison-workflow.js
Normal file
496
Web/comparison-workflow.js
Normal file
@@ -0,0 +1,496 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnComparisonWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_FADE_DURATION = 6;
|
||||
const SCHEMA_VERSION = 1;
|
||||
const PAIR_LIST_SCHEMA_VERSION = 1;
|
||||
const MAX_SAVED_PAIRS = 500;
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const stockCodePattern = /^[A-Za-z0-9._-]{1,32}$/;
|
||||
const worldSymbolPattern = /^[A-Za-z0-9@._:-]{1,64}$/;
|
||||
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
const runtimeAsset = Object.freeze({
|
||||
relativePath: "bin/Debug/Res/종목비교.ini",
|
||||
sha256: "980F74C000EF6A34B85FDAAF6D2756269483637186C91A93548F35C5CFC1B7E4"
|
||||
});
|
||||
|
||||
const marketTargetRows = [
|
||||
["kospi", "코스피 지수", "Kospi"],
|
||||
["kosdaq", "코스닥 지수", "Kosdaq"],
|
||||
["kospi200", "코스피200 지수", "Kospi200"],
|
||||
["futures", "선물 지수", "Futures"],
|
||||
["krx100", "KRX100지수", "Krx100"],
|
||||
["won-dollar", "환율-원달러", "WonDollar"],
|
||||
["won-yen", "환율-원엔", "WonYen"],
|
||||
["won-yuan", "환율-원위엔", "WonYuan"],
|
||||
["won-euro", "환율-원유로", "WonEuro"],
|
||||
["dow", "해외지수-다우", "Dow"],
|
||||
["nasdaq", "해외지수-나스닥", "Nasdaq"],
|
||||
["sp500", "해외지수-S&P", "Sp500"],
|
||||
["germany-dax", "해외지수-독일", "GermanyDax"],
|
||||
["united-kingdom-ftse", "해외지수-영국", "UnitedKingdomFtse"],
|
||||
["france-cac", "해외지수-프랑스", "FranceCac"],
|
||||
["nikkei", "해외지수-일본", "Nikkei"],
|
||||
["hang-seng", "해외지수-홍콩", "HangSeng"],
|
||||
["taiwan-weighted", "해외지수-대만", "TaiwanWeighted"],
|
||||
["singapore-straits-times", "해외지수-싱가포르", "SingaporeStraitsTimes"],
|
||||
["thailand-set", "해외지수-태국", "ThailandSet"],
|
||||
["philippines-composite", "해외지수-필리핀", "PhilippinesComposite"],
|
||||
["malaysia-klse", "해외지수-말레이시아", "MalaysiaKlse"],
|
||||
["indonesia-composite", "해외지수-인도네시아", "IndonesiaComposite"],
|
||||
["shanghai-composite", "해외지수-중국", "ShanghaiComposite"]
|
||||
];
|
||||
|
||||
const marketTargets = Object.freeze(marketTargetRows.map(([id, displayName, target]) =>
|
||||
Object.freeze({ kind: "market-target", id, displayName, target })));
|
||||
if (marketTargets.length !== 24 || new Set(marketTargets.map(value => value.target)).size !== 24) {
|
||||
throw new Error("The legacy comparison selector must expose exactly 24 market targets.");
|
||||
}
|
||||
const marketTargetsByTarget = new Map(marketTargets.map(value => [value.target, value]));
|
||||
const marketTargetsByLookup = new Map();
|
||||
for (const target of marketTargets) {
|
||||
marketTargetsByLookup.set(target.id.toLocaleLowerCase("en-US"), target);
|
||||
marketTargetsByLookup.set(target.target.toLocaleLowerCase("en-US"), target);
|
||||
marketTargetsByLookup.set(target.displayName.toLocaleLowerCase("ko-KR"), target);
|
||||
}
|
||||
|
||||
function action(id, label, kind, details = {}) {
|
||||
return Object.freeze({ id, label, kind, ...details });
|
||||
}
|
||||
|
||||
const actions = Object.freeze([
|
||||
action("two-column-expected", "2열판 예상체결", "two-column", { mode: "EXPECTED" }),
|
||||
action("two-column-current", "2열판", "two-column", { mode: "CURRENT" }),
|
||||
action("comparison-candle", "종목별 비교분석_캔들 그래프", "comparison", {
|
||||
builderKey: "s5026", code: "5026", graphicType: "COMPARISON_CANDLE", period: "FiveDays"
|
||||
}),
|
||||
action("comparison-line", "종목별 비교분석_라인 그래프", "comparison", {
|
||||
builderKey: "s5087", code: "5087", graphicType: "COMPARISON_LINE", period: "FiveDays"
|
||||
}),
|
||||
action("return-5d", "5일", "return", { period: "FiveDays" }),
|
||||
action("return-1m", "1개월", "return", { period: "OneMonth" }),
|
||||
action("return-3m", "3개월", "return", { period: "ThreeMonths" }),
|
||||
action("return-6m", "6개월", "return", { period: "SixMonths" }),
|
||||
action("return-12m", "12개월", "return", { period: "TwelveMonths" })
|
||||
]);
|
||||
if (actions.length !== 9 || new Set(actions.map(value => value.id)).size !== 9) {
|
||||
throw new Error("The legacy comparison tree must expose exactly nine actions.");
|
||||
}
|
||||
const actionsById = new Map(actions.map(value => [value.id, value]));
|
||||
const yieldActionIds = Object.freeze(actions
|
||||
.filter(value => value.kind === "return")
|
||||
.map(value => value.id));
|
||||
const expectedMarketTargets = new Set(["Kospi", "Kosdaq", "Kospi200", "Krx100"]);
|
||||
|
||||
function cleanText(value, maximumLength = 128) {
|
||||
if (typeof value !== "string") return null;
|
||||
const text = value.trim();
|
||||
return text && text.length <= maximumLength && !controlCharacterPattern.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function getMarketTarget(value) {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) value = value.target ?? value.id ?? value.displayName;
|
||||
const text = cleanText(value);
|
||||
return text ? marketTargetsByLookup.get(text.toLocaleLowerCase("ko-KR")) ?? null : null;
|
||||
}
|
||||
|
||||
function normalizeMarket(value) {
|
||||
const text = String(value || "").trim().toLocaleLowerCase("en-US");
|
||||
return text === "kospi" || text === "kosdaq" ? text : null;
|
||||
}
|
||||
|
||||
function normalizeTarget(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
switch (value.kind) {
|
||||
case "market-target": {
|
||||
const target = marketTargetsByTarget.get(value.target);
|
||||
if (!target) return null;
|
||||
if ((value.id !== undefined && value.id !== target.id) ||
|
||||
(value.displayName !== undefined && value.displayName !== target.displayName)) return null;
|
||||
return target;
|
||||
}
|
||||
case "krx-stock":
|
||||
case "nxt-stock": {
|
||||
const market = normalizeMarket(value.market);
|
||||
const stockName = cleanText(value.stockName ?? value.name);
|
||||
const stockCode = cleanText(value.stockCode ?? value.code, 32);
|
||||
if (!market || !stockName || stockName.includes(",") || stockName.includes("(NXT)") ||
|
||||
!stockCode || !stockCodePattern.test(stockCode)) return null;
|
||||
const displayName = value.kind === "nxt-stock" ? `${stockName}(NXT)` : stockName;
|
||||
if (value.displayName !== undefined && value.displayName !== displayName) return null;
|
||||
return Object.freeze({ kind: value.kind, market, stockName, stockCode, displayName });
|
||||
}
|
||||
case "world-stock": {
|
||||
const inputName = cleanText(value.inputName);
|
||||
const displayName = cleanText(value.displayName ?? value.name);
|
||||
const symbol = cleanText(value.symbol, 64);
|
||||
const nation = cleanText(value.nation, 2)?.toLocaleUpperCase("en-US");
|
||||
if (!inputName || inputName.includes(",") || !displayName || !symbol ||
|
||||
!worldSymbolPattern.test(symbol) || (nation !== "US" && nation !== "TW")) return null;
|
||||
return Object.freeze({ kind: "world-stock", inputName, displayName, symbol, nation });
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function targetKey(value) {
|
||||
const target = normalizeTarget(value);
|
||||
if (!target) return null;
|
||||
switch (target.kind) {
|
||||
case "market-target": return `market:${target.target}`;
|
||||
case "krx-stock": return `krx:${target.market}:${target.stockCode}`;
|
||||
case "nxt-stock": return `nxt:${target.market}:${target.stockCode}`;
|
||||
case "world-stock": return `world:${target.nation}:${target.symbol}`;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function subjectTokenForTarget(value) {
|
||||
const target = normalizeTarget(value);
|
||||
if (!target) return null;
|
||||
switch (target.kind) {
|
||||
case "market-target": return target.target;
|
||||
case "krx-stock": return target.stockName;
|
||||
case "nxt-stock": return `${target.stockName}(NXT)`;
|
||||
case "world-stock": return target.inputName;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pairParts(value) {
|
||||
if (Array.isArray(value)) return [value[0], value[1]];
|
||||
if (value && typeof value === "object") return [value.first ?? value.pair?.[0], value.second ?? value.pair?.[1]];
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
function normalizePair(value) {
|
||||
const [firstValue, secondValue] = pairParts(value);
|
||||
const first = normalizeTarget(firstValue);
|
||||
const second = normalizeTarget(secondValue);
|
||||
return first && second ? Object.freeze([first, second]) : null;
|
||||
}
|
||||
|
||||
function normalizePairState(value) {
|
||||
const [firstValue, secondValue] = pairParts(value);
|
||||
const first = firstValue == null ? null : normalizeTarget(firstValue);
|
||||
const second = secondValue == null ? null : normalizeTarget(secondValue);
|
||||
return (firstValue == null || first) && (secondValue == null || second)
|
||||
? Object.freeze([first, second])
|
||||
: null;
|
||||
}
|
||||
|
||||
function rotatePair(value, selectedValue) {
|
||||
const pair = normalizePairState(value);
|
||||
const selected = normalizeTarget(selectedValue);
|
||||
return pair && selected ? Object.freeze([selected, pair[0]]) : null;
|
||||
}
|
||||
|
||||
function swapPair(value) {
|
||||
const pair = normalizePairState(value);
|
||||
return pair ? Object.freeze([pair[1], pair[0]]) : null;
|
||||
}
|
||||
|
||||
function clearPair() {
|
||||
return Object.freeze([null, null]);
|
||||
}
|
||||
|
||||
function pairLabel(value) {
|
||||
const pair = normalizePair(value);
|
||||
return pair ? `${pair[0].displayName} ↔ ${pair[1].displayName}` : "";
|
||||
}
|
||||
|
||||
function unsupported(actionId, reason) {
|
||||
return Object.freeze({ actionId, supported: false, reason, effectiveMode: null, warning: null });
|
||||
}
|
||||
|
||||
function supported(actionId, effectiveMode = null, warning = null) {
|
||||
return Object.freeze({ actionId, supported: true, reason: null, effectiveMode, warning });
|
||||
}
|
||||
|
||||
function getCompatibility(actionId, pairValue) {
|
||||
const selectedAction = actionsById.get(actionId);
|
||||
if (!selectedAction) return unsupported(actionId, "unknown-action");
|
||||
const pair = normalizePair(pairValue);
|
||||
if (!pair) return unsupported(actionId, "pair-incomplete-or-invalid");
|
||||
const futuresCount = pair.filter(value => value.kind === "market-target" && value.target === "Futures").length;
|
||||
const hasWorld = pair.some(value => value.kind === "world-stock");
|
||||
|
||||
if (selectedAction.kind === "comparison" || selectedAction.kind === "return") {
|
||||
return pair.every(value => value.kind === "krx-stock")
|
||||
? supported(actionId, "GRAPH")
|
||||
: unsupported(actionId, "comparison-graphs-require-two-krx-stocks");
|
||||
}
|
||||
|
||||
if (futuresCount > 1) return unsupported(actionId, "two-futures-targets-are-unsupported");
|
||||
if (futuresCount === 1) {
|
||||
const counterpart = pair.find(value => !(value.kind === "market-target" && value.target === "Futures"));
|
||||
if (!counterpart || counterpart.kind !== "market-target") {
|
||||
return unsupported(actionId, "futures-requires-a-market-target-counterpart");
|
||||
}
|
||||
return selectedAction.mode === "EXPECTED"
|
||||
? supported(actionId, "CURRENT", "The current Core routes a futures expected action through the current s5032 branch.")
|
||||
: supported(actionId, "CURRENT");
|
||||
}
|
||||
|
||||
if (selectedAction.mode === "EXPECTED") {
|
||||
const allowed = pair.every(value => value.kind === "krx-stock" ||
|
||||
(value.kind === "market-target" && expectedMarketTargets.has(value.target)));
|
||||
return allowed
|
||||
? supported(actionId, "EXPECTED")
|
||||
: unsupported(actionId, "expected-mode-supports-only-krx-stocks-and-four-domestic-indices");
|
||||
}
|
||||
|
||||
const hasMarketTarget = pair.some(value => value.kind === "market-target");
|
||||
if (hasMarketTarget && hasWorld) {
|
||||
return unsupported(actionId, "mixed-market-and-world-is-unsupported");
|
||||
}
|
||||
return supported(actionId, "CURRENT");
|
||||
}
|
||||
|
||||
function isActionAllowed(actionId, pairValue) {
|
||||
return getCompatibility(actionId, pairValue).supported;
|
||||
}
|
||||
|
||||
function buildComparisonSelection(actionId, pairValue) {
|
||||
const selectedAction = actionsById.get(actionId);
|
||||
const pair = normalizePair(pairValue);
|
||||
const compatibility = getCompatibility(actionId, pair);
|
||||
if (!selectedAction || !pair || !compatibility.supported) {
|
||||
throw new RangeError(`The comparison action is unavailable: ${compatibility.reason || "invalid-selection"}.`);
|
||||
}
|
||||
const subject = pair.map(subjectTokenForTarget).join(",");
|
||||
if (selectedAction.kind === "two-column") {
|
||||
const futures = pair.some(value => value.kind === "market-target" && value.target === "Futures");
|
||||
const code = futures ? "5032" : "8032";
|
||||
return Object.freeze({
|
||||
builderKey: futures ? "s5032" : "s8018",
|
||||
code,
|
||||
aliases: Object.freeze([code]),
|
||||
selection: Object.freeze({
|
||||
groupCode: pair.some(value => value.kind === "market-target") ? "INDEX" : "STOCK",
|
||||
subject,
|
||||
graphicType: "2열판",
|
||||
subtype: selectedAction.mode,
|
||||
dataCode: ""
|
||||
}),
|
||||
effectiveMode: compatibility.effectiveMode,
|
||||
warning: compatibility.warning
|
||||
});
|
||||
}
|
||||
const builderKey = selectedAction.kind === "return" ? "s5029" : selectedAction.builderKey;
|
||||
const code = selectedAction.kind === "return" ? "5029" : selectedAction.code;
|
||||
const graphicType = selectedAction.kind === "return" ? "RETURN_COMPARISON" : selectedAction.graphicType;
|
||||
return Object.freeze({
|
||||
builderKey,
|
||||
code,
|
||||
aliases: Object.freeze([code]),
|
||||
selection: Object.freeze({
|
||||
groupCode: "STOCK",
|
||||
subject,
|
||||
graphicType,
|
||||
subtype: selectedAction.period,
|
||||
dataCode: ""
|
||||
}),
|
||||
effectiveMode: compatibility.effectiveMode,
|
||||
warning: compatibility.warning
|
||||
});
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new TypeError("Comparison playlist options are required.");
|
||||
}
|
||||
const id = cleanText(options.id);
|
||||
if (!id || !identifierPattern.test(id)) throw new TypeError("A safe comparison playlist id is required.");
|
||||
const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : Number(options.fadeDuration);
|
||||
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
||||
throw new RangeError("Fade duration must be an integer from 0 through 60.");
|
||||
}
|
||||
return { id, fadeDuration };
|
||||
}
|
||||
|
||||
function createComparisonPlaylistEntry(actionId, pairValue, options) {
|
||||
const selectedAction = actionsById.get(actionId);
|
||||
if (!selectedAction) throw new RangeError("The comparison action is unknown.");
|
||||
const pair = normalizePair(pairValue);
|
||||
if (!pair) throw new RangeError("Select two comparison targets first.");
|
||||
const normalized = requireOptions(options);
|
||||
const mapping = buildComparisonSelection(actionId, pair);
|
||||
const entry = {
|
||||
id: normalized.id,
|
||||
builderKey: mapping.builderKey,
|
||||
code: mapping.code,
|
||||
aliases: mapping.aliases,
|
||||
title: pairLabel(pair),
|
||||
detail: selectedAction.label,
|
||||
category: selectedAction.kind === "two-column" ? "plate" : "chart",
|
||||
market: "compare",
|
||||
selection: mapping.selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalized.fadeDuration,
|
||||
graphicType: mapping.selection.graphicType,
|
||||
subtype: mapping.selection.subtype,
|
||||
effectiveMode: mapping.effectiveMode,
|
||||
warning: mapping.warning,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-comparison-workflow",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
actionId,
|
||||
pair,
|
||||
effectiveMode: mapping.effectiveMode,
|
||||
assetPath: runtimeAsset.relativePath,
|
||||
assetSha256: runtimeAsset.sha256
|
||||
})
|
||||
};
|
||||
return entry;
|
||||
}
|
||||
|
||||
function createYieldBatchEntries(pairValue, options = {}) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new TypeError("Yield batch options must be an object.");
|
||||
}
|
||||
const idPrefix = cleanText(options.idPrefix ?? "comparison-return", 96);
|
||||
if (!idPrefix || !identifierPattern.test(idPrefix)) throw new TypeError("A safe yield batch id prefix is required.");
|
||||
return Object.freeze(yieldActionIds.map(actionId => createComparisonPlaylistEntry(actionId, pairValue, {
|
||||
id: `${idPrefix}-${actionId.replace("return-", "")}`,
|
||||
fadeDuration: options.fadeDuration
|
||||
})));
|
||||
}
|
||||
|
||||
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 restoreComparisonPlaylistEntry(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-comparison-workflow" ||
|
||||
operator.schemaVersion !== SCHEMA_VERSION || operator.assetPath !== runtimeAsset.relativePath ||
|
||||
operator.assetSha256 !== runtimeAsset.sha256 || !actionsById.has(operator.actionId)) return null;
|
||||
let recreated;
|
||||
try {
|
||||
recreated = createComparisonPlaylistEntry(operator.actionId, operator.pair, {
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const scalars = [
|
||||
"id", "builderKey", "code", "title", "detail", "category", "market",
|
||||
"fadeDuration", "graphicType", "subtype", "effectiveMode", "warning"
|
||||
];
|
||||
if (!scalars.every(key => value[key] === recreated[key]) ||
|
||||
!Array.isArray(value.aliases) || value.aliases.length !== recreated.aliases.length ||
|
||||
!value.aliases.every((alias, index) => alias === recreated.aliases[index]) ||
|
||||
!sameSelection(value.selection, recreated.selection) ||
|
||||
operator.effectiveMode !== recreated.operator.effectiveMode) return null;
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
function createPairRecord(idValue, pairValue) {
|
||||
const id = cleanText(idValue);
|
||||
const pair = normalizePair(pairValue);
|
||||
if (!id || !identifierPattern.test(id) || !pair) return null;
|
||||
return Object.freeze({ id, pair });
|
||||
}
|
||||
|
||||
function emptyPairList() {
|
||||
return Object.freeze({ schemaVersion: PAIR_LIST_SCHEMA_VERSION, pairs: Object.freeze([]) });
|
||||
}
|
||||
|
||||
function freezePairList(records) {
|
||||
return Object.freeze({ schemaVersion: PAIR_LIST_SCHEMA_VERSION, pairs: Object.freeze(records) });
|
||||
}
|
||||
|
||||
function normalizePairList(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
||||
value.schemaVersion !== PAIR_LIST_SCHEMA_VERSION || !Array.isArray(value.pairs)) return emptyPairList();
|
||||
const records = [];
|
||||
const ids = new Set();
|
||||
for (const raw of value.pairs) {
|
||||
if (records.length >= MAX_SAVED_PAIRS) break;
|
||||
const record = createPairRecord(raw?.id, raw?.pair ?? raw);
|
||||
if (!record || ids.has(record.id)) continue;
|
||||
ids.add(record.id);
|
||||
records.push(record);
|
||||
}
|
||||
return freezePairList(records);
|
||||
}
|
||||
|
||||
function appendPair(value, recordOrId, pairValue) {
|
||||
const list = normalizePairList(value);
|
||||
const record = typeof recordOrId === "string"
|
||||
? createPairRecord(recordOrId, pairValue)
|
||||
: createPairRecord(recordOrId?.id, recordOrId?.pair ?? recordOrId);
|
||||
if (!record) throw new TypeError("A valid saved comparison pair is required.");
|
||||
if (list.pairs.length >= MAX_SAVED_PAIRS) throw new RangeError("The saved comparison pair limit was reached.");
|
||||
if (list.pairs.some(value => value.id === record.id)) throw new RangeError("The saved comparison pair id already exists.");
|
||||
return freezePairList([...list.pairs, record]);
|
||||
}
|
||||
|
||||
function reorderPairList(value, idValue, directionValue) {
|
||||
const list = normalizePairList(value);
|
||||
const id = cleanText(idValue);
|
||||
const direction = directionValue === "up" ? -1 : directionValue === "down" ? 1 : Number(directionValue);
|
||||
if (!id || (direction !== -1 && direction !== 1)) return list;
|
||||
const from = list.pairs.findIndex(value => value.id === id);
|
||||
const to = from + direction;
|
||||
if (from < 0 || to < 0 || to >= list.pairs.length) return list;
|
||||
const records = [...list.pairs];
|
||||
[records[from], records[to]] = [records[to], records[from]];
|
||||
return freezePairList(records);
|
||||
}
|
||||
|
||||
function deletePair(value, idValue) {
|
||||
const list = normalizePairList(value);
|
||||
const id = cleanText(idValue);
|
||||
return id ? freezePairList(list.pairs.filter(value => value.id !== id)) : list;
|
||||
}
|
||||
|
||||
function deleteAllPairs() {
|
||||
return emptyPairList();
|
||||
}
|
||||
|
||||
return {
|
||||
runtimeAsset,
|
||||
marketTargets,
|
||||
marketQuoteTargets: marketTargets,
|
||||
actions,
|
||||
comparisonActions: actions,
|
||||
yieldActionIds,
|
||||
pairListSchemaVersion: PAIR_LIST_SCHEMA_VERSION,
|
||||
normalizeTarget,
|
||||
getMarketTarget,
|
||||
targetKey,
|
||||
subjectTokenForTarget,
|
||||
normalizePair,
|
||||
rotatePair,
|
||||
swapPair,
|
||||
clearPair,
|
||||
pairLabel,
|
||||
getCompatibility,
|
||||
isActionAllowed,
|
||||
buildComparisonSelection,
|
||||
createComparisonPlaylistEntry,
|
||||
createYieldBatchEntries,
|
||||
restoreComparisonPlaylistEntry,
|
||||
refreshComparisonPlaylistEntry: restoreComparisonPlaylistEntry,
|
||||
createPairRecord,
|
||||
normalizePairList,
|
||||
appendPair,
|
||||
reorderPairList,
|
||||
deletePair,
|
||||
deleteAllPairs
|
||||
};
|
||||
});
|
||||
502
Web/expert-workflow.js
Normal file
502
Web/expert-workflow.js
Normal file
@@ -0,0 +1,502 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnExpertWorkflow = 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 DEFAULT_MAXIMUM_PREVIEW_ITEMS = 100;
|
||||
const MAXIMUM_PREVIEW_ITEMS = 100;
|
||||
const MAXIMUM_PAGE_COUNT = 20;
|
||||
const PAGE_SIZE = 5;
|
||||
const SCHEMA_VERSION = 1;
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const expertCodePattern = /^[0-9]{4}$/;
|
||||
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
|
||||
const bridgeContract = Object.freeze({
|
||||
searchRequestType: "search-experts",
|
||||
searchResultType: "expert-search-results",
|
||||
searchErrorType: "expert-search-error",
|
||||
previewRequestType: "request-expert-preview",
|
||||
previewResultType: "expert-preview-results",
|
||||
previewErrorType: "expert-preview-error",
|
||||
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
|
||||
maximumResults: MAXIMUM_RESULTS,
|
||||
maximumQueryLength: MAXIMUM_QUERY_LENGTH,
|
||||
defaultMaximumPreviewItems: DEFAULT_MAXIMUM_PREVIEW_ITEMS,
|
||||
maximumPreviewItems: MAXIMUM_PREVIEW_ITEMS
|
||||
});
|
||||
|
||||
const sourceContract = Object.freeze({
|
||||
originalControl: "Control/UC6.cs",
|
||||
provider: "oracle",
|
||||
expertTable: "EXPERT_LIST",
|
||||
recommendationTable: "RECOMMEND_LIST",
|
||||
originalGroupLabel: "전문가 추천",
|
||||
originalCutLabel: "5단 표그래프",
|
||||
originalValueLabel: "현재가",
|
||||
builderKey: "s5074",
|
||||
cutCode: "5074",
|
||||
resolverGroup: "PAGED_EXPERT",
|
||||
pageSize: PAGE_SIZE,
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT
|
||||
});
|
||||
|
||||
const actions = Object.freeze([
|
||||
Object.freeze({
|
||||
id: "five-row-current",
|
||||
label: "전문가 추천 · 5단 표그래프 · 현재가",
|
||||
builderKey: "s5074",
|
||||
code: "5074",
|
||||
aliases: Object.freeze(["5074"]),
|
||||
pageSize: PAGE_SIZE
|
||||
})
|
||||
]);
|
||||
const action = actions[0];
|
||||
const trustedPreviewResponses = new WeakSet();
|
||||
|
||||
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 hasOnlyKeys(value, keys) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) &&
|
||||
Object.keys(value).every(key => keys.includes(key));
|
||||
}
|
||||
|
||||
function isSafeCanonicalText(value, maximumLength) {
|
||||
return typeof value === "string" &&
|
||||
value.length > 0 && value.length <= maximumLength &&
|
||||
value === value.trim() && !unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function isSafeRequestId(value) {
|
||||
return isSafeCanonicalText(value, 128) && identifierPattern.test(value);
|
||||
}
|
||||
|
||||
function normalizeQuery(value) {
|
||||
if (typeof value !== "string" || unsafeTextPattern.test(value)) return null;
|
||||
const query = value.trim();
|
||||
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query)
|
||||
? query
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeExpertIdentity(value) {
|
||||
if (!hasExactKeys(value, ["expertCode", "expertName"]) ||
|
||||
!expertCodePattern.test(value.expertCode) ||
|
||||
!isSafeCanonicalText(value.expertName, 128)) return null;
|
||||
return Object.freeze({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExpert(value) {
|
||||
if (!hasExactKeys(value, ["expertCode", "expertName", "source"]) ||
|
||||
value.source !== "oracle") return null;
|
||||
const identity = normalizeExpertIdentity({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName
|
||||
});
|
||||
return identity && Object.freeze({ ...identity, source: "oracle" });
|
||||
}
|
||||
|
||||
function createExpertSearchRequest(requestId, query = "", maximumResults) {
|
||||
if (!isSafeRequestId(requestId)) {
|
||||
throw new TypeError("A safe expert-search request id is required.");
|
||||
}
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
if (normalizedQuery === null) {
|
||||
throw new TypeError(`An expert query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`);
|
||||
}
|
||||
if (maximumResults !== undefined &&
|
||||
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
|
||||
throw new RangeError(`Expert 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 compareExperts(left, right) {
|
||||
if (left.expertName < right.expertName) return -1;
|
||||
if (left.expertName > right.expertName) return 1;
|
||||
if (left.expertCode < right.expertCode) return -1;
|
||||
if (left.expertCode > right.expertCode) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeExpertSearchResponse(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
|
||||
]) || !isSafeRequestId(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;
|
||||
|
||||
let expected = null;
|
||||
if (expectedRequest !== undefined) {
|
||||
try {
|
||||
expected = createExpertSearchRequest(
|
||||
expectedRequest?.requestId,
|
||||
expectedRequest?.query,
|
||||
expectedRequest?.maximumResults);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const bound = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS;
|
||||
if (value.requestId !== expected.requestId || value.query !== expected.query ||
|
||||
value.results.length > bound || (value.truncated && value.results.length !== bound)) return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
for (const raw of value.results) {
|
||||
const expert = normalizeExpert(raw);
|
||||
if (!expert || codes.has(expert.expertCode) || names.has(expert.expertName) ||
|
||||
(results.length && compareExperts(results[results.length - 1], expert) > 0)) return null;
|
||||
codes.add(expert.expertCode);
|
||||
names.add(expert.expertName);
|
||||
results.push(expert);
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId: value.requestId,
|
||||
query: value.query,
|
||||
retrievedAt: value.retrievedAt,
|
||||
totalRowCount: value.totalRowCount,
|
||||
truncated: value.truncated,
|
||||
results: Object.freeze(results)
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExpertSearchError(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, ["requestId", "query", "message"]) ||
|
||||
!isSafeRequestId(value.requestId) || normalizeQuery(value.query) !== value.query ||
|
||||
!isSafeCanonicalText(value.message, 512)) return null;
|
||||
if (expectedRequest) {
|
||||
let expected;
|
||||
try {
|
||||
expected = createExpertSearchRequest(
|
||||
expectedRequest.requestId,
|
||||
expectedRequest.query,
|
||||
expectedRequest.maximumResults);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (value.requestId !== expected.requestId || value.query !== expected.query) return null;
|
||||
}
|
||||
return Object.freeze({ requestId: value.requestId, query: value.query, message: value.message });
|
||||
}
|
||||
|
||||
function createExpertPreviewRequest(requestId, expertValue, maximumItems) {
|
||||
if (!isSafeRequestId(requestId)) {
|
||||
throw new TypeError("A safe expert-preview request id is required.");
|
||||
}
|
||||
const expert = normalizeExpert(expertValue);
|
||||
if (!expert) throw new TypeError("A typed Oracle expert is required.");
|
||||
if (maximumItems !== undefined &&
|
||||
(!Number.isInteger(maximumItems) || maximumItems < 1 || maximumItems > MAXIMUM_PREVIEW_ITEMS)) {
|
||||
throw new RangeError(`Expert preview items must be limited from 1 through ${MAXIMUM_PREVIEW_ITEMS}.`);
|
||||
}
|
||||
const request = {
|
||||
requestId,
|
||||
expertCode: expert.expertCode,
|
||||
expertName: expert.expertName
|
||||
};
|
||||
if (maximumItems !== undefined) request.maximumItems = maximumItems;
|
||||
return Object.freeze(request);
|
||||
}
|
||||
|
||||
function normalizeRecommendation(value) {
|
||||
if (!hasExactKeys(value, ["playIndex", "stockCode", "stockName", "buyAmount"]) ||
|
||||
!Number.isInteger(value.playIndex) || value.playIndex < 0 || value.playIndex > 9999 ||
|
||||
typeof value.stockCode !== "string" || !stockCodePattern.test(value.stockCode) ||
|
||||
!isSafeCanonicalText(value.stockName, 200) ||
|
||||
!Number.isSafeInteger(value.buyAmount) || value.buyAmount <= 0) return null;
|
||||
return Object.freeze({
|
||||
playIndex: value.playIndex,
|
||||
stockCode: value.stockCode,
|
||||
stockName: value.stockName,
|
||||
buyAmount: value.buyAmount
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExpertPreviewResponse(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items"
|
||||
]) || !isSafeRequestId(value.requestId) ||
|
||||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
||||
value.totalRowCount > MAXIMUM_PREVIEW_ITEMS || typeof value.truncated !== "boolean" ||
|
||||
!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null;
|
||||
const selection = normalizeExpertIdentity(value.selection);
|
||||
if (!selection) return null;
|
||||
|
||||
if (expectedRequest !== undefined) {
|
||||
let expected;
|
||||
try {
|
||||
expected = createExpertPreviewRequest(
|
||||
expectedRequest?.requestId,
|
||||
{
|
||||
expertCode: expectedRequest?.expertCode,
|
||||
expertName: expectedRequest?.expertName,
|
||||
source: "oracle"
|
||||
},
|
||||
expectedRequest?.maximumItems);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const bound = expected.maximumItems ?? DEFAULT_MAXIMUM_PREVIEW_ITEMS;
|
||||
if (value.requestId !== expected.requestId ||
|
||||
selection.expertCode !== expected.expertCode ||
|
||||
selection.expertName !== expected.expertName ||
|
||||
value.items.length > bound || (value.truncated && value.items.length !== bound)) return null;
|
||||
}
|
||||
|
||||
const items = [];
|
||||
const indexes = new Set();
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
for (const raw of value.items) {
|
||||
const item = normalizeRecommendation(raw);
|
||||
if (!item || indexes.has(item.playIndex) || codes.has(item.stockCode) || names.has(item.stockName) ||
|
||||
(items.length && items[items.length - 1].playIndex >= item.playIndex)) return null;
|
||||
indexes.add(item.playIndex);
|
||||
codes.add(item.stockCode);
|
||||
names.add(item.stockName);
|
||||
items.push(item);
|
||||
}
|
||||
const result = Object.freeze({
|
||||
requestId: value.requestId,
|
||||
selection,
|
||||
retrievedAt: value.retrievedAt,
|
||||
totalRowCount: value.totalRowCount,
|
||||
truncated: value.truncated,
|
||||
items: Object.freeze(items)
|
||||
});
|
||||
trustedPreviewResponses.add(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeExpertPreviewError(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, ["requestId", "selection", "message"]) ||
|
||||
!isSafeRequestId(value.requestId) || !isSafeCanonicalText(value.message, 512)) return null;
|
||||
const selection = normalizeExpertIdentity(value.selection);
|
||||
if (!selection) return null;
|
||||
if (expectedRequest) {
|
||||
let expected;
|
||||
try {
|
||||
expected = createExpertPreviewRequest(
|
||||
expectedRequest.requestId,
|
||||
{
|
||||
expertCode: expectedRequest.expertCode,
|
||||
expertName: expectedRequest.expertName,
|
||||
source: "oracle"
|
||||
},
|
||||
expectedRequest.maximumItems);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (value.requestId !== expected.requestId ||
|
||||
selection.expertCode !== expected.expertCode ||
|
||||
selection.expertName !== expected.expertName) return null;
|
||||
}
|
||||
return Object.freeze({ requestId: value.requestId, selection, message: value.message });
|
||||
}
|
||||
|
||||
function createSelectableExpert(expertValue, previewValue) {
|
||||
const expert = normalizeExpert(expertValue);
|
||||
if (!expert || !previewValue || typeof previewValue !== "object" ||
|
||||
!trustedPreviewResponses.has(previewValue) ||
|
||||
!normalizeExpertIdentity(previewValue.selection) ||
|
||||
previewValue.selection.expertCode !== expert.expertCode ||
|
||||
previewValue.selection.expertName !== expert.expertName ||
|
||||
!Number.isInteger(previewValue.totalRowCount) || previewValue.totalRowCount < 0 ||
|
||||
previewValue.totalRowCount > MAXIMUM_PREVIEW_ITEMS ||
|
||||
typeof previewValue.truncated !== "boolean") return null;
|
||||
return Object.freeze({
|
||||
...expert,
|
||||
itemCount: previewValue.totalRowCount,
|
||||
previewTruncated: previewValue.truncated
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSelectableExpert(value) {
|
||||
if (!hasExactKeys(value, [
|
||||
"expertCode", "expertName", "source", "itemCount", "previewTruncated"
|
||||
]) || !Number.isInteger(value.itemCount) || value.itemCount < 0 ||
|
||||
value.itemCount > MAXIMUM_PREVIEW_ITEMS || typeof value.previewTruncated !== "boolean") return null;
|
||||
const expert = normalizeExpert({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName,
|
||||
source: value.source
|
||||
});
|
||||
return expert && Object.freeze({
|
||||
...expert,
|
||||
itemCount: value.itemCount,
|
||||
previewTruncated: value.previewTruncated
|
||||
});
|
||||
}
|
||||
|
||||
function calculatePagePreview(expertValue) {
|
||||
const expert = normalizeSelectableExpert(expertValue);
|
||||
if (!expert) return null;
|
||||
return Object.freeze({
|
||||
itemCount: expert.itemCount,
|
||||
accessibleItemCount: expert.itemCount,
|
||||
pageSize: PAGE_SIZE,
|
||||
pageCount: Math.ceil(expert.itemCount / PAGE_SIZE),
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
||||
isTruncated: expert.previewTruncated
|
||||
});
|
||||
}
|
||||
|
||||
function buildExpertSelection(expertValue) {
|
||||
const expert = normalizeSelectableExpert(expertValue);
|
||||
if (!expert) throw new TypeError("A previewed expert selection is required.");
|
||||
return Object.freeze({
|
||||
builderKey: action.builderKey,
|
||||
code: action.code,
|
||||
aliases: action.aliases,
|
||||
selection: Object.freeze({
|
||||
groupCode: "PAGED_EXPERT",
|
||||
subject: expert.expertName,
|
||||
graphicType: "INPUT_ORDER",
|
||||
subtype: "CURRENT",
|
||||
dataCode: expert.expertCode
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!hasOnlyKeys(options, ["id", "fadeDuration"]) || !isSafeRequestId(options.id)) {
|
||||
throw new TypeError("Safe expert playlist options are 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.");
|
||||
}
|
||||
return Object.freeze({ id: options.id, fadeDuration });
|
||||
}
|
||||
|
||||
function createExpertPlaylistEntry(expertValue, options) {
|
||||
const expert = normalizeSelectableExpert(expertValue);
|
||||
if (!expert) throw new TypeError("A previewed expert selection is required.");
|
||||
if (expert.itemCount === 0) throw new RangeError("An expert with no recommendation rows cannot create a cut.");
|
||||
const normalizedOptions = requireOptions(options);
|
||||
const mapping = buildExpertSelection(expert);
|
||||
const pagePreview = calculatePagePreview(expert);
|
||||
return {
|
||||
id: normalizedOptions.id,
|
||||
builderKey: mapping.builderKey,
|
||||
code: mapping.code,
|
||||
aliases: [...mapping.aliases],
|
||||
title: expert.expertName,
|
||||
detail: action.label,
|
||||
category: "expert",
|
||||
source: expert.source,
|
||||
expertCode: expert.expertCode,
|
||||
expertName: expert.expertName,
|
||||
itemCount: expert.itemCount,
|
||||
previewTruncated: expert.previewTruncated,
|
||||
pageSize: pagePreview.pageSize,
|
||||
pageCount: pagePreview.pageCount,
|
||||
selection: mapping.selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
graphicType: mapping.selection.graphicType,
|
||||
subtype: mapping.selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-expert-workflow",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
actionId: action.id,
|
||||
expert,
|
||||
pagePreview
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
return left && right && ["groupCode", "subject", "graphicType", "subtype", "dataCode"]
|
||||
.every(key => typeof left[key] === "string" && left[key] === right[key]);
|
||||
}
|
||||
|
||||
function samePagePreview(left, right) {
|
||||
return left && right && [
|
||||
"itemCount", "accessibleItemCount", "pageSize", "pageCount",
|
||||
"maximumPageCount", "isTruncated"
|
||||
].every(key => left[key] === right[key]);
|
||||
}
|
||||
|
||||
function restoreExpertPlaylistEntry(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
||||
typeof value.enabled !== "boolean") return null;
|
||||
const operator = value.operator;
|
||||
if (!hasExactKeys(operator, ["source", "schemaVersion", "actionId", "expert", "pagePreview"]) ||
|
||||
operator.source !== "legacy-expert-workflow" || operator.schemaVersion !== SCHEMA_VERSION ||
|
||||
operator.actionId !== action.id || !normalizeSelectableExpert(operator.expert)) return null;
|
||||
|
||||
let recreated;
|
||||
try {
|
||||
recreated = createExpertPlaylistEntry(operator.expert, {
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const scalars = [
|
||||
"id", "builderKey", "code", "title", "detail", "category", "source",
|
||||
"expertCode", "expertName", "itemCount", "previewTruncated", "pageSize",
|
||||
"pageCount", "fadeDuration", "graphicType", "subtype"
|
||||
];
|
||||
if (!scalars.every(key => value[key] === recreated[key]) ||
|
||||
!sameStringArray(value.aliases, recreated.aliases) ||
|
||||
!sameSelection(value.selection, recreated.selection) ||
|
||||
!samePagePreview(operator.pagePreview, recreated.operator.pagePreview)) return null;
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
bridgeContract,
|
||||
sourceContract,
|
||||
actions,
|
||||
normalizeExpertIdentity,
|
||||
normalizeExpert,
|
||||
createExpertSearchRequest,
|
||||
normalizeExpertSearchResponse,
|
||||
normalizeExpertSearchError,
|
||||
createExpertPreviewRequest,
|
||||
normalizeRecommendation,
|
||||
normalizeExpertPreviewResponse,
|
||||
normalizeExpertPreviewError,
|
||||
createSelectableExpert,
|
||||
calculatePagePreview,
|
||||
buildExpertSelection,
|
||||
createExpertPlaylistEntry,
|
||||
restoreExpertPlaylistEntry,
|
||||
refreshExpertPlaylistEntry: restoreExpertPlaylistEntry
|
||||
};
|
||||
});
|
||||
359
Web/index.html
359
Web/index.html
@@ -6,6 +6,9 @@
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<title>MBN Stock WebView</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="manual-lists-ui.css">
|
||||
<link rel="stylesheet" href="manual-financial-ui.css">
|
||||
<link rel="stylesheet" href="operator-catalog-ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -27,6 +30,7 @@
|
||||
<button class="nav-item" data-market="kosdaq"><span>Q</span>코스닥</button>
|
||||
<button class="nav-item" data-market="compare"><span>≍</span>비교</button>
|
||||
<button class="nav-item" data-market="theme"><span>◆</span>테마</button>
|
||||
<button class="nav-item" data-market="foreign-stock"><span>$</span>해외종목</button>
|
||||
<button class="nav-item" data-market="expert"><span>✦</span>전문가</button>
|
||||
<button class="nav-item" data-market="halted"><span>Ⅱ</span>정지</button>
|
||||
</nav>
|
||||
@@ -61,12 +65,278 @@
|
||||
<div class="console-grid">
|
||||
<section class="panel catalog-panel">
|
||||
<div class="panel-header">
|
||||
<div><p class="panel-kicker">CUT CATALOG</p><h2>그래픽 항목</h2></div>
|
||||
<div><p class="panel-kicker">OPERATOR INPUT</p><h2>종목·그래픽 선택</h2></div>
|
||||
<span id="catalogModeBadge" class="badge">Web UI</span>
|
||||
</div>
|
||||
<section id="stockWorkflow" class="stock-workflow" aria-labelledby="stockWorkflowTitle">
|
||||
<div class="stock-workflow-header">
|
||||
<div>
|
||||
<p class="panel-kicker">LEGACY STOCK WORKFLOW</p>
|
||||
<h3 id="stockWorkflowTitle">종목 검색</h3>
|
||||
</div>
|
||||
<div class="stock-workflow-tools">
|
||||
<span id="stockSearchBadge" class="badge neutral">DB SEARCH</span>
|
||||
<button id="stockWorkflowToggle" type="button" aria-expanded="true">접기</button>
|
||||
</div>
|
||||
</div>
|
||||
<form id="stockSearchForm" class="stock-search-form" autocomplete="off">
|
||||
<label for="stockSearchInput" class="visually-hidden">종목명</label>
|
||||
<input id="stockSearchInput" type="search" maxlength="64" placeholder="예: 삼성" spellcheck="false">
|
||||
<button id="stockSearchButton" type="submit">검색</button>
|
||||
</form>
|
||||
<div id="stockSearchState" class="stock-search-state" role="status" aria-live="polite">종목명을 입력하고 Enter를 누르세요.</div>
|
||||
<div id="stockSearchResults" class="stock-search-results" role="listbox" aria-label="종목 검색 결과"></div>
|
||||
<div id="selectedStock" class="selected-stock" hidden>
|
||||
<span>선택 종목</span>
|
||||
<strong id="selectedStockName">—</strong>
|
||||
<small id="selectedStockMeta">—</small>
|
||||
</div>
|
||||
<div class="stock-cut-header">
|
||||
<div><strong>종목용 컷</strong><small>더블클릭 또는 + 버튼으로 추가</small></div>
|
||||
<label><input id="stockMa5" type="checkbox" checked>5일선</label>
|
||||
<label><input id="stockMa20" type="checkbox" checked>20일선</label>
|
||||
</div>
|
||||
<div id="stockCutList" class="stock-cut-list" aria-label="종목용 컷 목록"></div>
|
||||
<div id="manualStockActions" class="manual-stock-actions" aria-label="수동 재무 데이터"></div>
|
||||
</section>
|
||||
<section id="legacyFixedWorkflow" class="legacy-fixed-workflow" aria-labelledby="legacyFixedWorkflowTitle" hidden>
|
||||
<div class="legacy-fixed-header">
|
||||
<div>
|
||||
<p class="panel-kicker">LEGACY RUNTIME CATALOG</p>
|
||||
<h3 id="legacyFixedWorkflowTitle">원본 운영 항목</h3>
|
||||
</div>
|
||||
<div class="legacy-fixed-tools">
|
||||
<span id="legacyFixedCount" class="badge neutral">0 ACTIONS</span>
|
||||
<button id="legacyFixedToggle" type="button" aria-expanded="true">접기</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="legacy-fixed-search">
|
||||
<span>⌕</span>
|
||||
<input id="legacyFixedSearch" type="search" maxlength="64" autocomplete="off" placeholder="원본 항목 검색">
|
||||
</label>
|
||||
<div id="legacyFixedState" class="legacy-fixed-state" role="status" aria-live="polite"></div>
|
||||
<div id="legacyFixedSections" class="legacy-fixed-sections" aria-label="원본 운영 항목"></div>
|
||||
</section>
|
||||
<section id="industryWorkflow" class="industry-workflow" aria-labelledby="industryWorkflowTitle" hidden>
|
||||
<div class="industry-workflow-header">
|
||||
<div>
|
||||
<p class="panel-kicker">LEGACY INDUSTRY WORKFLOW</p>
|
||||
<h3 id="industryWorkflowTitle">업종 선택</h3>
|
||||
</div>
|
||||
<div class="industry-workflow-tools">
|
||||
<span id="industryCount" class="badge neutral">DB</span>
|
||||
<button id="industryRetry" type="button">다시 조회</button>
|
||||
<button id="industryWorkflowToggle" type="button" aria-expanded="true">접기</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="industry-search">
|
||||
<span>⌕</span>
|
||||
<input id="industrySearch" type="search" maxlength="64" autocomplete="off" placeholder="업종명 검색">
|
||||
</label>
|
||||
<div id="industryState" class="industry-state" role="status" aria-live="polite"></div>
|
||||
<div id="industryResults" class="industry-results" role="listbox" aria-label="업종 목록"></div>
|
||||
<div class="industry-pair" aria-label="업종 비교 선택">
|
||||
<div><span>첫 번째</span><strong id="industryPrimary">선택 없음</strong></div>
|
||||
<button id="industrySwap" type="button" title="두 업종 순서 교환">⇄</button>
|
||||
<div><span>두 번째</span><strong id="industrySecondary">선택 없음</strong></div>
|
||||
<button id="industryClear" type="button">초기화</button>
|
||||
</div>
|
||||
<div id="industryCutList" class="industry-cut-list" aria-label="업종용 컷 목록"></div>
|
||||
</section>
|
||||
<section id="comparisonWorkflow" class="comparison-workflow" aria-labelledby="comparisonWorkflowTitle" hidden>
|
||||
<div class="comparison-workflow-header">
|
||||
<div>
|
||||
<p class="panel-kicker">LEGACY COMPARISON WORKFLOW</p>
|
||||
<h3 id="comparisonWorkflowTitle">종목 비교</h3>
|
||||
</div>
|
||||
<span id="comparisonPairCount" class="badge neutral">0 PAIRS</span>
|
||||
</div>
|
||||
<div id="comparisonSourceTabs" class="comparison-source-tabs" role="tablist" aria-label="비교 대상 종류">
|
||||
<button type="button" class="active" data-comparison-source="domestic">국내·NXT</button>
|
||||
<button type="button" data-comparison-source="fixed">지수·환율</button>
|
||||
<button type="button" data-comparison-source="world">해외 종목</button>
|
||||
</div>
|
||||
<section id="comparisonDomesticPanel" class="comparison-source-panel">
|
||||
<form id="comparisonDomesticSearchForm" class="comparison-search-form" autocomplete="off">
|
||||
<input id="comparisonDomesticSearch" type="search" maxlength="64" placeholder="국내·NXT 종목명" aria-label="국내·NXT 종목명">
|
||||
<button id="comparisonDomesticSearchButton" type="submit">검색</button>
|
||||
</form>
|
||||
<div id="comparisonDomesticState" class="comparison-search-state" role="status" aria-live="polite"></div>
|
||||
<div id="comparisonDomesticResults" class="comparison-target-results" role="listbox" aria-label="국내·NXT 종목 검색 결과"></div>
|
||||
</section>
|
||||
<section id="comparisonFixedPanel" class="comparison-source-panel" hidden>
|
||||
<div id="comparisonFixedTargets" class="comparison-target-results fixed" role="listbox" aria-label="고정 비교 대상"></div>
|
||||
</section>
|
||||
<section id="comparisonWorldPanel" class="comparison-source-panel" hidden>
|
||||
<form id="comparisonWorldSearchForm" class="comparison-search-form" autocomplete="off">
|
||||
<input id="comparisonWorldSearch" type="search" maxlength="64" placeholder="미국·대만 종목명" aria-label="해외 종목명">
|
||||
<button id="comparisonWorldSearchButton" type="submit">검색</button>
|
||||
</form>
|
||||
<div id="comparisonWorldState" class="comparison-search-state" role="status" aria-live="polite"></div>
|
||||
<div id="comparisonWorldResults" class="comparison-target-results" role="listbox" aria-label="해외 종목 검색 결과"></div>
|
||||
</section>
|
||||
<div class="comparison-draft" aria-label="비교쌍 편집">
|
||||
<div><span>첫 번째</span><strong id="comparisonFirst">선택 없음</strong></div>
|
||||
<button id="comparisonSwap" type="button" title="비교 대상 순서 교환">⇄</button>
|
||||
<div><span>두 번째</span><strong id="comparisonSecond">선택 없음</strong></div>
|
||||
<button id="comparisonClear" type="button">초기화</button>
|
||||
<button id="comparisonSavePair" class="primary" type="button">비교쌍 추가</button>
|
||||
</div>
|
||||
<div class="comparison-pair-header">
|
||||
<div><strong>저장 비교쌍</strong><small>행을 선택한 뒤 컷을 추가합니다.</small></div>
|
||||
<div>
|
||||
<button id="comparisonMoveUp" type="button" title="위로">↑</button>
|
||||
<button id="comparisonMoveDown" type="button" title="아래로">↓</button>
|
||||
<button id="comparisonDeletePair" type="button">삭제</button>
|
||||
<button id="comparisonDeleteAll" type="button">전체삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="comparisonPairList" class="comparison-pair-list" role="listbox" aria-label="저장 비교쌍"></div>
|
||||
<div class="comparison-action-header">
|
||||
<div><strong>비교용 컷</strong><small>원본 종목비교.ini 9개 액션</small></div>
|
||||
<button id="comparisonYieldBatch" type="button">수익률 5종 일괄 추가</button>
|
||||
</div>
|
||||
<div id="comparisonActionList" class="comparison-action-list" aria-label="비교용 컷 목록"></div>
|
||||
</section>
|
||||
<section id="themeWorkflow" class="theme-workflow" aria-labelledby="themeWorkflowTitle" hidden>
|
||||
<div class="theme-workflow-header">
|
||||
<div>
|
||||
<p class="panel-kicker">LEGACY THEME WORKFLOW</p>
|
||||
<h3 id="themeWorkflowTitle">테마 선택</h3>
|
||||
</div>
|
||||
<span id="themeCount" class="badge neutral">READ ONLY</span>
|
||||
</div>
|
||||
<form id="themeSearchForm" class="theme-search-form" autocomplete="off">
|
||||
<input id="themeSearch" type="search" maxlength="64" placeholder="테마명 · 빈 값은 전체" aria-label="테마명">
|
||||
<select id="themeNxtSession" aria-label="NXT 세션">
|
||||
<option value="preMarket">NXT 장전</option>
|
||||
<option value="afterMarket">NXT 시간외</option>
|
||||
</select>
|
||||
<button id="themeSearchButton" type="submit">조회</button>
|
||||
</form>
|
||||
<div id="themeSearchState" class="theme-search-state" role="status" aria-live="polite"></div>
|
||||
<div id="themeResults" class="theme-results" role="listbox" aria-label="테마 검색 결과"></div>
|
||||
<div id="selectedTheme" class="selected-theme" hidden>
|
||||
<div><span>선택 테마</span><strong id="selectedThemeTitle">—</strong><small id="selectedThemeMeta">—</small></div>
|
||||
<label>정렬
|
||||
<select id="themeSort">
|
||||
<option value="INPUT_ORDER">입력순</option>
|
||||
<option value="GAIN_DESC">상승률순</option>
|
||||
<option value="GAIN_ASC">하락률순</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="theme-preview-header">
|
||||
<div><strong>테마 종목</strong><small>송출 전 read-only 미리보기</small></div>
|
||||
<span id="themePreviewCount">0 ITEMS</span>
|
||||
</div>
|
||||
<div id="themePreviewState" class="theme-preview-state" role="status" aria-live="polite"></div>
|
||||
<div id="themePreviewItems" class="theme-preview-items" aria-label="테마 종목 미리보기"></div>
|
||||
<div class="theme-action-header"><strong>테마용 컷</strong><small>5·6·12개 단위 · 최대 20페이지</small></div>
|
||||
<div id="themeActionList" class="theme-action-list" aria-label="테마용 컷 목록"></div>
|
||||
<div class="theme-crud-notice">
|
||||
<span>ThemeA · 트랜잭션 기반 추가·편집·삭제</span>
|
||||
<button id="themeCatalogManageButton" type="button">테마 관리</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="overseasWorkflow" class="overseas-workflow" aria-labelledby="overseasWorkflowTitle" hidden>
|
||||
<div class="overseas-workflow-header">
|
||||
<div>
|
||||
<p class="panel-kicker">LEGACY FOREIGN INSTRUMENT WORKFLOW</p>
|
||||
<h3 id="overseasWorkflowTitle">해외업종·해외종목</h3>
|
||||
</div>
|
||||
<span id="overseasCount" class="badge neutral">ORACLE</span>
|
||||
</div>
|
||||
<div id="overseasSourceTabs" class="overseas-source-tabs" role="tablist" aria-label="해외 검색 종류">
|
||||
<button type="button" class="active" data-overseas-source="industry">미국 해외업종</button>
|
||||
<button type="button" data-overseas-source="stock">US·TW 해외종목</button>
|
||||
</div>
|
||||
<form id="overseasSearchForm" class="overseas-search-form" autocomplete="off">
|
||||
<input id="overseasSearch" type="search" maxlength="64" placeholder="빈 값은 전체 목록" aria-label="해외업종 또는 해외종목 검색어">
|
||||
<button id="overseasSearchButton" type="submit">조회</button>
|
||||
</form>
|
||||
<div id="overseasSearchState" class="overseas-search-state" role="status" aria-live="polite"></div>
|
||||
<div id="overseasResults" class="overseas-results" role="listbox" aria-label="해외업종·해외종목 검색 결과"></div>
|
||||
<div id="selectedOverseas" class="selected-overseas" hidden>
|
||||
<div>
|
||||
<span>선택 대상</span>
|
||||
<strong id="selectedOverseasName">-</strong>
|
||||
<small id="selectedOverseasMeta">-</small>
|
||||
</div>
|
||||
<div class="overseas-ma-options" aria-label="해외종목 캔들 이동평균선">
|
||||
<label><input id="overseasMa5" type="checkbox" checked>5일선</label>
|
||||
<label><input id="overseasMa20" type="checkbox" checked>20일선</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overseas-action-header"><strong>해외 대상 컷</strong><small>업종 1개 · 종목 6개 닫힌 동작</small></div>
|
||||
<div id="overseasActionList" class="overseas-action-list" aria-label="해외 대상 컷 목록"></div>
|
||||
</section>
|
||||
<section id="expertWorkflow" class="expert-workflow" aria-labelledby="expertWorkflowTitle" hidden>
|
||||
<div class="expert-workflow-header">
|
||||
<div>
|
||||
<p class="panel-kicker">LEGACY EXPERT WORKFLOW</p>
|
||||
<h3 id="expertWorkflowTitle">전문가 추천</h3>
|
||||
</div>
|
||||
<span id="expertCount" class="badge neutral">READ ONLY</span>
|
||||
</div>
|
||||
<form id="expertSearchForm" class="expert-search-form" autocomplete="off">
|
||||
<input id="expertSearch" type="search" maxlength="64" placeholder="전문가명 · 빈 값은 전체" aria-label="전문가명">
|
||||
<button id="expertSearchButton" type="submit">조회</button>
|
||||
</form>
|
||||
<div id="expertSearchState" class="expert-search-state" role="status" aria-live="polite"></div>
|
||||
<div id="expertResults" class="expert-results" role="listbox" aria-label="전문가 검색 결과"></div>
|
||||
<div id="selectedExpert" class="selected-expert" hidden>
|
||||
<span>선택 전문가</span>
|
||||
<strong id="selectedExpertName">-</strong>
|
||||
<small id="selectedExpertMeta">-</small>
|
||||
</div>
|
||||
<div class="expert-preview-header">
|
||||
<div><strong>추천 종목</strong><small>PLAYINDEX 순서 · read-only 미리보기</small></div>
|
||||
<span id="expertPreviewCount">0 ITEMS</span>
|
||||
</div>
|
||||
<div id="expertPreviewState" class="expert-preview-state" role="status" aria-live="polite"></div>
|
||||
<div id="expertPreviewItems" class="expert-preview-items" aria-label="전문가 추천 종목 미리보기"></div>
|
||||
<div class="expert-action-header"><strong>전문가 추천 컷</strong><small>5개 단위 · 최대 20페이지</small></div>
|
||||
<div id="expertActionList" class="expert-action-list" aria-label="전문가 추천 컷 목록"></div>
|
||||
<div class="expert-crud-notice">
|
||||
<span>EList · 전문가·추천종목 추가·편집·삭제</span>
|
||||
<button id="expertCatalogManageButton" type="button">전문가 관리</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="tradingHaltWorkflow" class="trading-halt-workflow" aria-labelledby="tradingHaltWorkflowTitle" hidden>
|
||||
<div class="trading-halt-workflow-header">
|
||||
<div>
|
||||
<p class="panel-kicker">LEGACY TRADING HALT WORKFLOW</p>
|
||||
<h3 id="tradingHaltWorkflowTitle">거래 정지 종목</h3>
|
||||
</div>
|
||||
<span id="tradingHaltCount" class="badge neutral">DB</span>
|
||||
</div>
|
||||
<form id="tradingHaltSearchForm" class="trading-halt-search-form" autocomplete="off">
|
||||
<input id="tradingHaltSearch" type="search" maxlength="64" placeholder="종목명 · 빈 값은 전체" aria-label="거래 정지 종목명">
|
||||
<button id="tradingHaltSearchButton" type="submit">조회</button>
|
||||
</form>
|
||||
<div id="tradingHaltSearchState" class="trading-halt-search-state" role="status" aria-live="polite"></div>
|
||||
<div id="tradingHaltResults" class="trading-halt-results" role="listbox" aria-label="거래 정지 종목 검색 결과"></div>
|
||||
<div id="selectedTradingHalt" class="selected-trading-halt" hidden>
|
||||
<div>
|
||||
<span>선택 종목</span>
|
||||
<strong id="selectedTradingHaltName">-</strong>
|
||||
<small id="selectedTradingHaltMeta">-</small>
|
||||
</div>
|
||||
<div class="trading-halt-ma-options" aria-label="캔들 이동평균선">
|
||||
<label><input id="tradingHaltMa5" type="checkbox" checked>5일선</label>
|
||||
<label><input id="tradingHaltMa20" type="checkbox" checked>20일선</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trading-halt-action-header">
|
||||
<strong>거래 정지 컷</strong>
|
||||
<small>원본 UC7 · 현재가와 120일 캔들</small>
|
||||
</div>
|
||||
<div id="tradingHaltActionList" class="trading-halt-action-list" aria-label="거래 정지 컷 목록"></div>
|
||||
</section>
|
||||
<label class="search-box">
|
||||
<span>⌕</span>
|
||||
<input id="catalogSearch" type="search" autocomplete="off" placeholder="종목·지수·그래픽 검색">
|
||||
<input id="catalogSearch" type="search" autocomplete="off" placeholder="장면 코드·그래픽 검색">
|
||||
<kbd>Ctrl K</kbd>
|
||||
</label>
|
||||
<div id="catalogTabs" class="catalog-tabs" role="tablist">
|
||||
@@ -96,17 +366,64 @@
|
||||
<span id="playlistCount" class="badge neutral">0 CUTS</span>
|
||||
</div>
|
||||
<div class="playlist-actions">
|
||||
<button id="selectAllButton" type="button">전체 선택</button>
|
||||
<button id="toggleAllEnabledButton" type="button">전체 송출</button>
|
||||
<button id="selectAllButton" type="button">작업 전체선택</button>
|
||||
<button id="moveUpButton" type="button" title="선택 항목 위로">↑</button>
|
||||
<button id="moveDownButton" type="button" title="선택 항목 아래로">↓</button>
|
||||
<button id="deleteButton" class="danger" type="button">삭제</button>
|
||||
<span></span>
|
||||
<button id="loadButton" type="button">불러오기</button>
|
||||
<button id="saveButton" class="primary-soft" type="button">저장</button>
|
||||
<button id="deleteAllButton" class="danger" type="button">전체삭제</button>
|
||||
<div class="global-candle-options" aria-label="전체 캔들 이동평균선">
|
||||
<span>캔들</span>
|
||||
<label><input id="globalMa5" type="checkbox" checked>5일선</label>
|
||||
<label><input id="globalMa20" type="checkbox" checked>20일선</label>
|
||||
</div>
|
||||
<button id="namedPlaylistOpenButton" class="database-soft" type="button" aria-expanded="false" aria-controls="namedPlaylistPanel">DB 저장·불러오기</button>
|
||||
<button id="loadButton" type="button">로컬 불러오기</button>
|
||||
<button id="saveButton" class="primary-soft" type="button">로컬 임시저장</button>
|
||||
</div>
|
||||
<section id="namedPlaylistPanel" class="named-playlist-panel" aria-labelledby="namedPlaylistTitle" hidden>
|
||||
<header class="named-playlist-header">
|
||||
<div>
|
||||
<p class="panel-kicker">ORACLE · DC_LIST / PLAY_LIST</p>
|
||||
<h3 id="namedPlaylistTitle">DB 플레이리스트</h3>
|
||||
</div>
|
||||
<div class="named-playlist-header-actions">
|
||||
<span id="namedPlaylistStateBadge" class="badge neutral">대기</span>
|
||||
<button id="namedPlaylistRefreshButton" type="button">목록 새로고침</button>
|
||||
<button id="namedPlaylistCloseButton" type="button" aria-label="DB 플레이리스트 닫기">닫기</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="namedPlaylistStatus" class="named-playlist-status" role="status" aria-live="polite">
|
||||
Oracle에 저장된 이름 목록을 조회하세요. 로컬 임시저장과는 별도입니다.
|
||||
</div>
|
||||
<div class="named-playlist-layout">
|
||||
<label class="named-playlist-definitions-label" for="namedPlaylistDefinitions">저장된 이름</label>
|
||||
<select id="namedPlaylistDefinitions" class="named-playlist-definitions" size="6" aria-describedby="namedPlaylistSelectionSummary"></select>
|
||||
<div id="namedPlaylistSelectionSummary" class="named-playlist-selection-summary">선택된 DB 정의 없음</div>
|
||||
<div class="named-playlist-load-actions">
|
||||
<button id="namedPlaylistLoadButton" class="primary-soft" type="button">선택 DB 불러오기</button>
|
||||
<button id="namedPlaylistPageRetryButton" type="button" hidden>페이지 다시 조회</button>
|
||||
<button id="namedPlaylistRestoreBackupButton" type="button">불러오기 전 상태 복원</button>
|
||||
</div>
|
||||
<div class="named-playlist-create">
|
||||
<label for="namedPlaylistProgramCode">새 코드</label>
|
||||
<input id="namedPlaylistProgramCode" type="text" inputmode="numeric" maxlength="8" autocomplete="off" placeholder="00000001">
|
||||
<label for="namedPlaylistProgramTitle">새 이름</label>
|
||||
<input id="namedPlaylistProgramTitle" type="text" maxlength="128" autocomplete="off" placeholder="예: 오전 방송">
|
||||
<button id="namedPlaylistCreateButton" type="button">새 이름 만들기</button>
|
||||
</div>
|
||||
<div class="named-playlist-mutation-actions">
|
||||
<button id="namedPlaylistReplaceButton" class="primary-soft" type="button">선택 정의에 현재 순서 저장</button>
|
||||
<button id="namedPlaylistDeleteButton" class="danger" type="button">선택 정의 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="namedPlaylistGuardSummary" class="named-playlist-guard-summary">
|
||||
DB에서 읽은 각 행은 신뢰 복원과 현재 페이지 재계산을 통과해야 PREPARE할 수 있습니다.
|
||||
</p>
|
||||
</section>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th class="check-cell"></th><th class="number-cell">#</th><th>그래픽 컷</th><th>구분</th><th class="drag-cell">정렬</th></tr></thead>
|
||||
<thead><tr><th class="check-cell">송출</th><th class="number-cell">#</th><th>종목·대상</th><th>컷</th><th>세부사항</th><th class="page-cell">Page</th><th class="drag-cell">정렬</th></tr></thead>
|
||||
<tbody id="playlistBody"></tbody>
|
||||
</table>
|
||||
<div id="playlistEmpty" class="empty-state">
|
||||
@@ -137,6 +454,15 @@
|
||||
<strong id="playoutSafetyMode">확인 중</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="background-control-bar" aria-label="장면 배경 설정">
|
||||
<div>
|
||||
<small>BACKGROUND</small>
|
||||
<strong id="backgroundFileName">배경 없음</strong>
|
||||
<span id="backgroundStateText">원본 F2/F3 배경 상태 확인 중</span>
|
||||
</div>
|
||||
<button id="chooseBackgroundButton" type="button">배경 파일 (F2)</button>
|
||||
<button id="toggleBackgroundButton" type="button">배경 켜기/끄기 (F3)</button>
|
||||
</div>
|
||||
<div id="playoutStatusMessage" class="playout-status-message" role="status" aria-live="polite">송출 어댑터 상태를 확인하고 있습니다.</div>
|
||||
<div class="playout-page-state" aria-label="장면 및 페이지 상태">
|
||||
<span>SCENE <strong id="playoutSceneCode">—</strong></span>
|
||||
@@ -191,7 +517,7 @@
|
||||
</datalist>
|
||||
</form>
|
||||
<div class="playout-controls">
|
||||
<button id="prepareButton" class="prepare" type="button"><span>F2</span>PREPARE</button>
|
||||
<button id="prepareButton" class="prepare" type="button"><span>PREP</span>PREPARE</button>
|
||||
<button id="takeInButton" class="take-in" type="button"><span>F8</span>TAKE IN</button>
|
||||
<button id="nextButton" type="button"><span>→</span>NEXT</button>
|
||||
<button id="takeOutButton" class="take-out" type="button"><span>ESC</span>TAKE OUT</button>
|
||||
@@ -207,6 +533,23 @@
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="playout-safety.js"></script>
|
||||
<script src="operator-workflow.js"></script>
|
||||
<script src="legacy-fixed-workflow.js"></script>
|
||||
<script src="industry-workflow.js"></script>
|
||||
<script src="comparison-workflow.js"></script>
|
||||
<script src="theme-workflow.js"></script>
|
||||
<script src="overseas-workflow.js"></script>
|
||||
<script src="expert-workflow.js"></script>
|
||||
<script src="trading-halt-workflow.js"></script>
|
||||
<script src="candle-options-workflow.js"></script>
|
||||
<script src="manual-lists-workflow.js"></script>
|
||||
<script src="manual-financial-workflow.js"></script>
|
||||
<script src="named-manual-restore-workflow.js"></script>
|
||||
<script src="operator-catalog-workflow.js"></script>
|
||||
<script src="manual-lists-ui.js"></script>
|
||||
<script src="manual-financial-ui.js"></script>
|
||||
<script src="operator-catalog-ui.js"></script>
|
||||
<script src="named-playlist-workflow.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
326
Web/industry-workflow.js
Normal file
326
Web/industry-workflow.js
Normal file
@@ -0,0 +1,326 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnIndustryWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_FADE_DURATION = 6;
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const codePattern = /^[A-Za-z0-9]{1,32}$/;
|
||||
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
|
||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const markets = Object.freeze(["kospi", "kosdaq"]);
|
||||
const runtimeAssets = Object.freeze({
|
||||
kospi: Object.freeze({
|
||||
relativePath: "bin/Debug/Res/업종_코스피.ini",
|
||||
sha256: "F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52"
|
||||
}),
|
||||
kosdaq: Object.freeze({
|
||||
relativePath: "bin/Debug/Res/업종_코스닥.ini",
|
||||
sha256: "F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4"
|
||||
})
|
||||
});
|
||||
|
||||
const periodCuts = [
|
||||
["5일", "8061", "FiveDays"], ["20일", "8040", "TwentyDays"],
|
||||
["60일", "8046", "SixtyDays"], ["120일", "8051", "OneHundredTwentyDays"],
|
||||
["240일", "8056", "TwoHundredFortyDays"]
|
||||
];
|
||||
const candleCuts = [["일봉", "8035"], ...periodCuts.map(([label, code]) => [label, code])];
|
||||
|
||||
function cut(id, label, section, kind, details = {}) {
|
||||
return Object.freeze({ id, label, section, kind, ...details });
|
||||
}
|
||||
|
||||
const cuts = Object.freeze([
|
||||
cut("one-column", "1열판", "업종 판", "single", { builderKey: "s5001" }),
|
||||
cut("two-column", "2열판", "업종 판", "pair", { builderKey: "s8018" }),
|
||||
...periodCuts.map(([label, , period]) =>
|
||||
cut(`yield-${label}`, `수익률그래프_${label}`, "수익률 그래프", "yield", {
|
||||
builderKey: "s5086", period
|
||||
})),
|
||||
...candleCuts.map(([label, code]) =>
|
||||
cut(`candle-${label}`, `캔들그래프_${label}`, "캔들 그래프", "candle", {
|
||||
builderKey: "s8010", code, mode: "PRICE"
|
||||
})),
|
||||
...candleCuts.map(([label, code]) =>
|
||||
cut(`candle-volume-${label}`, `캔들그래프(거래량)_${label}`, "캔들 그래프 · 거래량", "candle", {
|
||||
builderKey: "s8010", code, mode: "VOLUME"
|
||||
})),
|
||||
cut("five-row", "5단 표그래프", "시장 전체", "fixed", { builderKey: "s5074" }),
|
||||
cut("square", "네모그래프", "시장 전체", "fixed", { builderKey: "s8001" }),
|
||||
cut("sector", "섹터지수", "시장 전체", "fixed", { builderKey: "s5078" })
|
||||
]);
|
||||
if (cuts.length !== 22 || new Set(cuts.map(value => value.id)).size !== 22) {
|
||||
throw new Error("Each legacy industry tab must contain exactly 22 cut actions.");
|
||||
}
|
||||
const cutsById = new Map(cuts.map(value => [value.id, value]));
|
||||
|
||||
function cleanText(value, maximumLength = 128) {
|
||||
if (typeof value !== "string") return null;
|
||||
const text = value.trim();
|
||||
return text && text.length <= maximumLength && !controlCharacterPattern.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function normalizeMarket(value) {
|
||||
const text = String(value || "").trim().toLocaleLowerCase("en-US");
|
||||
return markets.includes(text) ? text : null;
|
||||
}
|
||||
|
||||
function normalizeIndustry(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const market = normalizeMarket(value.market);
|
||||
const name = cleanText(value.name ?? value.industryName);
|
||||
const code = cleanText(value.code ?? value.industryCode, 32);
|
||||
if (!market || !name || !code || !codePattern.test(code)) return null;
|
||||
return Object.freeze({ market, name, code });
|
||||
}
|
||||
|
||||
function localDateText(now = new Date()) {
|
||||
const year = String(now.getFullYear()).padStart(4, "0");
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new TypeError("Industry playlist options are required.");
|
||||
}
|
||||
const id = cleanText(options.id);
|
||||
if (!id || !identifierPattern.test(id)) throw new TypeError("A safe industry playlist id is required.");
|
||||
const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : Number(options.fadeDuration);
|
||||
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
||||
throw new RangeError("Fade duration must be an integer from 0 through 60.");
|
||||
}
|
||||
const now = options.now instanceof Date && !Number.isNaN(options.now.valueOf()) ? options.now : new Date();
|
||||
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 { id, fadeDuration, now, ma5, ma20 };
|
||||
}
|
||||
|
||||
function mappingFor(market, selectedCut, selected, primary, secondary, now, movingAverages) {
|
||||
const industryGroup = market === "kospi" ? "INDUSTRY_KOSPI" : "INDUSTRY_KOSDAQ";
|
||||
const candleGroup = market === "kospi" ? "KOSPI_INDUSTRY" : "KOSDAQ_INDUSTRY";
|
||||
const pagedGroup = market === "kospi" ? "PAGED_DOMESTIC_KOSPI" : "PAGED_DOMESTIC_KOSDAQ";
|
||||
const marketName = market === "kospi" ? "KOSPI" : "KOSDAQ";
|
||||
switch (selectedCut.kind) {
|
||||
case "single":
|
||||
if (!selected) throw new RangeError("Select one industry first.");
|
||||
return {
|
||||
code: "5001",
|
||||
selection: {
|
||||
groupCode: industryGroup, subject: selected.name,
|
||||
graphicType: "1열판", subtype: "CURRENT", dataCode: ""
|
||||
}
|
||||
};
|
||||
case "pair":
|
||||
if (!primary || !secondary || primary.name.includes("-") || secondary.name.includes("-")) {
|
||||
throw new RangeError("Select two delimiter-safe industries first.");
|
||||
}
|
||||
return {
|
||||
code: market === "kospi" ? "8018" : "8032",
|
||||
selection: {
|
||||
groupCode: industryGroup, subject: `${primary.name}-${secondary.name}`,
|
||||
graphicType: "2열판", subtype: "", dataCode: ""
|
||||
}
|
||||
};
|
||||
case "yield":
|
||||
if (!selected) throw new RangeError("Select one industry first.");
|
||||
return {
|
||||
code: "5086",
|
||||
selection: {
|
||||
groupCode: industryGroup, subject: selected.name,
|
||||
graphicType: "YIELD_GRAPH", subtype: selectedCut.period, dataCode: ""
|
||||
}
|
||||
};
|
||||
case "candle":
|
||||
if (!selected) throw new RangeError("Select one industry first.");
|
||||
const flags = [];
|
||||
if (movingAverages.ma5) flags.push("MA5");
|
||||
if (movingAverages.ma20) flags.push("MA20");
|
||||
return {
|
||||
code: selectedCut.code,
|
||||
selection: {
|
||||
groupCode: candleGroup, subject: selected.name,
|
||||
graphicType: flags.join(","), subtype: selectedCut.mode, dataCode: ""
|
||||
}
|
||||
};
|
||||
case "fixed":
|
||||
if (selectedCut.id === "five-row") {
|
||||
return {
|
||||
code: "5074",
|
||||
selection: {
|
||||
groupCode: pagedGroup, subject: "INDEX",
|
||||
graphicType: "", subtype: "INDUSTRY", dataCode: localDateText(now)
|
||||
}
|
||||
};
|
||||
}
|
||||
if (selectedCut.id === "square") {
|
||||
return {
|
||||
code: market === "kospi" ? "8001" : "8002",
|
||||
selection: {
|
||||
groupCode: industryGroup, subject: marketName,
|
||||
graphicType: "네모그래프", subtype: "", dataCode: ""
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: "5078",
|
||||
selection: {
|
||||
groupCode: industryGroup, subject: marketName,
|
||||
graphicType: "SECTOR_INDEX", subtype: "", dataCode: ""
|
||||
}
|
||||
};
|
||||
default:
|
||||
throw new Error("The selected industry cut is unsupported.");
|
||||
}
|
||||
}
|
||||
|
||||
function isCutAllowed(marketValue, cutId, selectedValue, primaryValue, secondaryValue) {
|
||||
const market = normalizeMarket(marketValue);
|
||||
const selectedCut = cutsById.get(cutId);
|
||||
const selected = normalizeIndustry(selectedValue);
|
||||
const primary = normalizeIndustry(primaryValue);
|
||||
const secondary = normalizeIndustry(secondaryValue);
|
||||
if (!market || !selectedCut) return false;
|
||||
if (selected && selected.market !== market) return false;
|
||||
if (primary && primary.market !== market) return false;
|
||||
if (secondary && secondary.market !== market) return false;
|
||||
if (selectedCut.kind === "fixed") return true;
|
||||
if (selectedCut.kind === "pair") {
|
||||
return Boolean(primary && secondary && !primary.name.includes("-") && !secondary.name.includes("-"));
|
||||
}
|
||||
return Boolean(selected);
|
||||
}
|
||||
|
||||
function createIndustryPlaylistEntry(marketValue, cutId, options) {
|
||||
const market = normalizeMarket(marketValue);
|
||||
const selectedCut = cutsById.get(cutId);
|
||||
if (!market || !selectedCut) throw new RangeError("The industry action is unknown.");
|
||||
const selected = normalizeIndustry(options?.selected);
|
||||
const pair = Array.isArray(options?.pair) ? options.pair : [];
|
||||
const primary = normalizeIndustry(pair[0]);
|
||||
const secondary = normalizeIndustry(pair[1]);
|
||||
if ((selected && selected.market !== market) || (primary && primary.market !== market) ||
|
||||
(secondary && secondary.market !== market)) {
|
||||
throw new RangeError("The selected industry market is inconsistent.");
|
||||
}
|
||||
if (!isCutAllowed(market, cutId, selected, primary, secondary)) {
|
||||
throw new RangeError(selectedCut.kind === "pair" ? "Select two industries first." : "Select one industry first.");
|
||||
}
|
||||
const normalized = requireOptions(options);
|
||||
const movingAverages = Object.freeze({
|
||||
ma5: selectedCut.kind === "candle" && normalized.ma5,
|
||||
ma20: selectedCut.kind === "candle" && normalized.ma20
|
||||
});
|
||||
const mapping = mappingFor(
|
||||
market,
|
||||
selectedCut,
|
||||
selected,
|
||||
primary,
|
||||
secondary,
|
||||
normalized.now,
|
||||
movingAverages);
|
||||
const selection = Object.freeze(mapping.selection);
|
||||
return {
|
||||
id: normalized.id,
|
||||
builderKey: selectedCut.builderKey,
|
||||
code: mapping.code,
|
||||
aliases: [mapping.code],
|
||||
title: selectedCut.kind === "fixed"
|
||||
? `${market === "kospi" ? "코스피" : "코스닥"} ${selectedCut.label}`
|
||||
: (selectedCut.kind === "pair" ? `${primary.name}-${secondary.name}` : selected.name),
|
||||
detail: selectedCut.label,
|
||||
category: selectedCut.kind === "candle" || selectedCut.kind === "yield" ? "chart" : "plate",
|
||||
market,
|
||||
selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalized.fadeDuration,
|
||||
graphicType: selection.graphicType,
|
||||
subtype: selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-industry-workflow",
|
||||
cutId: selectedCut.id,
|
||||
market,
|
||||
...(selectedCut.kind === "pair" ? { pair: Object.freeze([primary, secondary]) } : {}),
|
||||
...(selectedCut.kind !== "pair" && selectedCut.kind !== "fixed" ? { selected } : {}),
|
||||
assetPath: runtimeAssets[market].relativePath,
|
||||
assetSha256: runtimeAssets[market].sha256,
|
||||
movingAverages
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function sameSelection(left, right, allowDynamicDate) {
|
||||
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
|
||||
return ["groupCode", "subject", "graphicType", "subtype", "dataCode"].every(key => {
|
||||
if (allowDynamicDate && key === "dataCode") return typeof left[key] === "string" && datePattern.test(left[key]);
|
||||
return typeof left[key] === "string" && left[key] === right[key];
|
||||
});
|
||||
}
|
||||
|
||||
function restoreIndustryPlaylistEntry(value, options = {}) {
|
||||
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-industry-workflow") return null;
|
||||
const market = normalizeMarket(operator.market);
|
||||
const selectedCut = cutsById.get(operator.cutId);
|
||||
if (!market || !selectedCut || operator.assetPath !== runtimeAssets[market].relativePath ||
|
||||
operator.assetSha256 !== runtimeAssets[market].sha256) return null;
|
||||
let movingAverages = operator.movingAverages;
|
||||
if (movingAverages === undefined) {
|
||||
if (selectedCut.kind === "candle") {
|
||||
const graphicType = value?.selection?.graphicType;
|
||||
if (!["", "MA5", "MA20", "MA5,MA20"].includes(graphicType)) return null;
|
||||
movingAverages = {
|
||||
ma5: graphicType === "MA5" || graphicType === "MA5,MA20",
|
||||
ma20: graphicType === "MA20" || graphicType === "MA5,MA20"
|
||||
};
|
||||
} else {
|
||||
movingAverages = { ma5: false, ma20: false };
|
||||
}
|
||||
}
|
||||
if (!movingAverages || typeof movingAverages !== "object" || Array.isArray(movingAverages) ||
|
||||
typeof movingAverages.ma5 !== "boolean" || typeof movingAverages.ma20 !== "boolean" ||
|
||||
Object.keys(movingAverages).length !== 2 ||
|
||||
(selectedCut.kind !== "candle" && (movingAverages.ma5 || movingAverages.ma20))) return null;
|
||||
let recreated;
|
||||
try {
|
||||
recreated = createIndustryPlaylistEntry(market, selectedCut.id, {
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration,
|
||||
selected: operator.selected,
|
||||
pair: operator.pair,
|
||||
now: options.now,
|
||||
ma5: movingAverages.ma5,
|
||||
ma20: movingAverages.ma20
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const scalars = ["id", "builderKey", "code", "title", "detail", "category", "market", "fadeDuration"];
|
||||
if (!scalars.every(key => value[key] === recreated[key]) ||
|
||||
!Array.isArray(value.aliases) || value.aliases.length !== 1 || value.aliases[0] !== recreated.code ||
|
||||
!sameSelection(value.selection, recreated.selection, selectedCut.id === "five-row")) return null;
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
markets,
|
||||
runtimeAssets,
|
||||
cuts,
|
||||
normalizeIndustry,
|
||||
isCutAllowed,
|
||||
localDateText,
|
||||
createIndustryPlaylistEntry,
|
||||
restoreIndustryPlaylistEntry,
|
||||
refreshIndustryPlaylistEntry: restoreIndustryPlaylistEntry
|
||||
};
|
||||
});
|
||||
527
Web/legacy-fixed-workflow.js
Normal file
527
Web/legacy-fixed-workflow.js
Normal file
@@ -0,0 +1,527 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnLegacyFixedWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_FADE_DURATION = 6;
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
|
||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const fixedMarkets = Object.freeze(["overseas", "exchange", "index"]);
|
||||
const runtimeAssets = Object.freeze({
|
||||
overseas: Object.freeze({
|
||||
relativePath: "bin/Debug/Res/해외.ini",
|
||||
sha256: "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"
|
||||
}),
|
||||
exchange: Object.freeze({
|
||||
relativePath: "bin/Debug/Res/환율.ini",
|
||||
sha256: "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"
|
||||
}),
|
||||
index: Object.freeze({
|
||||
relativePath: "bin/Debug/Res/지수.ini",
|
||||
sha256: "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6"
|
||||
})
|
||||
});
|
||||
|
||||
const rawActions = [];
|
||||
|
||||
function cloneSelection(value) {
|
||||
return {
|
||||
groupCode: String(value.groupCode || ""),
|
||||
subject: String(value.subject || ""),
|
||||
graphicType: String(value.graphicType || ""),
|
||||
subtype: String(value.subtype || ""),
|
||||
dataCode: String(value.dataCode || "")
|
||||
};
|
||||
}
|
||||
|
||||
function addAction(market, section, label, definition) {
|
||||
const ordinal = rawActions.length + 1;
|
||||
rawActions.push({
|
||||
id: `fixed-${String(ordinal).padStart(3, "0")}`,
|
||||
market,
|
||||
section,
|
||||
label,
|
||||
detail: definition.detail || section,
|
||||
category: definition.category || "market",
|
||||
builderKey: definition.builderKey,
|
||||
code: definition.code,
|
||||
aliases: definition.aliases || [definition.code],
|
||||
available: definition.available !== false,
|
||||
prerequisite: definition.prerequisite || "",
|
||||
selection: cloneSelection(definition.selection || {}),
|
||||
dynamicDate: definition.dynamicDate === true,
|
||||
dynamicNxtSession: definition.dynamicNxtSession === true
|
||||
});
|
||||
}
|
||||
|
||||
const foreignIndices = [
|
||||
["다우", "Dow"], ["나스닥", "Nasdaq"], ["S&P", "Sp500"],
|
||||
["독일", "GermanyDax"], ["영국", "UnitedKingdomFtse"], ["프랑스", "FranceCac"],
|
||||
["니케이", "Nikkei"], ["중국 상해", "ShanghaiComposite"], ["홍콩 항셍", "HangSeng"],
|
||||
["대만 가권", "TaiwanWeighted"], ["싱가포르 지수", "SingaporeStraitsTimes"],
|
||||
["태국 지수", "ThailandSet"], ["필리핀 지수", "PhilippinesComposite"],
|
||||
["말레이시아 지수", "MalaysiaKlse"], ["인도네시아 지수", "IndonesiaComposite"]
|
||||
];
|
||||
const commodities = [
|
||||
["소맥", "FeedWheat"], ["옥수수", "Corn"], ["밀", "Wheat"], ["대두", "Soybean"],
|
||||
["콩", "Bean"], ["현미", "BrownRice"], ["커피", "Coffee"], ["코코아", "Cocoa"],
|
||||
["설탕", "Sugar"], ["생우(소)", "LiveCattle"], ["비육우", "FeederCattle"],
|
||||
["돈육(돼지)", "LeanHogs"], ["구리", "Copper"], ["철광석", "IronOre"],
|
||||
["니켈", "Nickel"], ["천연가스", "NaturalGas"], ["백금", "Platinum"],
|
||||
["팔라듐", "Palladium"], ["납", "Lead"], ["아연", "Zinc"], ["주석", "Tin"],
|
||||
["원면", "RawCotton"], ["목화(면화)", "Cotton"],
|
||||
["국제 금", "InternationalGold"], ["국제 은", "InternationalSilver"],
|
||||
["국내 금", "DomesticGold"], ["국내 은", "DomesticSilver"]
|
||||
];
|
||||
|
||||
for (const [label, subject] of foreignIndices) {
|
||||
addAction("overseas", "1열판기본", label, {
|
||||
builderKey: "s5001", code: "5001", category: "plate",
|
||||
selection: { groupCode: "FOREIGN_INDEX", subject, graphicType: "1열판기본", subtype: "CURRENT" }
|
||||
});
|
||||
}
|
||||
addAction("overseas", "1열판기본", "BDI 지수", {
|
||||
builderKey: "s5001", code: "5001", category: "plate",
|
||||
selection: { groupCode: "FOREIGN_INDEX", subject: "BALTIC_DRY_INDEX", graphicType: "1열판기본", subtype: "CURRENT" }
|
||||
});
|
||||
for (const [label, subject] of commodities) {
|
||||
addAction("overseas", "1열판기본", label, {
|
||||
builderKey: "s5001", code: "5001", category: "plate",
|
||||
selection: { groupCode: "COMMODITY", subject, graphicType: "1열판기본", subtype: "CURRENT" }
|
||||
});
|
||||
}
|
||||
|
||||
[
|
||||
["농산물[원면, 목화(면화)]", "Cotton"],
|
||||
["원자재[국제 금, 국제 은]", "InternationalPreciousMetals"],
|
||||
["원자재[국내 금, 국내 은]", "DomesticPreciousMetals"]
|
||||
].forEach(([label, subtype]) => addAction("overseas", "2열판-", label, {
|
||||
builderKey: "s50160", code: "50160", category: "plate",
|
||||
selection: { groupCode: "ALL", subject: "INDEX", graphicType: "2열판-", subtype }
|
||||
}));
|
||||
|
||||
[
|
||||
["미국 지수[다우, 나스닥, S&P]", "UnitedStatesIndices"],
|
||||
["중화권 지수[홍콩항셍, 상해종합, 대만]", "GreaterChinaIndices"],
|
||||
["유럽권 지수[영국, 프랑스, 독일]", "EuropeanIndices"],
|
||||
["아시아 지수[상해, 대만, 일본]", "AsianIndices"],
|
||||
["미국국채[2년, 3년, 5년]", "UnitedStatesTreasuries"],
|
||||
["채권금리(CD,CP,콜금리)", "BondYields"],
|
||||
["유가[두바이, 브렌트유, WTI]", "Oil"],
|
||||
["광물[금, 은, 구리]", "Minerals"],
|
||||
["식자재[대두, 옥수수, 밀]", "FoodMaterials"],
|
||||
["농산물[소맥, 옥수수, 밀]", "AgricultureWheatCorn"],
|
||||
["농산물[대두, 콩, 현미]", "AgricultureSoyRice"],
|
||||
["농산물[커피, 코코아, 설탕]", "AgricultureCoffeeCocoaSugar"],
|
||||
["농산물[생우(소), 비육우, 돈육(돼지)]", "AgricultureLivestock"],
|
||||
["원자재[구리, 철광석, 니켈]", "RawMaterialsCopperIronNickel"],
|
||||
["원자재[천연가스, 백금, 팔라듐]", "RawMaterialsGasPlatinumPalladium"],
|
||||
["원자재[납, 아연, 주석]", "RawMaterialsLeadZincTin"]
|
||||
].forEach(([label, subtype]) => addAction("overseas", "3열판", label, {
|
||||
builderKey: "s5016", code: "5016", category: "plate",
|
||||
selection: { groupCode: "ALL", subject: "INDEX", graphicType: "3열판", subtype }
|
||||
}));
|
||||
|
||||
[
|
||||
["글로벌 증시 지도", "8067"], ["아시아 증시 지도", "5072"],
|
||||
["미국 증시 지도", "5068"], ["유럽 증시 지도", "5070"]
|
||||
].forEach(([label, code]) => addAction("overseas", "글로벌 증시 지도", label, {
|
||||
builderKey: "s8067", code, aliases: [code],
|
||||
selection: { groupCode: "ALL", subject: "INDEX", graphicType: "WORLD_MAP", subtype: code }
|
||||
}));
|
||||
|
||||
[
|
||||
["미국 다우 지수", "DowJones"], ["미국 나스닥 지수", "Nasdaq"],
|
||||
["미국 S&P 지수", "StandardAndPoor500"], ["중국 상해 지수", "China"],
|
||||
["일본 닛케이 지수", "Japan"], ["유가 두바이 지수", "DubaiCrude"],
|
||||
["유가 WTI 지수", "WtiCrude"], ["유가 브렌트유 지수", "BrentCrude"],
|
||||
["금 지수", "Gold"]
|
||||
].forEach(([label, subtype]) => addAction("overseas", "이미지 그래프", label, {
|
||||
builderKey: "s6001", code: "6001",
|
||||
selection: { groupCode: "FOREIGN_INDEX", subject: label, graphicType: "IMAGE_GRAPH", subtype }
|
||||
}));
|
||||
|
||||
[["다우", "DOW"], ["나스닥", "NASDAQ"], ["S&P", "S&P500"]]
|
||||
.forEach(([label, subject]) => addAction("overseas", "미국업종", label, {
|
||||
builderKey: "s5078", code: "5078", category: "chart",
|
||||
selection: { groupCode: "FOREIGN_INDEX", subject, graphicType: "US_SECTOR_INDEX", subtype: "" }
|
||||
}));
|
||||
|
||||
const exchangeTargets = [
|
||||
["원달러", "WonDollar"], ["원엔", "WonYen"],
|
||||
["원위엔", "WonYuan"], ["원유로", "WonEuro"]
|
||||
];
|
||||
for (const [label, subject] of exchangeTargets) {
|
||||
addAction("exchange", "1열판기본", label, {
|
||||
builderKey: "s5001", code: "5001", category: "plate",
|
||||
selection: { groupCode: "EXCHANGE_RATE", subject, graphicType: "1열판기본", subtype: "CURRENT" }
|
||||
});
|
||||
}
|
||||
[
|
||||
["해외환율[엔달러, 위안화달러, 유로달러]", "OverseasExchangeRates"],
|
||||
["환율[원달러, 원엔, 원유로]", "DomesticExchangeRates"]
|
||||
].forEach(([label, subtype]) => addAction("exchange", "3열판", label, {
|
||||
builderKey: "s5016", code: "5016", category: "plate",
|
||||
selection: { groupCode: "ALL", subject: "INDEX", graphicType: "3열판", subtype }
|
||||
}));
|
||||
|
||||
const yieldPeriods = [
|
||||
["5일", "FiveDays"], ["20일", "TwentyDays"], ["60일", "SixtyDays"],
|
||||
["120일", "OneHundredTwentyDays"], ["240일", "TwoHundredFortyDays"]
|
||||
];
|
||||
for (const [label, subject] of exchangeTargets) {
|
||||
for (const [periodLabel, subtype] of yieldPeriods) {
|
||||
addAction("exchange", "수익률 그래프", `${label}_${periodLabel}`, {
|
||||
builderKey: "s5086", code: "5086", category: "chart",
|
||||
selection: { groupCode: "EXCHANGE_RATE", subject, graphicType: "YIELD_GRAPH", subtype }
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [label, subject] of exchangeTargets) {
|
||||
for (const [periodLabel, subtype] of yieldPeriods) {
|
||||
addAction("exchange", "라인 그래프", `${label}_${periodLabel}`, {
|
||||
builderKey: "s50860", code: "50860", category: "chart",
|
||||
selection: { groupCode: "EXCHANGE_RATE", subject, graphicType: "LINE_GRAPH", subtype }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[
|
||||
["3열판_주요지수[코스피, 코스닥, 코스피200]", "MajorKospiKosdaqKospi200"],
|
||||
["3열판_주요지수[코스피, 코스닥, 선물]", "MajorKospiKosdaqFutures"],
|
||||
["3열판_예상체결지수[코스피, 코스닥, 코스피200]", "ExpectedKospiKosdaqKospi200"],
|
||||
["3열판_예상체결지수[코스피, 코스닥, 선물]", "ExpectedKospiKosdaqFutures"]
|
||||
].forEach(([label, subtype]) => addAction("index", "주요지수", label, {
|
||||
builderKey: "s5016", code: "5016", category: "plate",
|
||||
selection: { groupCode: "ALL", subject: "INDEX", graphicType: "3열판", subtype }
|
||||
}));
|
||||
|
||||
const candlePeriods = [
|
||||
["일봉", "8035"], ["5일", "8061"], ["20일", "8040"],
|
||||
["60일", "8046"], ["120일", "8051"], ["240일", "8056"]
|
||||
];
|
||||
function addIndexMarket(section, groupCode, candleGroup, options) {
|
||||
addAction("index", section, "1열판기본_예상지수", {
|
||||
builderKey: "s5001", code: "5001", category: "plate",
|
||||
selection: { groupCode, subject: "INDEX", graphicType: "1열판기본", subtype: "EXPECTED_INDEX" }
|
||||
});
|
||||
addAction("index", section, "1열판기본_현재지수", {
|
||||
builderKey: "s5001", code: "5001", category: "plate",
|
||||
selection: { groupCode, subject: "INDEX", graphicType: "1열판기본", subtype: "CURRENT" }
|
||||
});
|
||||
if (options.yield) {
|
||||
for (const [periodLabel, subtype] of yieldPeriods) {
|
||||
addAction("index", section, `수익률그래프_${periodLabel}`, {
|
||||
builderKey: "s5086", code: "5086", category: "chart",
|
||||
selection: { groupCode, subject: "INDEX", graphicType: "YIELD_GRAPH", subtype }
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [periodLabel, code] of candlePeriods) {
|
||||
addAction("index", section, `캔들그래프_${periodLabel}`, {
|
||||
builderKey: "s8010", code, category: "chart",
|
||||
selection: { groupCode: candleGroup, subject: "INDEX", graphicType: "", subtype: "PRICE" }
|
||||
});
|
||||
}
|
||||
if (options.volume) {
|
||||
for (const [periodLabel, code] of candlePeriods) {
|
||||
addAction("index", section, `캔들그래프(거래량)_${periodLabel}`, {
|
||||
builderKey: "s8010", code, category: "chart",
|
||||
selection: { groupCode: candleGroup, subject: "INDEX", graphicType: "", subtype: "VOLUME" }
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options.expected) {
|
||||
for (const [periodLabel, code] of candlePeriods.slice(1)) {
|
||||
addAction("index", section, `캔들그래프(예상지수)_${periodLabel}`, {
|
||||
builderKey: "s8010", code, category: "chart",
|
||||
selection: { groupCode: candleGroup, subject: "INDEX", graphicType: "", subtype: "EXPECTED" }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
addIndexMarket("코스피", "KOSPI", "KOSPI_INDEX", { yield: true, volume: true, expected: true });
|
||||
addIndexMarket("코스닥", "KOSDAQ", "KOSDAQ_INDEX", { yield: true, volume: true, expected: true });
|
||||
addIndexMarket("코스피200", "KOSPI200", "KOSPI200_INDEX", { yield: true, volume: true, expected: true });
|
||||
addIndexMarket("선물", "FUTURES", "FUTURES_INDEX", { yield: false, volume: false, expected: true });
|
||||
addIndexMarket("KRX100", "KRX100", "KRX100_INDEX", { yield: true, volume: true, expected: false });
|
||||
|
||||
function trend(label, builderKey, code, groupCode, graphicType, subtype, available = true) {
|
||||
addAction("index", "매매동향", label, {
|
||||
builderKey, code, available, category: "market",
|
||||
prerequisite: available ? "" : "원본 수동 순매도 데이터 파일과 검증된 입력 화면이 필요합니다.",
|
||||
selection: { groupCode, subject: "INDEX", graphicType, subtype }
|
||||
});
|
||||
}
|
||||
trend("주체별 매매동향_표그래프", "s5082", "5082", "ALL", "TRADING_BY_PARTICIPANT", "TABLE_GRAPH");
|
||||
trend("코스피 매매동향_표그래프", "s5023", "5023", "KOSPI", "KOSPI_TRADING_TREND", "TABLE_GRAPH");
|
||||
trend("코스피 매매동향_라인그래프", "s5084", "5084", "KOSPI", "KOSPI_TRADING_TREND", "LINE_GRAPH");
|
||||
trend("코스피 매매동향_막대그래프", "s5024", "5024", "KOSPI", "KOSPI_TRADING_TREND", "BAR_GRAPH");
|
||||
trend("코스닥 매매동향_표그래프", "s5023", "5023", "KOSDAQ", "KOSDAQ_TRADING_TREND", "TABLE_GRAPH");
|
||||
trend("코스닥 매매동향_라인그래프", "s5084", "5084", "KOSDAQ", "KOSDAQ_TRADING_TREND", "LINE_GRAPH");
|
||||
trend("코스닥 매매동향_막대그래프", "s5024", "5024", "KOSDAQ", "KOSDAQ_TRADING_TREND", "BAR_GRAPH");
|
||||
trend("개인 매매동향_라인그래프", "s5083", "5083", "ALL", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH");
|
||||
trend("외국인 매매동향_라인그래프", "s5083", "5083", "ALL", "FOREIGN_TRADING_TREND", "LINE_GRAPH");
|
||||
trend("기관 매매동향_라인그래프", "s5083", "5083", "ALL", "INSTITUTION_TRADING_TREND", "LINE_GRAPH");
|
||||
trend("기관 순매수 현황_표그래프", "s6067", "6067", "ALL", "INSTITUTION_NET_BUY", "TABLE_GRAPH");
|
||||
trend("프로그램 매매_표그래프", "s5085", "5085", "ALL", "PROGRAM_TRADING", "TABLE_GRAPH");
|
||||
trend("개인 순매도 상위(수동)", "s5025", "5025", "INDIVIDUAL", "MANUAL_NET_SELL", "MANUAL_NET_SELL", false);
|
||||
trend("외국인 순매도 상위(수동)", "s5025", "5025", "FOREIGN", "MANUAL_NET_SELL", "MANUAL_NET_SELL", false);
|
||||
trend("기관 순매도 상위(수동)", "s5025", "5025", "INSTITUTION", "MANUAL_NET_SELL", "MANUAL_NET_SELL", false);
|
||||
|
||||
const fiveRows = [
|
||||
"코스피 시가총액", "코스피 예상가 시가총액 상위", "코스피 상승률 상위", "코스피 하락률 상위",
|
||||
"코스피 시간외 상승률 상위", "코스피 시간외 하락률 상위",
|
||||
"코스닥 시가총액", "코스닥 예상가 시가총액 상위", "코스닥 상승률 상위", "코스닥 하락률 상위",
|
||||
"코스닥 시간외 상승률 상위", "코스닥 시간외 하락률 상위", "코스피 200 종목", "ETF 종목", "VI 발동(수동)",
|
||||
"코스피_NXT 시가총액", "코스피_NXT 상승률 상위", "코스피_NXT 하락률 상위",
|
||||
"코스피_NXT 시간외 상승률 상위", "코스피_NXT 시간외 하락률 상위", "코스피_NXT 거래량 상위",
|
||||
"코스닥_NXT 시가총액", "코스닥_NXT 상승률 상위", "코스닥_NXT 하락률 상위",
|
||||
"코스닥_NXT 시간외 상승률 상위", "코스닥_NXT 시간외 하락률 상위", "코스닥_NXT 거래량 상위"
|
||||
];
|
||||
const sixAndTwelveRows = [
|
||||
"코스피 시가총액", "코스피 예상가 시가총액 상위", "코스피 상승률 상위", "코스피 하락률 상위",
|
||||
"코스피 52주 신고가", "코스피 52주 신저가", "코스피 시간외 상승률 상위", "코스피 시간외 하락률 상위",
|
||||
"코스닥 시가총액", "코스닥 예상가 시가총액 상위", "코스닥 상승률 상위", "코스닥 하락률 상위",
|
||||
"코스닥 52주 신고가", "코스닥 52주 신저가", "코스닥 시간외 상승률 상위", "코스닥 시간외 하락률 상위",
|
||||
"코스피 200 종목", "ETF 종목", "VI 발동(수동)",
|
||||
"코스피_NXT 시가총액", "코스피_NXT 상승률 상위", "코스피_NXT 하락률 상위", "코스피_NXT 거래량 상위",
|
||||
"코스닥_NXT 시가총액", "코스닥_NXT 상승률 상위", "코스닥_NXT 하락률 상위", "코스닥_NXT 거래량 상위"
|
||||
];
|
||||
|
||||
function pagedDefinition(label, builderKey, code) {
|
||||
if (label === "VI 발동(수동)") {
|
||||
return {
|
||||
// VILIST removes the placeholder row and always creates the legacy
|
||||
// five-row outcome, even when the placeholder came from the 6/12 section.
|
||||
builderKey: "s5074", code: "5074", available: false, category: "plate",
|
||||
prerequisite: "검증된 VI 종목 목록 입력 화면이 필요합니다.",
|
||||
selection: { groupCode: "PAGED_VI", subject: "", graphicType: "INPUT_ORDER", subtype: "CURRENT" }
|
||||
};
|
||||
}
|
||||
let groupCode;
|
||||
if (label.startsWith("코스피_NXT")) groupCode = "PAGED_NXT_KOSPI";
|
||||
else if (label.startsWith("코스닥_NXT")) groupCode = "PAGED_NXT_KOSDAQ";
|
||||
else if (label.startsWith("코스닥")) groupCode = "PAGED_DOMESTIC_KOSDAQ";
|
||||
else groupCode = "PAGED_DOMESTIC_KOSPI";
|
||||
|
||||
let subtype;
|
||||
if (label.includes("예상가 시가총액")) subtype = "EXPECTED_MARKET_CAP";
|
||||
else if (label.includes("시가총액")) subtype = "MARKET_CAP";
|
||||
else if (label.includes("시간외 상승률")) subtype = "AFTER_HOURS_GAIN_RATE";
|
||||
else if (label.includes("시간외 하락률")) subtype = "AFTER_HOURS_LOSS_RATE";
|
||||
else if (label.includes("상승률")) subtype = "GAIN_RATE";
|
||||
else if (label.includes("하락률")) subtype = "LOSS_RATE";
|
||||
else if (label.includes("52주 신고가")) subtype = "52_WEEK_HIGH";
|
||||
else if (label.includes("52주 신저가")) subtype = "52_WEEK_LOW";
|
||||
else if (label.includes("200 종목")) subtype = "KOSPI200";
|
||||
else if (label.startsWith("ETF")) subtype = "ETF";
|
||||
else if (label.includes("거래량")) subtype = "VOLUME";
|
||||
else throw new Error(`Unknown paged legacy action: ${label}`);
|
||||
|
||||
const dynamicNxtSession = groupCode.startsWith("PAGED_NXT_");
|
||||
return {
|
||||
builderKey, code, category: "plate",
|
||||
dynamicDate: !dynamicNxtSession,
|
||||
dynamicNxtSession,
|
||||
selection: {
|
||||
groupCode,
|
||||
subject: "INDEX",
|
||||
graphicType: dynamicNxtSession ? "$NXT_SESSION" : "",
|
||||
subtype,
|
||||
dataCode: dynamicNxtSession ? "" : "$TODAY"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function addPagedSection(section, labels, builderKey, code) {
|
||||
for (const label of labels) addAction("index", section, label, pagedDefinition(label, builderKey, code));
|
||||
}
|
||||
addPagedSection("5단 표그래프", fiveRows, "s5074", "5074");
|
||||
addPagedSection("6종목 현재가", sixAndTwelveRows, "s5077", "5077");
|
||||
addPagedSection("12종목 현재가", sixAndTwelveRows, "s5088", "5088");
|
||||
|
||||
const actions = Object.freeze(rawActions.map(value => Object.freeze({
|
||||
...value,
|
||||
aliases: Object.freeze([...value.aliases]),
|
||||
selection: Object.freeze({ ...value.selection })
|
||||
})));
|
||||
if (actions.length !== 328) throw new Error("The three fixed legacy tabs must contain exactly 328 actions.");
|
||||
if (actions.filter(action => action.available).length !== 322) {
|
||||
throw new Error("Exactly six fixed-tab actions must remain trusted manual prerequisites.");
|
||||
}
|
||||
const byId = new Map(actions.map(action => [action.id, action]));
|
||||
|
||||
function localDateText(now = new Date()) {
|
||||
const year = String(now.getFullYear()).padStart(4, "0");
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function currentNxtSession(now = new Date()) {
|
||||
return now.getHours() <= 12 ? "PRE_MARKET" : "AFTER_MARKET";
|
||||
}
|
||||
|
||||
function materializeSelection(action, now, movingAverages) {
|
||||
const selection = cloneSelection(action.selection);
|
||||
if (action.dynamicDate) selection.dataCode = localDateText(now);
|
||||
if (action.dynamicNxtSession) selection.graphicType = currentNxtSession(now);
|
||||
if (action.builderKey === "s8010") {
|
||||
const flags = [];
|
||||
if (movingAverages.ma5) flags.push("MA5");
|
||||
if (movingAverages.ma20) flags.push("MA20");
|
||||
selection.graphicType = flags.join(",");
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new TypeError("Fixed playlist options are required.");
|
||||
}
|
||||
const id = typeof options.id === "string" ? options.id.trim() : "";
|
||||
if (!identifierPattern.test(id)) throw new TypeError("A safe fixed playlist entry id is required.");
|
||||
const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : Number(options.fadeDuration);
|
||||
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
||||
throw new RangeError("Fade duration must be an integer from 0 through 60.");
|
||||
}
|
||||
const now = options.now instanceof Date && !Number.isNaN(options.now.valueOf()) ? options.now : new Date();
|
||||
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 { id, fadeDuration, now, ma5, ma20 };
|
||||
}
|
||||
|
||||
function createFixedPlaylistEntry(actionId, options) {
|
||||
const action = typeof actionId === "string" ? byId.get(actionId) : null;
|
||||
if (!action) throw new RangeError("The selected fixed legacy action is unknown.");
|
||||
if (!action.available) throw new RangeError(action.prerequisite || "The selected action requires trusted manual data.");
|
||||
const normalized = requireOptions(options);
|
||||
const movingAverages = Object.freeze({
|
||||
ma5: action.builderKey === "s8010" && normalized.ma5,
|
||||
ma20: action.builderKey === "s8010" && normalized.ma20
|
||||
});
|
||||
const selection = Object.freeze(materializeSelection(action, normalized.now, movingAverages));
|
||||
return {
|
||||
id: normalized.id,
|
||||
builderKey: action.builderKey,
|
||||
code: action.code,
|
||||
aliases: [...action.aliases],
|
||||
title: action.label,
|
||||
detail: `${action.section} · ${action.label}`,
|
||||
category: action.category,
|
||||
market: action.market,
|
||||
selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalized.fadeDuration,
|
||||
graphicType: selection.graphicType,
|
||||
subtype: selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-fixed-workflow",
|
||||
actionId: action.id,
|
||||
legacyTab: action.market,
|
||||
legacySection: action.section,
|
||||
legacyLabel: action.label,
|
||||
assetPath: runtimeAssets[action.market].relativePath,
|
||||
assetSha256: runtimeAssets[action.market].sha256,
|
||||
movingAverages
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function sameStringArray(left, right) {
|
||||
return Array.isArray(left) && Array.isArray(right) &&
|
||||
left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function normalizeStoredMovingAverages(value, action) {
|
||||
const stored = value?.operator?.movingAverages;
|
||||
if (stored !== undefined) {
|
||||
if (!stored || typeof stored !== "object" || Array.isArray(stored) ||
|
||||
typeof stored.ma5 !== "boolean" || typeof stored.ma20 !== "boolean" ||
|
||||
Object.keys(stored).length !== 2) return null;
|
||||
if (action.builderKey !== "s8010" && (stored.ma5 || stored.ma20)) return null;
|
||||
return Object.freeze({ ma5: stored.ma5, ma20: stored.ma20 });
|
||||
}
|
||||
if (action.builderKey !== "s8010") return Object.freeze({ ma5: false, ma20: false });
|
||||
const graphicType = value?.selection?.graphicType;
|
||||
if (!["", "MA5", "MA20", "MA5,MA20"].includes(graphicType)) return null;
|
||||
return Object.freeze({
|
||||
ma5: graphicType === "MA5" || graphicType === "MA5,MA20",
|
||||
ma20: graphicType === "MA20" || graphicType === "MA5,MA20"
|
||||
});
|
||||
}
|
||||
|
||||
function storedSelectionMatches(value, action, movingAverages) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
for (const key of ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) {
|
||||
const text = value[key];
|
||||
if (typeof text !== "string" || text.length > 256 || controlCharacterPattern.test(text)) return false;
|
||||
if (action.dynamicDate && key === "dataCode") {
|
||||
if (!datePattern.test(text)) return false;
|
||||
} else if (action.dynamicNxtSession && key === "graphicType") {
|
||||
if (!(["PRE_MARKET", "AFTER_MARKET"].includes(text))) return false;
|
||||
} else if (action.builderKey === "s8010" && key === "graphicType") {
|
||||
const flags = [];
|
||||
if (movingAverages.ma5) flags.push("MA5");
|
||||
if (movingAverages.ma20) flags.push("MA20");
|
||||
if (text !== flags.join(",")) return false;
|
||||
} else if (text !== action.selection[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function restoreFixedPlaylistEntry(value, options = {}) {
|
||||
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-fixed-workflow") return null;
|
||||
const action = byId.get(operator.actionId);
|
||||
const movingAverages = action ? normalizeStoredMovingAverages(value, action) : null;
|
||||
if (!action || !action.available || !movingAverages ||
|
||||
!storedSelectionMatches(value.selection, action, movingAverages)) return null;
|
||||
if (operator.legacyTab !== action.market || operator.legacySection !== action.section ||
|
||||
operator.legacyLabel !== action.label || operator.assetPath !== runtimeAssets[action.market].relativePath ||
|
||||
operator.assetSha256 !== runtimeAssets[action.market].sha256) return null;
|
||||
if (value.builderKey !== action.builderKey || value.code !== action.code ||
|
||||
value.title !== action.label || value.detail !== `${action.section} · ${action.label}` ||
|
||||
value.category !== action.category || value.market !== action.market ||
|
||||
!sameStringArray(value.aliases, action.aliases)) return null;
|
||||
let recreated;
|
||||
try {
|
||||
recreated = createFixedPlaylistEntry(action.id, {
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration,
|
||||
now: options.now,
|
||||
ma5: movingAverages.ma5,
|
||||
ma20: movingAverages.ma20
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
function actionsForMarket(market) {
|
||||
return fixedMarkets.includes(market) ? actions.filter(action => action.market === market) : [];
|
||||
}
|
||||
|
||||
return {
|
||||
runtimeAssets,
|
||||
fixedMarkets,
|
||||
actions,
|
||||
actionsForMarket,
|
||||
localDateText,
|
||||
currentNxtSession,
|
||||
createFixedPlaylistEntry,
|
||||
restoreFixedPlaylistEntry,
|
||||
refreshFixedPlaylistEntry: restoreFixedPlaylistEntry
|
||||
};
|
||||
});
|
||||
326
Web/manual-financial-ui.css
Normal file
326
Web/manual-financial-ui.css
Normal file
@@ -0,0 +1,326 @@
|
||||
.mfui-overlay[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mfui-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1400;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(4, 10, 18, 0.74);
|
||||
backdrop-filter: blur(7px);
|
||||
color: #e9f0f8;
|
||||
}
|
||||
|
||||
.mfui-dialog {
|
||||
--mfui-border: rgba(139, 162, 190, 0.28);
|
||||
--mfui-panel: rgba(15, 27, 42, 0.96);
|
||||
--mfui-muted: #91a3b8;
|
||||
width: min(1180px, 96vw);
|
||||
max-height: min(880px, 94vh);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mfui-border);
|
||||
border-radius: 16px;
|
||||
background: #0b1624;
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.52);
|
||||
font-family: "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
}
|
||||
|
||||
.mfui-header,
|
||||
.mfui-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border-color: var(--mfui-border);
|
||||
background: linear-gradient(135deg, rgba(20, 39, 59, 0.98), rgba(9, 21, 35, 0.98));
|
||||
}
|
||||
|
||||
.mfui-header {
|
||||
border-bottom: 1px solid var(--mfui-border);
|
||||
}
|
||||
|
||||
.mfui-footer {
|
||||
align-items: flex-start;
|
||||
border-top: 1px solid var(--mfui-border);
|
||||
}
|
||||
|
||||
.mfui-heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mfui-title,
|
||||
.mfui-editor-title,
|
||||
.mfui-subtitle,
|
||||
.mfui-status,
|
||||
.mfui-quarantine,
|
||||
.mfui-list-state,
|
||||
.mfui-stock-state,
|
||||
.mfui-empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mfui-title {
|
||||
color: #f5f9ff;
|
||||
font-size: 22px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.mfui-subtitle,
|
||||
.mfui-list-state,
|
||||
.mfui-stock-state,
|
||||
.mfui-empty {
|
||||
color: var(--mfui-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mfui-subtitle {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mfui-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(250px, 0.72fr) minmax(520px, 1.8fr);
|
||||
min-height: 560px;
|
||||
max-height: calc(94vh - 150px);
|
||||
}
|
||||
|
||||
.mfui-list-panel,
|
||||
.mfui-editor {
|
||||
min-height: 0;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.mfui-list-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
border-right: 1px solid var(--mfui-border);
|
||||
background: rgba(8, 19, 31, 0.92);
|
||||
}
|
||||
|
||||
.mfui-editor {
|
||||
overflow: auto;
|
||||
background: var(--mfui-panel);
|
||||
}
|
||||
|
||||
.mfui-editor-title {
|
||||
color: #f1f6fc;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.mfui-stock-state {
|
||||
margin: 5px 0 15px;
|
||||
}
|
||||
|
||||
.mfui-search {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mfui-search-input,
|
||||
.mfui-input {
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(135, 158, 186, 0.34);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background: rgba(4, 14, 25, 0.88);
|
||||
color: #f1f6fc;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.mfui-search-input {
|
||||
height: 38px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
.mfui-input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 9px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mfui-search-input:focus,
|
||||
.mfui-input:focus {
|
||||
border-color: #55a9ed;
|
||||
box-shadow: 0 0 0 3px rgba(65, 151, 221, 0.16);
|
||||
}
|
||||
|
||||
.mfui-input[readonly] {
|
||||
color: #aebdcd;
|
||||
background: rgba(30, 43, 58, 0.82);
|
||||
}
|
||||
|
||||
.mfui-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(135, 158, 186, 0.18);
|
||||
border-radius: 10px;
|
||||
background: rgba(2, 10, 19, 0.48);
|
||||
}
|
||||
|
||||
.mfui-list-row {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(135, 158, 186, 0.14);
|
||||
background: transparent;
|
||||
color: #dce7f3;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mfui-list-row:hover,
|
||||
.mfui-list-row[aria-selected="true"] {
|
||||
background: rgba(42, 125, 191, 0.24);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mfui-empty {
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mfui-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mfui-field {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mfui-field-label {
|
||||
color: #aebdcd;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.mfui-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(135, 158, 186, 0.2);
|
||||
border-radius: 10px;
|
||||
background: rgba(6, 17, 29, 0.52);
|
||||
}
|
||||
|
||||
.mfui-group-title {
|
||||
padding: 0 5px;
|
||||
color: #73b8ee;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mfui-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid rgba(135, 158, 186, 0.18);
|
||||
}
|
||||
|
||||
.mfui-button,
|
||||
.mfui-search-button,
|
||||
.mfui-close {
|
||||
min-height: 36px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid rgba(135, 158, 186, 0.36);
|
||||
border-radius: 8px;
|
||||
background: rgba(30, 49, 69, 0.92);
|
||||
color: #e8f0f8;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mfui-button:hover:not(:disabled),
|
||||
.mfui-search-button:hover:not(:disabled),
|
||||
.mfui-close:hover:not(:disabled) {
|
||||
border-color: #78b9eb;
|
||||
background: rgba(43, 75, 105, 0.96);
|
||||
}
|
||||
|
||||
.mfui-primary {
|
||||
border-color: rgba(65, 164, 226, 0.64);
|
||||
background: #1671b5;
|
||||
}
|
||||
|
||||
.mfui-accent {
|
||||
border-color: rgba(72, 194, 147, 0.64);
|
||||
background: #147b59;
|
||||
}
|
||||
|
||||
.mfui-danger {
|
||||
border-color: rgba(224, 100, 108, 0.54);
|
||||
background: rgba(119, 41, 50, 0.9);
|
||||
}
|
||||
|
||||
.mfui-button:disabled,
|
||||
.mfui-search-button:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mfui-status {
|
||||
color: #c6d4e2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mfui-status[data-status="error"],
|
||||
.mfui-status[data-status="quarantined"],
|
||||
.mfui-quarantine {
|
||||
color: #ff9da5;
|
||||
}
|
||||
|
||||
.mfui-quarantine {
|
||||
max-width: 58%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.mfui-overlay {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.mfui-dialog {
|
||||
width: 100%;
|
||||
max-height: 98vh;
|
||||
}
|
||||
|
||||
.mfui-body {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mfui-list-panel {
|
||||
min-height: 260px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--mfui-border);
|
||||
}
|
||||
|
||||
.mfui-editor {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mfui-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mfui-quarantine {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
1128
Web/manual-financial-ui.js
Normal file
1128
Web/manual-financial-ui.js
Normal file
File diff suppressed because it is too large
Load Diff
1033
Web/manual-financial-workflow.js
Normal file
1033
Web/manual-financial-workflow.js
Normal file
File diff suppressed because it is too large
Load Diff
261
Web/manual-lists-ui.css
Normal file
261
Web/manual-lists-ui.css
Normal file
@@ -0,0 +1,261 @@
|
||||
.manual-lists-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
color: #e8edf6;
|
||||
font: 13px/1.45 "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
}
|
||||
|
||||
.manual-lists-root[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.manual-lists-backdrop {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgb(5 10 18 / 76%);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.manual-lists-dialog {
|
||||
display: flex;
|
||||
width: min(1080px, 96vw);
|
||||
max-height: 92vh;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
padding: 18px;
|
||||
border: 1px solid #344258;
|
||||
border-radius: 12px;
|
||||
background: #111927;
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 48%);
|
||||
}
|
||||
|
||||
.manual-lists-header,
|
||||
.manual-lists-actions,
|
||||
.manual-lists-search-controls,
|
||||
.manual-lists-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.manual-lists-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.manual-lists-title {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.manual-lists-close,
|
||||
.manual-lists-button,
|
||||
.manual-lists-tab,
|
||||
.manual-lists-search-result {
|
||||
min-height: 34px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #42516a;
|
||||
border-radius: 6px;
|
||||
color: #dfe8f7;
|
||||
background: #1d293a;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.manual-lists-close,
|
||||
.manual-lists-button,
|
||||
.manual-lists-tab {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.manual-lists-close:hover:not(:disabled),
|
||||
.manual-lists-button:hover:not(:disabled),
|
||||
.manual-lists-tab:hover:not(:disabled),
|
||||
.manual-lists-search-result:hover:not(:disabled) {
|
||||
border-color: #6b83a7;
|
||||
background: #28374d;
|
||||
}
|
||||
|
||||
.manual-lists-button:disabled,
|
||||
.manual-lists-tab:disabled,
|
||||
.manual-lists-search-result:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.manual-lists-button.is-primary {
|
||||
border-color: #2f7ac4;
|
||||
background: #175a97;
|
||||
}
|
||||
|
||||
.manual-lists-button.is-accent {
|
||||
border-color: #3e9a6b;
|
||||
background: #17623f;
|
||||
}
|
||||
|
||||
.manual-lists-tab.is-active {
|
||||
border-color: #f29b38;
|
||||
color: #fff;
|
||||
background: #8a4d12;
|
||||
}
|
||||
|
||||
.manual-lists-banner {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #6d5b31;
|
||||
border-radius: 6px;
|
||||
color: #ffe5a8;
|
||||
background: #342b18;
|
||||
}
|
||||
|
||||
.manual-lists-banner-danger {
|
||||
border-color: #8b3d47;
|
||||
color: #ffd0d5;
|
||||
background: #3d1c23;
|
||||
}
|
||||
|
||||
.manual-lists-table-wrap,
|
||||
.manual-lists-search-results {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.manual-lists-table-wrap {
|
||||
min-height: 160px;
|
||||
max-height: 45vh;
|
||||
border: 1px solid #334158;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.manual-lists-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.manual-lists-table th,
|
||||
.manual-lists-table td {
|
||||
padding: 7px;
|
||||
border: 1px solid #334158;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.manual-lists-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
color: #f4f7fc;
|
||||
background: #202c3e;
|
||||
}
|
||||
|
||||
.manual-lists-row-number {
|
||||
width: 42px;
|
||||
color: #8fa4c3;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.manual-lists-net-table th:first-child {
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
.manual-lists-input {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #42516a;
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
color: #f4f7fc;
|
||||
background: #0b1320;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.manual-lists-input:focus {
|
||||
border-color: #5da6e8;
|
||||
box-shadow: 0 0 0 2px rgb(66 145 217 / 20%);
|
||||
}
|
||||
|
||||
.manual-lists-input:disabled {
|
||||
color: #7f8ba0;
|
||||
background: #141b27;
|
||||
}
|
||||
|
||||
.manual-lists-search {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.manual-lists-search-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.manual-lists-search-state,
|
||||
.manual-lists-summary {
|
||||
margin: 0;
|
||||
color: #9eafc8;
|
||||
}
|
||||
|
||||
.manual-lists-search-results {
|
||||
display: grid;
|
||||
max-height: 150px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.manual-lists-search-result {
|
||||
padding: 7px 9px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.manual-lists-code {
|
||||
width: 160px;
|
||||
color: #9cc9ff;
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
|
||||
.manual-lists-name {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.manual-lists-row-actions {
|
||||
display: flex;
|
||||
width: 190px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.manual-lists-row-actions .manual-lists-button {
|
||||
min-width: 42px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.manual-lists-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.manual-lists-backdrop {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.manual-lists-dialog {
|
||||
width: 100%;
|
||||
max-height: 98vh;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.manual-lists-net-table {
|
||||
min-width: 760px;
|
||||
}
|
||||
}
|
||||
919
Web/manual-lists-ui.js
Normal file
919
Web/manual-lists-ui.js
Normal file
@@ -0,0 +1,919 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory(root));
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnManualListsUi = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function (root) {
|
||||
"use strict";
|
||||
|
||||
const NET_KEYS = Object.freeze(["leftName", "leftAmount", "rightName", "rightAmount"]);
|
||||
const STOCK_MARKETS = Object.freeze(["kospi", "kosdaq", "nxt-kospi", "nxt-kosdaq"]);
|
||||
const STOCK_SOURCES = Object.freeze(["oracle", "mariaDb"]);
|
||||
const MAXIMUM_STOCK_RESULTS = 200;
|
||||
const DEFAULT_FADE_DURATION = 6;
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const stockCodePattern = /^[A-Za-z0-9._-]{1,63}$/;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
|
||||
function exactKeys(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 safeText(value, maximumLength, allowEmpty = true) {
|
||||
return typeof value === "string" && value.length <= maximumLength &&
|
||||
(allowEmpty || value.length > 0) && !value.includes("^") && !unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function copyRows(rows) {
|
||||
return rows.map(row => Object.freeze({
|
||||
leftName: row.leftName,
|
||||
leftAmount: row.leftAmount,
|
||||
rightName: row.rightName,
|
||||
rightAmount: row.rightAmount
|
||||
}));
|
||||
}
|
||||
|
||||
function copyItems(items) {
|
||||
return items.map(item => Object.freeze({ code: item.code, name: item.name }));
|
||||
}
|
||||
|
||||
function rowsEqual(left, right) {
|
||||
return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
|
||||
left.every((row, index) => NET_KEYS.every(key => row[key] === right[index][key]));
|
||||
}
|
||||
|
||||
function itemsEqual(left, right) {
|
||||
return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
|
||||
left.every((item, index) => item.code === right[index].code && item.name === right[index].name);
|
||||
}
|
||||
|
||||
function emptyRows() {
|
||||
return Array.from({ length: 5 }, () => ({
|
||||
leftName: "",
|
||||
leftAmount: "",
|
||||
rightName: "",
|
||||
rightAmount: ""
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveWorkflow() {
|
||||
if (root && root.MbnManualListsWorkflow) return root.MbnManualListsWorkflow;
|
||||
if (typeof require === "function") return require("./manual-lists-workflow.js");
|
||||
throw new Error("MbnManualListsWorkflow must be loaded before the manual-list UI.");
|
||||
}
|
||||
|
||||
function normalizeStockResult(value) {
|
||||
if (!exactKeys(value, ["market", "source", "name", "code"]) ||
|
||||
!STOCK_MARKETS.includes(value.market) || !STOCK_SOURCES.includes(value.source) ||
|
||||
!safeText(value.name, 200, false) || value.name !== value.name.trim() || value.name.includes(",") ||
|
||||
typeof value.code !== "string" || !stockCodePattern.test(value.code)) return null;
|
||||
return Object.freeze({
|
||||
market: value.market,
|
||||
source: value.source,
|
||||
name: value.name,
|
||||
code: value.code
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStockSearchResponse(value, expected) {
|
||||
if (!exactKeys(value, [
|
||||
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
|
||||
]) || !expected || value.requestId !== expected.requestId || value.query !== expected.query ||
|
||||
!identifierPattern.test(value.requestId) || !safeText(value.query, 100) ||
|
||||
typeof value.retrievedAt !== "string" || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
||||
typeof value.truncated !== "boolean" || !Array.isArray(value.results) ||
|
||||
value.results.length > expected.maximumResults || value.totalRowCount < value.results.length) return null;
|
||||
const results = value.results.map(normalizeStockResult);
|
||||
if (!results.every(Boolean)) return null;
|
||||
const identities = new Set(results.map(item => `${item.market}\u0000${item.code}\u0000${item.name}`));
|
||||
if (identities.size !== results.length) return null;
|
||||
return Object.freeze({
|
||||
requestId: value.requestId,
|
||||
query: value.query,
|
||||
retrievedAt: value.retrievedAt,
|
||||
totalRowCount: value.totalRowCount,
|
||||
truncated: value.truncated,
|
||||
results: Object.freeze(results)
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStockSearchError(value, expected) {
|
||||
if (!exactKeys(value, ["requestId", "query", "message"]) || !expected ||
|
||||
value.requestId !== expected.requestId || value.query !== expected.query ||
|
||||
!identifierPattern.test(value.requestId) || !safeText(value.query, 100) ||
|
||||
!safeText(value.message, 512, false)) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function createController(dependencies) {
|
||||
const workflow = resolveWorkflow();
|
||||
if (!exactKeys(dependencies, [
|
||||
"postNative", "appendPlaylistEntry", "isLocked", "showToast", "addLog", "createId"
|
||||
]) || !Object.values(dependencies).every(value => typeof value === "function")) {
|
||||
throw new TypeError("Closed manual-list UI dependencies are required.");
|
||||
}
|
||||
|
||||
const {
|
||||
postNative,
|
||||
appendPlaylistEntry,
|
||||
isLocked,
|
||||
showToast,
|
||||
addLog,
|
||||
createId
|
||||
} = dependencies;
|
||||
|
||||
const state = {
|
||||
host: null,
|
||||
root: null,
|
||||
open: false,
|
||||
mode: null,
|
||||
manualLocked: false,
|
||||
available: null,
|
||||
quarantined: false,
|
||||
quarantineMessage: "",
|
||||
audience: "INDIVIDUAL",
|
||||
netRows: emptyRows(),
|
||||
netRevision: 0,
|
||||
netVerified: null,
|
||||
viItems: [],
|
||||
viBaseVersion: null,
|
||||
viVersion: null,
|
||||
viRevision: 0,
|
||||
viVerified: null,
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
searchMessage: "",
|
||||
pending: {
|
||||
status: null,
|
||||
netRead: null,
|
||||
netSave: null,
|
||||
viRead: null,
|
||||
viSave: null,
|
||||
stockSearch: null
|
||||
}
|
||||
};
|
||||
|
||||
function locked() {
|
||||
try {
|
||||
return state.manualLocked || Boolean(isLocked());
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function canMutate() {
|
||||
return state.available === true && !state.pending.status && !locked() && !state.quarantined;
|
||||
}
|
||||
|
||||
function notify(message, kind = "info") {
|
||||
try { showToast(message, kind); } catch { /* host notification is best effort */ }
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
try { addLog(message); } catch { /* host logging is best effort */ }
|
||||
}
|
||||
|
||||
function nextId() {
|
||||
const value = createId();
|
||||
if (typeof value !== "string" || !identifierPattern.test(value)) {
|
||||
throw new TypeError("createId returned an unsafe identifier.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function send(request) {
|
||||
postNative(request.type, request.payload);
|
||||
return request.payload.requestId;
|
||||
}
|
||||
|
||||
function requestStatus() {
|
||||
const requestId = nextId();
|
||||
state.pending.status = { requestId };
|
||||
send(workflow.createStatusRequest(requestId));
|
||||
}
|
||||
|
||||
function requestNetRead(audience, verification = null) {
|
||||
state.netVerified = null;
|
||||
const requestId = nextId();
|
||||
state.pending.netRead = {
|
||||
requestId,
|
||||
audience,
|
||||
revision: state.netRevision,
|
||||
verification
|
||||
};
|
||||
send(workflow.createNetSellReadRequest(requestId, audience));
|
||||
}
|
||||
|
||||
function requestViRead(verification = null) {
|
||||
state.viVersion = null;
|
||||
state.viVerified = null;
|
||||
const requestId = nextId();
|
||||
state.pending.viRead = {
|
||||
requestId,
|
||||
revision: state.viRevision,
|
||||
verification
|
||||
};
|
||||
send(workflow.createViReadRequest(requestId));
|
||||
}
|
||||
|
||||
function invalidateNetVerification() {
|
||||
state.netRevision += 1;
|
||||
state.netVerified = null;
|
||||
}
|
||||
|
||||
function invalidateViVerification() {
|
||||
state.viRevision += 1;
|
||||
state.viVersion = null;
|
||||
state.viVerified = null;
|
||||
}
|
||||
|
||||
function setAudience(audience) {
|
||||
const normalized = workflow.normalizeAudience(audience);
|
||||
if (!normalized) throw new RangeError("Unknown FSell audience.");
|
||||
state.audience = normalized;
|
||||
state.netRows = emptyRows();
|
||||
invalidateNetVerification();
|
||||
requestNetRead(normalized);
|
||||
render();
|
||||
}
|
||||
|
||||
function setNetCell(rowIndex, key, value) {
|
||||
if (locked() || !Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= 5 ||
|
||||
!NET_KEYS.includes(key) || typeof value !== "string" || value.length > 256) return false;
|
||||
state.netRows[rowIndex][key] = value;
|
||||
invalidateNetVerification();
|
||||
updateGateButtons();
|
||||
return true;
|
||||
}
|
||||
|
||||
function saveNetSell() {
|
||||
if (!canMutate() || state.pending.netRead || state.pending.netSave) return false;
|
||||
let request;
|
||||
try {
|
||||
request = workflow.createNetSellSaveRequest(nextId(), state.audience, state.netRows);
|
||||
} catch {
|
||||
notify("순매도 입력 5행의 값을 확인하세요.", "error");
|
||||
return false;
|
||||
}
|
||||
const snapshot = copyRows(request.payload.rows);
|
||||
state.pending.netSave = {
|
||||
requestId: request.payload.requestId,
|
||||
audience: request.payload.audience,
|
||||
rows: snapshot,
|
||||
revision: state.netRevision
|
||||
};
|
||||
state.netVerified = null;
|
||||
send(request);
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function addNetSellCut() {
|
||||
const verified = state.netVerified;
|
||||
if (!canMutate() || !verified || verified.audience !== state.audience ||
|
||||
!rowsEqual(verified.rows, state.netRows)) return false;
|
||||
try {
|
||||
const entry = workflow.createNetSellPlaylistEntry(state.audience, {
|
||||
id: nextId(),
|
||||
fadeDuration: DEFAULT_FADE_DURATION
|
||||
});
|
||||
appendPlaylistEntry(entry);
|
||||
notify("수동 순매도 컷을 playlist에 추가했습니다.", "success");
|
||||
log(`수동 순매도 컷 추가 · ${state.audience}`);
|
||||
return true;
|
||||
} catch {
|
||||
notify("수동 순매도 컷을 추가하지 못했습니다.", "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function searchStocks(query = state.searchQuery) {
|
||||
const normalizedQuery = typeof query === "string" ? query.trim() : "";
|
||||
if (!safeText(normalizedQuery, 100)) return false;
|
||||
const requestId = nextId();
|
||||
const request = {
|
||||
requestId,
|
||||
query: normalizedQuery,
|
||||
maximumResults: MAXIMUM_STOCK_RESULTS
|
||||
};
|
||||
state.searchQuery = normalizedQuery;
|
||||
state.searchResults = [];
|
||||
state.searchMessage = "검색 중…";
|
||||
state.pending.stockSearch = request;
|
||||
postNative("search-stocks", request);
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function toViItem(stock) {
|
||||
const prefix = stock.market.endsWith("kospi") ? "P" : "D";
|
||||
return workflow.normalizeViItem({ code: `${prefix}${stock.code}`, name: stock.name });
|
||||
}
|
||||
|
||||
function addSearchResult(index) {
|
||||
if (locked() || !Number.isInteger(index) || index < 0 ||
|
||||
index >= state.searchResults.length || state.viItems.length >= workflow.maximumViItems) return false;
|
||||
const item = toViItem(state.searchResults[index]);
|
||||
if (!item) return false;
|
||||
state.viItems.push({ code: item.code, name: item.name });
|
||||
invalidateViVerification();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeViItem(index) {
|
||||
if (locked() || !Number.isInteger(index) || index < 0 || index >= state.viItems.length) return false;
|
||||
state.viItems.splice(index, 1);
|
||||
invalidateViVerification();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveViItem(index, delta) {
|
||||
if (locked() || !Number.isInteger(index) || ![-1, 1].includes(delta)) return false;
|
||||
const target = index + delta;
|
||||
if (index < 0 || index >= state.viItems.length || target < 0 || target >= state.viItems.length) return false;
|
||||
[state.viItems[index], state.viItems[target]] = [state.viItems[target], state.viItems[index]];
|
||||
invalidateViVerification();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearViItems() {
|
||||
if (locked() || state.viItems.length === 0) return false;
|
||||
state.viItems = [];
|
||||
invalidateViVerification();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function saveVi() {
|
||||
if (!canMutate() || !state.viBaseVersion || state.pending.viRead || state.pending.viSave) return false;
|
||||
let request;
|
||||
try {
|
||||
request = workflow.createViSaveRequest(nextId(), state.viItems, state.viBaseVersion);
|
||||
} catch {
|
||||
notify("VI 목록 값을 확인하세요.", "error");
|
||||
return false;
|
||||
}
|
||||
state.pending.viSave = {
|
||||
requestId: request.payload.requestId,
|
||||
expectedVersion: request.payload.expectedVersion,
|
||||
items: copyItems(request.payload.items),
|
||||
revision: state.viRevision
|
||||
};
|
||||
state.viVerified = null;
|
||||
send(request);
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function addViCut() {
|
||||
const verified = state.viVerified;
|
||||
if (!canMutate() || state.viItems.length === 0 || !verified ||
|
||||
verified.version !== state.viVersion || !itemsEqual(verified.items, state.viItems)) return false;
|
||||
try {
|
||||
const entry = workflow.createViPlaylistEntry(state.viItems, state.viVersion, {
|
||||
id: nextId(),
|
||||
fadeDuration: DEFAULT_FADE_DURATION
|
||||
});
|
||||
appendPlaylistEntry(entry);
|
||||
notify("VI 발동 컷을 playlist에 추가했습니다.", "success");
|
||||
log(`VI 발동 컷 추가 · ${state.viItems.length}종목 · ${entry.pageCount}페이지`);
|
||||
return true;
|
||||
} catch {
|
||||
notify("최신 VI 목록을 다시 조회한 뒤 추가하세요.", "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatus(payload) {
|
||||
const pending = state.pending.status;
|
||||
if (!pending) return false;
|
||||
const normalized = workflow.normalizeStatusResponse(payload, pending.requestId);
|
||||
if (!normalized) return false;
|
||||
state.pending.status = null;
|
||||
state.available = normalized.available;
|
||||
if (normalized.writeQuarantined) {
|
||||
state.quarantined = true;
|
||||
state.quarantineMessage = normalized.message || "수동 저장 결과를 확인할 수 없습니다.";
|
||||
}
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleNetData(payload) {
|
||||
const pending = state.pending.netRead;
|
||||
if (!pending) return false;
|
||||
const normalized = workflow.normalizeNetSellDataResponse(payload, pending);
|
||||
if (!normalized) return false;
|
||||
state.pending.netRead = null;
|
||||
if (pending.audience !== state.audience || pending.revision !== state.netRevision) return true;
|
||||
state.netRows = copyRows(normalized.rows).map(row => ({ ...row }));
|
||||
if (pending.verification &&
|
||||
pending.verification.audience === state.audience &&
|
||||
rowsEqual(pending.verification.rows, normalized.rows)) {
|
||||
state.netVerified = {
|
||||
audience: state.audience,
|
||||
rows: copyRows(normalized.rows)
|
||||
};
|
||||
notify("저장 파일 재조회가 입력과 일치합니다.", "success");
|
||||
} else {
|
||||
state.netVerified = null;
|
||||
}
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleNetSave(payload) {
|
||||
const pending = state.pending.netSave;
|
||||
if (!pending) return false;
|
||||
const normalized = workflow.normalizeNetSellSaveResponse(payload, pending);
|
||||
if (!normalized) return false;
|
||||
state.pending.netSave = null;
|
||||
requestNetRead(pending.audience, {
|
||||
audience: pending.audience,
|
||||
rows: pending.rows
|
||||
});
|
||||
notify("저장했습니다. 파일을 다시 확인하고 있습니다.", "success");
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleViData(payload) {
|
||||
const pending = state.pending.viRead;
|
||||
if (!pending) return false;
|
||||
const normalized = workflow.normalizeViDataResponse(payload, pending.requestId);
|
||||
if (!normalized) return false;
|
||||
state.pending.viRead = null;
|
||||
if (pending.revision !== state.viRevision) return true;
|
||||
|
||||
state.viItems = copyItems(normalized.items).map(item => ({ ...item }));
|
||||
state.viBaseVersion = normalized.version;
|
||||
state.viVersion = normalized.version;
|
||||
const verification = pending.verification;
|
||||
if (verification && verification.persisted &&
|
||||
(verification.version !== normalized.version ||
|
||||
!itemsEqual(verification.items, normalized.items))) {
|
||||
state.viVerified = null;
|
||||
notify("저장 후 VI 파일 내용이 달라 컷 추가를 차단했습니다.", "error");
|
||||
} else {
|
||||
state.viVerified = {
|
||||
version: normalized.version,
|
||||
items: copyItems(normalized.items)
|
||||
};
|
||||
if (verification) {
|
||||
notify(
|
||||
verification.persisted
|
||||
? "저장 파일 재조회가 VI 입력과 일치합니다."
|
||||
: "빈 목록은 원본 동작에 따라 파일을 변경하지 않았습니다.",
|
||||
"success");
|
||||
}
|
||||
}
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleViSave(payload) {
|
||||
const pending = state.pending.viSave;
|
||||
if (!pending) return false;
|
||||
const normalized = workflow.normalizeViSaveResponse(payload, pending.requestId);
|
||||
if (!normalized) return false;
|
||||
state.pending.viSave = null;
|
||||
requestViRead({
|
||||
items: pending.items,
|
||||
version: normalized.version,
|
||||
persisted: normalized.persisted
|
||||
});
|
||||
notify(
|
||||
normalized.persisted
|
||||
? "VI 목록을 저장했습니다. 파일을 다시 확인하고 있습니다."
|
||||
: "빈 VI 목록은 원본 동작에 따라 저장 파일을 변경하지 않았습니다.",
|
||||
"success");
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
const pendingOperations = Object.freeze({
|
||||
status: "status",
|
||||
netRead: "read-net-sell",
|
||||
netSave: "save-net-sell",
|
||||
viRead: "read-vi",
|
||||
viSave: "save-vi"
|
||||
});
|
||||
|
||||
function handleManualError(payload) {
|
||||
for (const [key, operation] of Object.entries(pendingOperations)) {
|
||||
const pending = state.pending[key];
|
||||
if (!pending || payload?.requestId !== pending.requestId) continue;
|
||||
const normalized = workflow.normalizeError(payload, pending.requestId);
|
||||
if (!normalized || normalized.operation !== operation) return false;
|
||||
state.pending[key] = null;
|
||||
if (key === "status") state.available = false;
|
||||
if (key === "viSave" && normalized.code === "STALE_DATA") {
|
||||
state.viBaseVersion = null;
|
||||
state.viVersion = null;
|
||||
state.viVerified = null;
|
||||
}
|
||||
if (normalized.outcomeUnknown) {
|
||||
state.quarantined = true;
|
||||
state.quarantineMessage = normalized.message;
|
||||
state.netVerified = null;
|
||||
state.viVerified = null;
|
||||
notify("저장 결과가 불명확하여 앱 재시작 전까지 재저장을 차단합니다.", "error");
|
||||
} else {
|
||||
notify(normalized.message, "error");
|
||||
}
|
||||
log(`수동 목록 오류 · ${normalized.operation} · ${normalized.code}`);
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleStockResults(payload) {
|
||||
const pending = state.pending.stockSearch;
|
||||
if (!pending) return false;
|
||||
const normalized = normalizeStockSearchResponse(payload, pending);
|
||||
if (!normalized) return false;
|
||||
state.pending.stockSearch = null;
|
||||
state.searchResults = [...normalized.results];
|
||||
state.searchMessage = normalized.results.length === 0
|
||||
? "검색 결과가 없습니다."
|
||||
: `${normalized.results.length}건${normalized.truncated ? " · 일부 결과" : ""}`;
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleStockError(payload) {
|
||||
const pending = state.pending.stockSearch;
|
||||
if (!pending) return false;
|
||||
const normalized = normalizeStockSearchError(payload, pending);
|
||||
if (!normalized) return false;
|
||||
state.pending.stockSearch = null;
|
||||
state.searchResults = [];
|
||||
state.searchMessage = normalized.message;
|
||||
notify(normalized.message, "error");
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleMessage(type, payload) {
|
||||
switch (type) {
|
||||
case workflow.bridgeContract.statusResultType: return handleStatus(payload);
|
||||
case workflow.bridgeContract.netSellDataType: return handleNetData(payload);
|
||||
case workflow.bridgeContract.netSellSaveResultType: return handleNetSave(payload);
|
||||
case workflow.bridgeContract.viDataType: return handleViData(payload);
|
||||
case workflow.bridgeContract.viSaveResultType: return handleViSave(payload);
|
||||
case workflow.bridgeContract.errorType: return handleManualError(payload);
|
||||
case "stock-search-results": return handleStockResults(payload);
|
||||
case "stock-search-error": return handleStockError(payload);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
function element(tag, className, text) {
|
||||
const node = state.root.ownerDocument.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function button(text, action, options = {}) {
|
||||
const node = element("button", options.className || "manual-lists-button", text);
|
||||
node.type = "button";
|
||||
node.disabled = Boolean(options.disabled);
|
||||
if (options.title) node.title = options.title;
|
||||
node.addEventListener("click", action);
|
||||
return node;
|
||||
}
|
||||
|
||||
function renderHeader(panel) {
|
||||
const header = element("header", "manual-lists-header");
|
||||
const title = element(
|
||||
"h2",
|
||||
"manual-lists-title",
|
||||
state.mode === "net" ? "수동 순매도 상위" : "VI 발동 목록");
|
||||
const close = button("닫기", () => {
|
||||
state.open = false;
|
||||
render();
|
||||
}, { className: "manual-lists-close" });
|
||||
header.append(title, close);
|
||||
panel.append(header);
|
||||
}
|
||||
|
||||
function renderBanner(panel) {
|
||||
if (state.quarantined) {
|
||||
const banner = element(
|
||||
"div",
|
||||
"manual-lists-banner manual-lists-banner-danger",
|
||||
state.quarantineMessage || "저장 결과가 불명확하여 앱을 다시 시작하기 전까지 저장할 수 없습니다.");
|
||||
banner.setAttribute("role", "alert");
|
||||
panel.append(banner);
|
||||
} else if (locked()) {
|
||||
panel.append(element(
|
||||
"div",
|
||||
"manual-lists-banner",
|
||||
"PREPARE 또는 송출 중에는 목록을 변경할 수 없습니다."));
|
||||
} else if (state.available === false) {
|
||||
panel.append(element(
|
||||
"div",
|
||||
"manual-lists-banner manual-lists-banner-danger",
|
||||
"수동 데이터 저장소를 사용할 수 없습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
function renderNet(panel) {
|
||||
const tabs = element("div", "manual-lists-tabs");
|
||||
for (const audience of workflow.audiences) {
|
||||
const tab = button(workflow.audienceLabels[audience], () => setAudience(audience), {
|
||||
className: audience === state.audience
|
||||
? "manual-lists-tab is-active"
|
||||
: "manual-lists-tab",
|
||||
disabled: Boolean(state.pending.netSave)
|
||||
});
|
||||
tab.setAttribute("aria-pressed", String(audience === state.audience));
|
||||
tabs.append(tab);
|
||||
}
|
||||
panel.append(tabs);
|
||||
|
||||
const table = element("table", "manual-lists-table manual-lists-net-table");
|
||||
const head = element("thead");
|
||||
const headRow = element("tr");
|
||||
for (const text of ["#", "좌측 종목", "좌측 금액", "우측 종목", "우측 금액"]) {
|
||||
headRow.append(element("th", "", text));
|
||||
}
|
||||
head.append(headRow);
|
||||
table.append(head);
|
||||
const body = element("tbody");
|
||||
state.netRows.forEach((row, rowIndex) => {
|
||||
const tr = element("tr");
|
||||
tr.append(element("th", "manual-lists-row-number", String(rowIndex + 1)));
|
||||
for (const key of NET_KEYS) {
|
||||
const td = element("td");
|
||||
const input = element("input", "manual-lists-input");
|
||||
input.type = "text";
|
||||
input.maxLength = 256;
|
||||
input.value = row[key];
|
||||
input.disabled = locked() || Boolean(state.pending.netSave);
|
||||
input.setAttribute("aria-label", `${rowIndex + 1}행 ${key}`);
|
||||
input.addEventListener("input", event => setNetCell(rowIndex, key, event.currentTarget.value));
|
||||
td.append(input);
|
||||
tr.append(td);
|
||||
}
|
||||
body.append(tr);
|
||||
});
|
||||
table.append(body);
|
||||
const tableWrap = element("div", "manual-lists-table-wrap");
|
||||
tableWrap.append(table);
|
||||
panel.append(tableWrap);
|
||||
|
||||
const actions = element("div", "manual-lists-actions");
|
||||
actions.append(
|
||||
button("재조회", () => requestNetRead(state.audience), {
|
||||
disabled: Boolean(state.pending.netRead || state.pending.netSave)
|
||||
}),
|
||||
button("저장", saveNetSell, {
|
||||
className: "manual-lists-button is-primary",
|
||||
disabled: !canMutate() || Boolean(state.pending.netRead || state.pending.netSave)
|
||||
}),
|
||||
button("s5025 추가", addNetSellCut, {
|
||||
className: "manual-lists-button is-accent",
|
||||
disabled: !canMutate() || !state.netVerified ||
|
||||
!rowsEqual(state.netVerified.rows, state.netRows)
|
||||
})
|
||||
);
|
||||
panel.append(actions);
|
||||
}
|
||||
|
||||
function renderSearch(panel) {
|
||||
const section = element("section", "manual-lists-search");
|
||||
const controls = element("div", "manual-lists-search-controls");
|
||||
const input = element("input", "manual-lists-input manual-lists-search-input");
|
||||
input.type = "search";
|
||||
input.maxLength = 100;
|
||||
input.placeholder = "종목명 검색";
|
||||
input.value = state.searchQuery;
|
||||
input.addEventListener("input", event => { state.searchQuery = event.currentTarget.value; });
|
||||
input.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
searchStocks(event.currentTarget.value);
|
||||
}
|
||||
});
|
||||
controls.append(input, button("검색", () => searchStocks(input.value), {
|
||||
disabled: Boolean(state.pending.stockSearch)
|
||||
}));
|
||||
section.append(controls);
|
||||
section.append(element("p", "manual-lists-search-state", state.searchMessage));
|
||||
const results = element("div", "manual-lists-search-results");
|
||||
state.searchResults.forEach((stock, index) => {
|
||||
results.append(button(
|
||||
`${stock.name} · ${stock.code} · ${stock.market}`,
|
||||
() => addSearchResult(index),
|
||||
{
|
||||
className: "manual-lists-search-result",
|
||||
disabled: locked() || state.viItems.length >= workflow.maximumViItems
|
||||
}));
|
||||
});
|
||||
section.append(results);
|
||||
panel.append(section);
|
||||
}
|
||||
|
||||
function renderVi(panel) {
|
||||
renderSearch(panel);
|
||||
const summary = element(
|
||||
"div",
|
||||
"manual-lists-summary",
|
||||
`${state.viItems.length}/${workflow.maximumViItems}종목 · ${state.viItems.length === 0 ? 0 : Math.ceil(state.viItems.length / workflow.viPageSize)}페이지`);
|
||||
panel.append(summary);
|
||||
|
||||
const tableWrap = element("div", "manual-lists-table-wrap");
|
||||
const table = element("table", "manual-lists-table manual-lists-vi-table");
|
||||
const head = element("thead");
|
||||
const headRow = element("tr");
|
||||
for (const text of ["#", "코드", "종목명", "순서/삭제"]) headRow.append(element("th", "", text));
|
||||
head.append(headRow);
|
||||
table.append(head);
|
||||
const body = element("tbody");
|
||||
state.viItems.forEach((item, index) => {
|
||||
const row = element("tr");
|
||||
row.append(
|
||||
element("td", "manual-lists-row-number", String(index + 1)),
|
||||
element("td", "manual-lists-code", item.code),
|
||||
element("td", "manual-lists-name", item.name)
|
||||
);
|
||||
const controls = element("td", "manual-lists-row-actions");
|
||||
controls.append(
|
||||
button("↑", () => moveViItem(index, -1), {
|
||||
disabled: locked() || index === 0,
|
||||
title: "위로"
|
||||
}),
|
||||
button("↓", () => moveViItem(index, 1), {
|
||||
disabled: locked() || index === state.viItems.length - 1,
|
||||
title: "아래로"
|
||||
}),
|
||||
button("삭제", () => removeViItem(index), { disabled: locked() })
|
||||
);
|
||||
row.append(controls);
|
||||
body.append(row);
|
||||
});
|
||||
table.append(body);
|
||||
tableWrap.append(table);
|
||||
panel.append(tableWrap);
|
||||
|
||||
const actions = element("div", "manual-lists-actions");
|
||||
actions.append(
|
||||
button("전체 삭제", clearViItems, {
|
||||
disabled: locked() || state.viItems.length === 0
|
||||
}),
|
||||
button("재조회", () => requestViRead(), {
|
||||
disabled: Boolean(state.pending.viRead || state.pending.viSave)
|
||||
}),
|
||||
button("저장", saveVi, {
|
||||
className: "manual-lists-button is-primary",
|
||||
disabled: !canMutate() || !state.viBaseVersion ||
|
||||
Boolean(state.pending.viRead || state.pending.viSave)
|
||||
}),
|
||||
button("s5074 추가", addViCut, {
|
||||
className: "manual-lists-button is-accent",
|
||||
disabled: !canMutate() || state.viItems.length === 0 || !state.viVerified ||
|
||||
state.viVerified.version !== state.viVersion ||
|
||||
!itemsEqual(state.viVerified.items, state.viItems)
|
||||
})
|
||||
);
|
||||
panel.append(actions);
|
||||
}
|
||||
|
||||
function updateGateButtons() {
|
||||
if (!state.root) return;
|
||||
const add = state.root.querySelector(".manual-lists-button.is-accent");
|
||||
if (!add) return;
|
||||
add.disabled = state.mode === "net"
|
||||
? !canMutate() || !state.netVerified || !rowsEqual(state.netVerified.rows, state.netRows)
|
||||
: !canMutate() || state.viItems.length === 0 || !state.viVerified ||
|
||||
state.viVerified.version !== state.viVersion || !itemsEqual(state.viVerified.items, state.viItems);
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return Object.freeze({
|
||||
open: state.open,
|
||||
mode: state.mode,
|
||||
locked: locked(),
|
||||
available: state.available,
|
||||
quarantined: state.quarantined,
|
||||
audience: state.audience,
|
||||
netRows: Object.freeze(copyRows(state.netRows)),
|
||||
netVerified: Boolean(state.netVerified && rowsEqual(state.netVerified.rows, state.netRows)),
|
||||
viItems: Object.freeze(copyItems(state.viItems)),
|
||||
viBaseVersion: state.viBaseVersion,
|
||||
viVersion: state.viVersion,
|
||||
viVerified: Boolean(state.viVerified && state.viVerified.version === state.viVersion &&
|
||||
itemsEqual(state.viVerified.items, state.viItems)),
|
||||
searchResults: Object.freeze(state.searchResults.map(item => ({ ...item }))),
|
||||
pending: Object.freeze(Object.fromEntries(
|
||||
Object.entries(state.pending).map(([key, value]) => [key, value?.requestId || null])))
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.root) return snapshot();
|
||||
state.root.hidden = !state.open;
|
||||
state.root.replaceChildren();
|
||||
if (!state.open) return snapshot();
|
||||
const backdrop = element("div", "manual-lists-backdrop");
|
||||
const panel = element("section", "manual-lists-dialog");
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-modal", "true");
|
||||
renderHeader(panel);
|
||||
renderBanner(panel);
|
||||
if (state.mode === "net") renderNet(panel);
|
||||
if (state.mode === "vi") renderVi(panel);
|
||||
backdrop.append(panel);
|
||||
state.root.append(backdrop);
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function mount(host) {
|
||||
const resolvedHost = host || root?.document?.body;
|
||||
if (!resolvedHost || typeof resolvedHost.append !== "function" || !resolvedHost.ownerDocument) {
|
||||
throw new TypeError("A DOM host is required to mount the manual-list UI.");
|
||||
}
|
||||
if (state.root?.parentNode) state.root.remove();
|
||||
state.host = resolvedHost;
|
||||
state.root = resolvedHost.ownerDocument.createElement("div");
|
||||
state.root.className = "manual-lists-root";
|
||||
state.root.hidden = true;
|
||||
resolvedHost.append(state.root);
|
||||
render();
|
||||
return state.root;
|
||||
}
|
||||
|
||||
function openNetSell(audience = state.audience) {
|
||||
const normalized = workflow.normalizeAudience(audience);
|
||||
if (!normalized) throw new RangeError("Unknown FSell audience.");
|
||||
state.open = true;
|
||||
state.mode = "net";
|
||||
state.audience = normalized;
|
||||
state.netRows = emptyRows();
|
||||
invalidateNetVerification();
|
||||
requestStatus();
|
||||
requestNetRead(normalized);
|
||||
render();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function openVi() {
|
||||
state.open = true;
|
||||
state.mode = "vi";
|
||||
requestStatus();
|
||||
requestViRead();
|
||||
render();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setLocked(value) {
|
||||
state.manualLocked = Boolean(value);
|
||||
render();
|
||||
return state.manualLocked;
|
||||
}
|
||||
|
||||
const actions = Object.freeze({
|
||||
setAudience,
|
||||
setNetCell,
|
||||
saveNetSell,
|
||||
addNetSellCut,
|
||||
requestNetRead: () => requestNetRead(state.audience),
|
||||
searchStocks,
|
||||
addSearchResult,
|
||||
removeViItem,
|
||||
moveViItem,
|
||||
clearViItems,
|
||||
saveVi,
|
||||
addViCut,
|
||||
requestViRead: () => requestViRead(),
|
||||
close: () => {
|
||||
state.open = false;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
mount,
|
||||
openNetSell,
|
||||
openVi,
|
||||
handleMessage,
|
||||
render,
|
||||
setLocked,
|
||||
actions,
|
||||
getState: snapshot
|
||||
});
|
||||
}
|
||||
|
||||
return { createController };
|
||||
});
|
||||
390
Web/manual-lists-workflow.js
Normal file
390
Web/manual-lists-workflow.js
Normal file
@@ -0,0 +1,390 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnManualListsWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const MAXIMUM_VI_ITEMS = 100;
|
||||
const VI_PAGE_SIZE = 5;
|
||||
const MAXIMUM_PAGE_COUNT = 20;
|
||||
const VI_SNAPSHOT_SUBJECT = "@MBN_VI_SNAPSHOT_V1";
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const rowVersionPattern = /^[a-f0-9]{64}$/;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
const audiences = Object.freeze(["INDIVIDUAL", "FOREIGN", "INSTITUTION"]);
|
||||
const audienceLabels = Object.freeze({
|
||||
INDIVIDUAL: "개인",
|
||||
FOREIGN: "외국인",
|
||||
INSTITUTION: "기관"
|
||||
});
|
||||
|
||||
const bridgeContract = Object.freeze({
|
||||
statusRequestType: "request-manual-operator-data-status",
|
||||
statusResultType: "manual-operator-data-status",
|
||||
netSellReadType: "request-manual-net-sell-data",
|
||||
netSellDataType: "manual-net-sell-data",
|
||||
netSellSaveType: "save-manual-net-sell-data",
|
||||
netSellSaveResultType: "manual-net-sell-save-result",
|
||||
viReadType: "request-vi-manual-list",
|
||||
viDataType: "vi-manual-list-data",
|
||||
viSaveType: "save-vi-manual-list",
|
||||
viSaveResultType: "vi-manual-list-save-result",
|
||||
errorType: "manual-operator-data-error"
|
||||
});
|
||||
|
||||
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 isRequestId(value) {
|
||||
return typeof value === "string" && identifierPattern.test(value);
|
||||
}
|
||||
|
||||
function isRowVersion(value) {
|
||||
return typeof value === "string" && rowVersionPattern.test(value);
|
||||
}
|
||||
|
||||
function isSafeText(value, maximumLength, allowEmpty = true) {
|
||||
return typeof value === "string" &&
|
||||
(allowEmpty || value.length > 0) &&
|
||||
value.length <= maximumLength &&
|
||||
!value.includes("^") &&
|
||||
!unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function normalizeAudience(value) {
|
||||
return typeof value === "string" && audiences.includes(value) ? value : null;
|
||||
}
|
||||
|
||||
function normalizeNetSellRow(value) {
|
||||
if (!hasExactKeys(value, ["leftName", "leftAmount", "rightName", "rightAmount"])) return null;
|
||||
const keys = ["leftName", "leftAmount", "rightName", "rightAmount"];
|
||||
if (!keys.every(key => isSafeText(value[key], 256))) return null;
|
||||
return Object.freeze({
|
||||
leftName: value.leftName,
|
||||
leftAmount: value.leftAmount,
|
||||
rightName: value.rightName,
|
||||
rightAmount: value.rightAmount
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeNetSellRows(value) {
|
||||
if (!Array.isArray(value) || value.length !== 5) return null;
|
||||
const rows = value.map(normalizeNetSellRow);
|
||||
return rows.every(Boolean) ? Object.freeze(rows) : null;
|
||||
}
|
||||
|
||||
function normalizeViItem(value) {
|
||||
if (!hasExactKeys(value, ["code", "name"]) ||
|
||||
!isSafeText(value.code, 64, false) ||
|
||||
!isSafeText(value.name, 200, false) ||
|
||||
value.code !== value.code.trim() || value.name !== value.name.trim() ||
|
||||
value.name.includes(",")) return null;
|
||||
return Object.freeze({ code: value.code, name: value.name });
|
||||
}
|
||||
|
||||
function normalizeViItems(value, allowEmpty = true) {
|
||||
if (!Array.isArray(value) || value.length > MAXIMUM_VI_ITEMS || (!allowEmpty && value.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
const items = value.map(normalizeViItem);
|
||||
return items.every(Boolean) ? Object.freeze(items) : null;
|
||||
}
|
||||
|
||||
function request(type, requestId, extra = {}) {
|
||||
if (!isRequestId(requestId)) throw new TypeError("A safe manual-data request id is required.");
|
||||
return Object.freeze({ type, payload: Object.freeze({ requestId, ...extra }) });
|
||||
}
|
||||
|
||||
function createStatusRequest(requestId) {
|
||||
return request(bridgeContract.statusRequestType, requestId);
|
||||
}
|
||||
|
||||
function createNetSellReadRequest(requestId, audience) {
|
||||
const normalized = normalizeAudience(audience);
|
||||
if (!normalized) throw new RangeError("The manual net-sell audience is invalid.");
|
||||
return request(bridgeContract.netSellReadType, requestId, { audience: normalized });
|
||||
}
|
||||
|
||||
function createNetSellSaveRequest(requestId, audience, rows) {
|
||||
const normalizedAudience = normalizeAudience(audience);
|
||||
const normalizedRows = normalizeNetSellRows(rows);
|
||||
if (!normalizedAudience || !normalizedRows) throw new TypeError("Five valid manual net-sell rows are required.");
|
||||
return request(bridgeContract.netSellSaveType, requestId, {
|
||||
audience: normalizedAudience,
|
||||
rows: normalizedRows
|
||||
});
|
||||
}
|
||||
|
||||
function createViReadRequest(requestId) {
|
||||
return request(bridgeContract.viReadType, requestId);
|
||||
}
|
||||
|
||||
function createViSaveRequest(requestId, items, expectedVersion) {
|
||||
const normalized = normalizeViItems(items);
|
||||
if (!normalized || !isRowVersion(expectedVersion)) {
|
||||
throw new TypeError("A valid VI manual list and its last-read version are required.");
|
||||
}
|
||||
return request(bridgeContract.viSaveType, requestId, {
|
||||
expectedVersion,
|
||||
items: normalized
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStatusResponse(value, expectedRequestId) {
|
||||
if (!hasExactKeys(value, ["requestId", "available", "writeQuarantined", "message"]) ||
|
||||
!isRequestId(value.requestId) ||
|
||||
typeof value.available !== "boolean" ||
|
||||
typeof value.writeQuarantined !== "boolean" ||
|
||||
!(value.message === null || isSafeText(value.message, 512, false)) ||
|
||||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizeNetSellDataResponse(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, ["requestId", "audience", "rows"]) ||
|
||||
!isRequestId(value.requestId) ||
|
||||
!normalizeAudience(value.audience) ||
|
||||
(expectedRequest &&
|
||||
(value.requestId !== expectedRequest.requestId || value.audience !== expectedRequest.audience))) return null;
|
||||
const rows = normalizeNetSellRows(value.rows);
|
||||
return rows && Object.freeze({ requestId: value.requestId, audience: value.audience, rows });
|
||||
}
|
||||
|
||||
function normalizeNetSellSaveResponse(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, ["requestId", "audience", "saved", "recovered"]) ||
|
||||
!isRequestId(value.requestId) ||
|
||||
!normalizeAudience(value.audience) ||
|
||||
value.saved !== true || typeof value.recovered !== "boolean" ||
|
||||
(expectedRequest &&
|
||||
(value.requestId !== expectedRequest.requestId || value.audience !== expectedRequest.audience))) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizeViDataResponse(value, expectedRequestId) {
|
||||
if (!hasExactKeys(value, ["requestId", "itemCount", "pageCount", "items", "version"]) ||
|
||||
!isRequestId(value.requestId) ||
|
||||
!isRowVersion(value.version) ||
|
||||
!Number.isInteger(value.itemCount) || value.itemCount < 0 || value.itemCount > MAXIMUM_VI_ITEMS ||
|
||||
!Number.isInteger(value.pageCount) || value.pageCount < 0 || value.pageCount > MAXIMUM_PAGE_COUNT ||
|
||||
value.pageCount !== (value.itemCount === 0 ? 0 : Math.ceil(value.itemCount / VI_PAGE_SIZE)) ||
|
||||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
|
||||
const items = normalizeViItems(value.items);
|
||||
if (!items || items.length !== value.itemCount) return null;
|
||||
return Object.freeze({
|
||||
requestId: value.requestId,
|
||||
itemCount: value.itemCount,
|
||||
pageCount: value.pageCount,
|
||||
items,
|
||||
version: value.version
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeViSaveResponse(value, expectedRequestId) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "saved", "recovered", "persisted", "itemCount", "pageCount", "version"
|
||||
]) ||
|
||||
!isRequestId(value.requestId) || value.saved !== true || typeof value.recovered !== "boolean" ||
|
||||
typeof value.persisted !== "boolean" || !isRowVersion(value.version) ||
|
||||
!Number.isInteger(value.itemCount) || value.itemCount < 0 || value.itemCount > MAXIMUM_VI_ITEMS ||
|
||||
!Number.isInteger(value.pageCount) ||
|
||||
value.pageCount !== (value.itemCount === 0 ? 0 : Math.ceil(value.itemCount / VI_PAGE_SIZE)) ||
|
||||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizeError(value, expectedRequestId) {
|
||||
if (!hasExactKeys(value, ["requestId", "operation", "code", "message", "retryable", "outcomeUnknown"]) ||
|
||||
!(value.requestId === "" || isRequestId(value.requestId)) ||
|
||||
!isSafeText(value.operation, 64, false) ||
|
||||
!isSafeText(value.code, 64, false) ||
|
||||
!isSafeText(value.message, 512, false) ||
|
||||
typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" ||
|
||||
(value.outcomeUnknown && value.retryable) ||
|
||||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!hasExactKeys(options, ["id", "fadeDuration"]) || !isRequestId(options.id) ||
|
||||
!Number.isInteger(options.fadeDuration) || options.fadeDuration < 0 || options.fadeDuration > 60) {
|
||||
throw new TypeError("Safe manual-list playlist options are required.");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function createNetSellPlaylistEntry(audience, options) {
|
||||
const normalizedAudience = normalizeAudience(audience);
|
||||
const normalizedOptions = requireOptions(options);
|
||||
if (!normalizedAudience) throw new RangeError("The manual net-sell audience is invalid.");
|
||||
const label = audienceLabels[normalizedAudience];
|
||||
const selection = Object.freeze({
|
||||
groupCode: normalizedAudience,
|
||||
subject: "INDEX",
|
||||
graphicType: "MANUAL_NET_SELL",
|
||||
subtype: "MANUAL_NET_SELL",
|
||||
dataCode: ""
|
||||
});
|
||||
return {
|
||||
id: normalizedOptions.id,
|
||||
builderKey: "s5025",
|
||||
code: "5025",
|
||||
aliases: ["5025"],
|
||||
title: `${label} 순매도 상위(수동)`,
|
||||
detail: "매매동향 · 검증된 수동 입력 5행",
|
||||
category: "market",
|
||||
market: "index",
|
||||
selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
graphicType: selection.graphicType,
|
||||
subtype: selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-manual-lists-workflow",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
kind: "net-sell",
|
||||
audience: normalizedAudience
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function createViPlaylistEntry(items, rowVersion, options) {
|
||||
const normalizedItems = normalizeViItems(items, false);
|
||||
const normalizedOptions = requireOptions(options);
|
||||
if (!normalizedItems || !isRowVersion(rowVersion)) {
|
||||
throw new TypeError("A versioned non-empty VI snapshot is required.");
|
||||
}
|
||||
const pageCount = Math.ceil(normalizedItems.length / VI_PAGE_SIZE);
|
||||
const selection = Object.freeze({
|
||||
groupCode: "PAGED_VI",
|
||||
subject: VI_SNAPSHOT_SUBJECT,
|
||||
graphicType: "INPUT_ORDER",
|
||||
subtype: "CURRENT",
|
||||
dataCode: rowVersion
|
||||
});
|
||||
return {
|
||||
id: normalizedOptions.id,
|
||||
builderKey: "s5074",
|
||||
code: "5074",
|
||||
aliases: ["5074"],
|
||||
title: normalizedItems.map(item => item.name).join(", "),
|
||||
detail: "5단 표그래프 · VI 발동(수동)",
|
||||
category: "plate",
|
||||
market: "index",
|
||||
itemCount: normalizedItems.length,
|
||||
pageSize: VI_PAGE_SIZE,
|
||||
pageCount,
|
||||
selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
graphicType: selection.graphicType,
|
||||
subtype: selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-manual-lists-workflow",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
kind: "vi",
|
||||
items: normalizedItems,
|
||||
rowVersion,
|
||||
pagePreview: Object.freeze({
|
||||
itemCount: normalizedItems.length,
|
||||
pageSize: VI_PAGE_SIZE,
|
||||
pageCount,
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT
|
||||
})
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function sameSelection(left, right) {
|
||||
const keys = ["groupCode", "subject", "graphicType", "subtype", "dataCode"];
|
||||
return hasExactKeys(left, keys) && hasExactKeys(right, keys) && keys
|
||||
.every(key => typeof left[key] === "string" && left[key] === right[key]);
|
||||
}
|
||||
|
||||
function restorePlaylistEntry(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.enabled !== "boolean") return null;
|
||||
const operator = value.operator;
|
||||
if (!operator || operator.source !== "legacy-manual-lists-workflow" ||
|
||||
operator.schemaVersion !== SCHEMA_VERSION || !["net-sell", "vi"].includes(operator.kind)) return null;
|
||||
const topKeys = operator.kind === "net-sell"
|
||||
? [
|
||||
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
|
||||
"selection", "enabled", "fadeDuration", "graphicType", "subtype", "operator"
|
||||
]
|
||||
: [
|
||||
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
|
||||
"itemCount", "pageSize", "pageCount", "selection", "enabled", "fadeDuration",
|
||||
"graphicType", "subtype", "operator"
|
||||
];
|
||||
const operatorKeys = operator.kind === "net-sell"
|
||||
? ["source", "schemaVersion", "kind", "audience"]
|
||||
: ["source", "schemaVersion", "kind", "items", "rowVersion", "pagePreview"];
|
||||
if (!hasExactKeys(value, topKeys) || !hasExactKeys(operator, operatorKeys)) return null;
|
||||
if (operator.kind === "vi" &&
|
||||
(!isRowVersion(operator.rowVersion) ||
|
||||
!hasExactKeys(operator.pagePreview, ["itemCount", "pageSize", "pageCount", "maximumPageCount"]))) {
|
||||
return null;
|
||||
}
|
||||
let recreated;
|
||||
try {
|
||||
const options = { id: value.id, fadeDuration: value.fadeDuration };
|
||||
recreated = operator.kind === "net-sell"
|
||||
? createNetSellPlaylistEntry(operator.audience, options)
|
||||
: createViPlaylistEntry(operator.items, operator.rowVersion, options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const scalars = [
|
||||
"id", "builderKey", "code", "title", "detail", "category", "market",
|
||||
"fadeDuration", "graphicType", "subtype"
|
||||
];
|
||||
if (!scalars.every(key => value[key] === recreated[key]) ||
|
||||
!Array.isArray(value.aliases) || value.aliases.length !== 1 || value.aliases[0] !== recreated.aliases[0] ||
|
||||
!sameSelection(value.selection, recreated.selection)) return null;
|
||||
if (operator.kind === "vi" &&
|
||||
(value.itemCount !== recreated.itemCount || value.pageSize !== recreated.pageSize ||
|
||||
value.pageCount !== recreated.pageCount ||
|
||||
operator.pagePreview.itemCount !== recreated.operator.pagePreview.itemCount ||
|
||||
operator.pagePreview.pageSize !== recreated.operator.pagePreview.pageSize ||
|
||||
operator.pagePreview.pageCount !== recreated.operator.pagePreview.pageCount ||
|
||||
operator.pagePreview.maximumPageCount !== recreated.operator.pagePreview.maximumPageCount)) return null;
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
bridgeContract,
|
||||
audiences,
|
||||
audienceLabels,
|
||||
maximumViItems: MAXIMUM_VI_ITEMS,
|
||||
viPageSize: VI_PAGE_SIZE,
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
||||
viSnapshotSubject: VI_SNAPSHOT_SUBJECT,
|
||||
normalizeAudience,
|
||||
normalizeNetSellRow,
|
||||
normalizeNetSellRows,
|
||||
normalizeViItem,
|
||||
normalizeViItems,
|
||||
createStatusRequest,
|
||||
createNetSellReadRequest,
|
||||
createNetSellSaveRequest,
|
||||
createViReadRequest,
|
||||
createViSaveRequest,
|
||||
normalizeStatusResponse,
|
||||
normalizeNetSellDataResponse,
|
||||
normalizeNetSellSaveResponse,
|
||||
normalizeViDataResponse,
|
||||
normalizeViSaveResponse,
|
||||
normalizeError,
|
||||
createNetSellPlaylistEntry,
|
||||
createViPlaylistEntry,
|
||||
restorePlaylistEntry,
|
||||
refreshPlaylistEntry: restorePlaylistEntry
|
||||
};
|
||||
});
|
||||
204
Web/named-manual-restore-workflow.js
Normal file
204
Web/named-manual-restore-workflow.js
Normal file
@@ -0,0 +1,204 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const manualLists = typeof module === "object" && module.exports
|
||||
? require("./manual-lists-workflow.js")
|
||||
: root.MbnManualListsWorkflow;
|
||||
const manualFinancial = typeof module === "object" && module.exports
|
||||
? require("./manual-financial-workflow.js")
|
||||
: root.MbnManualFinancialWorkflow;
|
||||
const api = Object.freeze(factory(manualLists, manualFinancial));
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnNamedManualRestoreWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function (manualLists, manualFinancial) {
|
||||
"use strict";
|
||||
|
||||
if (!manualLists || !manualFinancial) throw new Error("Manual restore dependencies are unavailable.");
|
||||
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const versionPattern = /^[a-f0-9]{64}$/;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
const pendingValues = new WeakSet();
|
||||
const legacyAudienceByGroup = Object.freeze({
|
||||
"개인 순매도 상위(수동)": "INDIVIDUAL",
|
||||
"외국인 순매도 상위(수동)": "FOREIGN",
|
||||
"기관 순매도 상위(수동)": "INSTITUTION"
|
||||
});
|
||||
const marketByGroup = Object.freeze({
|
||||
KOSPI: "kospi", KOSDAQ: "kosdaq", NXT_KOSPI: "nxt-kospi", NXT_KOSDAQ: "nxt-kosdaq",
|
||||
"코스피": "kospi", "코스닥": "kosdaq", "코스피_NXT": "nxt-kospi", "코스닥_NXT": "nxt-kosdaq"
|
||||
});
|
||||
const financialByGraphic = Object.freeze(Object.fromEntries(
|
||||
manualFinancial.sourceContract.screens.flatMap(screen => [
|
||||
[screen.graphicType, screen],
|
||||
[screen.label, screen]
|
||||
])));
|
||||
|
||||
function safeText(value, maximum, allowEmpty = false) {
|
||||
return typeof value === "string" && value.length <= maximum &&
|
||||
(allowEmpty || value.length > 0) && value === value.trim() &&
|
||||
!value.includes("^") && !unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function selection(raw) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw) ||
|
||||
typeof raw.enabled !== "boolean" || !Number.isInteger(raw.itemIndex) || raw.itemIndex < 0 ||
|
||||
!safeText(raw.groupCode, 256, true) || !safeText(raw.subject, 4096, true) ||
|
||||
!safeText(raw.graphicType, 256, true) || !safeText(raw.subtype, 256, true) ||
|
||||
!safeText(raw.dataCode, 1024, true)) return null;
|
||||
return Object.freeze({
|
||||
groupCode: raw.groupCode,
|
||||
subject: raw.subject,
|
||||
graphicType: raw.graphicType,
|
||||
subtype: raw.subtype,
|
||||
dataCode: raw.dataCode
|
||||
});
|
||||
}
|
||||
|
||||
function options(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
||||
!identifierPattern.test(value.id) || !Number.isInteger(value.fadeDuration) ||
|
||||
value.fadeDuration < 0 || value.fadeDuration > 60) return null;
|
||||
return Object.freeze({ id: value.id, fadeDuration: value.fadeDuration });
|
||||
}
|
||||
|
||||
function exactHistoricalViNames(subject) {
|
||||
if (!safeText(subject, 4096) || subject.startsWith("@MBN_VI_SNAPSHOT_")) return null;
|
||||
const names = subject.split(",");
|
||||
if (names.length < 1 || names.length > manualLists.maximumViItems ||
|
||||
names.some(name => !safeText(name, 200) || name !== name.trim()) ||
|
||||
new Set(names).size !== names.length) return null;
|
||||
return Object.freeze(names);
|
||||
}
|
||||
|
||||
function classify(raw, rawOptions) {
|
||||
const selected = selection(raw);
|
||||
const normalizedOptions = options(rawOptions);
|
||||
if (!selected || !normalizedOptions) return null;
|
||||
let descriptor = null;
|
||||
|
||||
const canonicalAudience = manualLists.normalizeAudience(selected.groupCode);
|
||||
const legacyAudience = legacyAudienceByGroup[selected.groupCode] || null;
|
||||
if ((canonicalAudience && selected.subject === "INDEX" &&
|
||||
selected.graphicType === "MANUAL_NET_SELL" && selected.subtype === "MANUAL_NET_SELL") ||
|
||||
(legacyAudience && selected.subject === "" &&
|
||||
selected.graphicType === "순매도 상위" && selected.subtype === "순매도 상위")) {
|
||||
if (selected.dataCode !== "") return null;
|
||||
descriptor = {
|
||||
kind: "net-sell",
|
||||
audience: canonicalAudience || legacyAudience,
|
||||
historical: Boolean(legacyAudience)
|
||||
};
|
||||
} else if (selected.groupCode === "PAGED_VI" && selected.graphicType === "INPUT_ORDER" &&
|
||||
selected.subtype === "CURRENT") {
|
||||
if (selected.subject === manualLists.viSnapshotSubject && versionPattern.test(selected.dataCode)) {
|
||||
descriptor = { kind: "vi", expectedVersion: selected.dataCode, historicalNames: null };
|
||||
} else if (selected.dataCode === "") {
|
||||
const names = exactHistoricalViNames(selected.subject);
|
||||
if (names) descriptor = { kind: "vi", expectedVersion: null, historicalNames: names };
|
||||
}
|
||||
} else if (selected.groupCode === "" && selected.graphicType === "5단 표그래프" &&
|
||||
selected.subtype === "VI 발동(수동)" && selected.dataCode === "") {
|
||||
const names = exactHistoricalViNames(selected.subject);
|
||||
if (names) descriptor = { kind: "vi", expectedVersion: null, historicalNames: names };
|
||||
} else {
|
||||
const profile = financialByGraphic[selected.graphicType];
|
||||
const market = marketByGroup[selected.groupCode];
|
||||
if (profile && market && safeText(selected.subject, 200) &&
|
||||
selected.subtype === "" && selected.dataCode === "") {
|
||||
descriptor = {
|
||||
kind: "financial",
|
||||
screen: profile.screen,
|
||||
builderKey: profile.builderKey,
|
||||
code: profile.code,
|
||||
market,
|
||||
stockName: selected.subject,
|
||||
identity: Object.freeze({ screen: profile.screen, stockName: selected.subject })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!descriptor) return null;
|
||||
const pending = Object.freeze({
|
||||
...descriptor,
|
||||
itemIndex: raw.itemIndex,
|
||||
enabled: raw.enabled,
|
||||
entry: Object.freeze({ id: normalizedOptions.id }),
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
rawSelection: selected
|
||||
});
|
||||
pendingValues.add(pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createManualListReadRequest(requestId, pending) {
|
||||
if (!pendingValues.has(pending) || !["net-sell", "vi"].includes(pending.kind)) {
|
||||
throw new TypeError("A validated named manual-list restore is required.");
|
||||
}
|
||||
return pending.kind === "net-sell"
|
||||
? manualLists.createNetSellReadRequest(requestId, pending.audience)
|
||||
: manualLists.createViReadRequest(requestId);
|
||||
}
|
||||
|
||||
function materializeManualList(pending, payload, requestEnvelope) {
|
||||
if (!pendingValues.has(pending) || !requestEnvelope ||
|
||||
requestEnvelope.type !== (pending.kind === "net-sell"
|
||||
? manualLists.bridgeContract.netSellReadType
|
||||
: manualLists.bridgeContract.viReadType)) return null;
|
||||
let entry;
|
||||
if (pending.kind === "net-sell") {
|
||||
const response = manualLists.normalizeNetSellDataResponse(payload, requestEnvelope.payload);
|
||||
if (!response) return null;
|
||||
entry = manualLists.createNetSellPlaylistEntry(pending.audience, {
|
||||
id: pending.entry.id,
|
||||
fadeDuration: pending.fadeDuration
|
||||
});
|
||||
} else if (pending.kind === "vi") {
|
||||
const response = manualLists.normalizeViDataResponse(payload, requestEnvelope.payload.requestId);
|
||||
if (!response || response.itemCount < 1 ||
|
||||
(pending.expectedVersion !== null && response.version !== pending.expectedVersion)) return null;
|
||||
if (pending.historicalNames !== null) {
|
||||
const names = response.items.map(item => item.name);
|
||||
if (names.length !== pending.historicalNames.length ||
|
||||
names.some((name, index) => name !== pending.historicalNames[index])) return null;
|
||||
}
|
||||
entry = manualLists.createViPlaylistEntry(response.items, response.version, {
|
||||
id: pending.entry.id,
|
||||
fadeDuration: pending.fadeDuration
|
||||
});
|
||||
}
|
||||
return manualLists.restorePlaylistEntry({ ...entry, enabled: pending.enabled });
|
||||
}
|
||||
|
||||
function isPending(value, kind) {
|
||||
return pendingValues.has(value) && (kind === undefined || value.kind === kind);
|
||||
}
|
||||
|
||||
function matchesMaterializedEntry(pending, entry) {
|
||||
if (!pendingValues.has(pending) || !entry || entry.id !== pending.entry.id ||
|
||||
entry.enabled !== pending.enabled || entry.fadeDuration !== pending.fadeDuration) return false;
|
||||
if (pending.kind === "net-sell") {
|
||||
const restored = manualLists.restorePlaylistEntry(entry);
|
||||
return !!restored && restored.builderKey === "s5025" &&
|
||||
restored.operator.kind === "net-sell" && restored.operator.audience === pending.audience;
|
||||
}
|
||||
if (pending.kind === "vi") {
|
||||
const restored = manualLists.restorePlaylistEntry(entry);
|
||||
return !!restored && restored.builderKey === "s5074" && restored.operator.kind === "vi";
|
||||
}
|
||||
if (pending.kind === "financial") {
|
||||
return manualFinancial.isPlaylistEntryPlayable(entry) &&
|
||||
entry.builderKey === pending.builderKey && entry.code === pending.code &&
|
||||
entry.stockName === pending.stockName && entry.market === pending.market;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
classify,
|
||||
createManualListReadRequest,
|
||||
materializeManualList,
|
||||
isPending,
|
||||
matchesMaterializedEntry
|
||||
};
|
||||
});
|
||||
691
Web/named-playlist-workflow.js
Normal file
691
Web/named-playlist-workflow.js
Normal file
@@ -0,0 +1,691 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnNamedPlaylistWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const MAXIMUM_DEFINITIONS = 1000;
|
||||
const MAXIMUM_ITEMS = 1000;
|
||||
const MAXIMUM_PAGE_COUNT = 20;
|
||||
const MAXIMUM_STORED_PAGE_COUNT = 9999;
|
||||
const DEFAULT_MAXIMUM_DEFINITIONS = 200;
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const programCodePattern = /^[0-9]{8}$/;
|
||||
const cutCodePattern = /^[0-9]{1,8}$/;
|
||||
const builderKeyPattern = /^s[0-9]+$/;
|
||||
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
|
||||
const pagedBuilderByCode = Object.freeze({
|
||||
"5074": Object.freeze({ builderKey: "s5074", pageSize: 5 }),
|
||||
"5077": Object.freeze({ builderKey: "s5077", pageSize: 6 }),
|
||||
"5088": Object.freeze({ builderKey: "s5088", pageSize: 12 })
|
||||
});
|
||||
|
||||
const bridgeContract = Object.freeze({
|
||||
requests: Object.freeze({
|
||||
list: "request-named-playlist-list",
|
||||
load: "request-named-playlist-load",
|
||||
create: "create-named-playlist",
|
||||
replace: "replace-named-playlist",
|
||||
delete: "delete-named-playlist"
|
||||
}),
|
||||
responses: Object.freeze({
|
||||
list: "named-playlist-list-results",
|
||||
load: "named-playlist-load-results",
|
||||
mutation: "named-playlist-mutation-results",
|
||||
error: "named-playlist-error"
|
||||
}),
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
maximumDefinitions: MAXIMUM_DEFINITIONS,
|
||||
maximumItems: MAXIMUM_ITEMS,
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
||||
maximumStoredPageCount: MAXIMUM_STORED_PAGE_COUNT
|
||||
});
|
||||
|
||||
const pagePlanBridgeContract = Object.freeze({
|
||||
request: "request-playlist-page-plans",
|
||||
results: "playlist-page-plans-results",
|
||||
error: "playlist-page-plans-error",
|
||||
pagedBuilderByCode
|
||||
});
|
||||
|
||||
const operations = Object.freeze(["list", "load", "create", "replace", "delete"]);
|
||||
const operationSet = new Set(operations);
|
||||
|
||||
function isPlainObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function hasExactKeys(value, expected) {
|
||||
if (!isPlainObject(value)) return false;
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
return actual.length === sortedExpected.length &&
|
||||
actual.every((key, index) => key === sortedExpected[index]);
|
||||
}
|
||||
|
||||
function exactText(value, maximumLength, allowEmpty = false, allowCaret = false) {
|
||||
if (typeof value !== "string" || value.length > maximumLength ||
|
||||
(!allowEmpty && value.length === 0) || value !== value.trim() ||
|
||||
controlCharacterPattern.test(value) || (!allowCaret && value.includes("^"))) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function requestId(value) {
|
||||
const text = exactText(value, 128);
|
||||
return text && identifierPattern.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function programCode(value) {
|
||||
const text = exactText(value, 8);
|
||||
return text && programCodePattern.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function isIsoTimestamp(value) {
|
||||
return typeof value === "string" && value.length >= 20 && value.length <= 64 &&
|
||||
!Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
function createListRequest(requestIdValue, maximumResults = DEFAULT_MAXIMUM_DEFINITIONS) {
|
||||
const id = requestId(requestIdValue);
|
||||
if (!id) throw new TypeError("A safe named-playlist request id is required.");
|
||||
if (!Number.isInteger(maximumResults) || maximumResults < 1 ||
|
||||
maximumResults > MAXIMUM_DEFINITIONS) {
|
||||
throw new RangeError(`The named-playlist definition limit must be 1 through ${MAXIMUM_DEFINITIONS}.`);
|
||||
}
|
||||
return Object.freeze({ requestId: id, maximumResults });
|
||||
}
|
||||
|
||||
function createLoadRequest(requestIdValue, programCodeValue) {
|
||||
const id = requestId(requestIdValue);
|
||||
const code = programCode(programCodeValue);
|
||||
if (!id || !code) throw new TypeError("A safe request id and eight-digit program code are required.");
|
||||
return Object.freeze({ requestId: id, programCode: code });
|
||||
}
|
||||
|
||||
function createDefinitionRequest(requestIdValue, programCodeValue, titleValue) {
|
||||
const request = createLoadRequest(requestIdValue, programCodeValue);
|
||||
const title = exactText(titleValue, 128, false, true);
|
||||
if (!title) throw new TypeError("A canonical named-playlist title is required.");
|
||||
return Object.freeze({ ...request, title });
|
||||
}
|
||||
|
||||
function createDeleteRequest(requestIdValue, programCodeValue) {
|
||||
return createLoadRequest(requestIdValue, programCodeValue);
|
||||
}
|
||||
|
||||
function parseStoredPageText(value) {
|
||||
if (value === "") return null;
|
||||
if (typeof value !== "string" || !/^[1-9][0-9]{0,3}\/(?:0|[1-9][0-9]{0,3})$/.test(value)) {
|
||||
return null;
|
||||
}
|
||||
const [currentText, totalText] = value.split("/");
|
||||
const currentPage = Number(currentText);
|
||||
const totalPages = Number(totalText);
|
||||
if (totalPages > MAXIMUM_STORED_PAGE_COUNT ||
|
||||
(totalPages === 0 ? currentPage !== 1 : currentPage > totalPages)) return null;
|
||||
const isWithinPlayoutBounds = totalPages >= 1 &&
|
||||
totalPages <= MAXIMUM_PAGE_COUNT && currentPage <= totalPages;
|
||||
return Object.freeze({
|
||||
pageText: value,
|
||||
currentPage,
|
||||
totalPages,
|
||||
isWithinPlayoutBounds,
|
||||
historicalPageOutOfBounds: !isWithinPlayoutBounds,
|
||||
pageRecalculationRequired: true
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSelection(value) {
|
||||
if (!hasExactKeys(value, ["groupCode", "subject", "graphicType", "subtype", "dataCode"])) {
|
||||
return null;
|
||||
}
|
||||
const groupCode = exactText(value.groupCode, 256, true);
|
||||
const subject = exactText(value.subject, 256, true);
|
||||
const graphicType = exactText(value.graphicType, 256, true);
|
||||
const subtype = exactText(value.subtype, 256, true);
|
||||
const dataCode = exactText(value.dataCode, 256, true);
|
||||
if ([groupCode, subject, graphicType, subtype, dataCode].some(field => field === null)) return null;
|
||||
return Object.freeze({ groupCode, subject, graphicType, subtype, dataCode });
|
||||
}
|
||||
|
||||
function normalizePageTextForSave(value) {
|
||||
if (value === "") return "";
|
||||
const page = parseStoredPageText(value);
|
||||
if (!page) throw new RangeError("The stored named-playlist page text is invalid.");
|
||||
return page.pageText;
|
||||
}
|
||||
|
||||
function toLegacySevenFields(entry, pageTextValue = "1/1") {
|
||||
if (!isPlainObject(entry) || typeof entry.enabled !== "boolean") {
|
||||
throw new TypeError("A typed playlist entry with an enabled flag is required.");
|
||||
}
|
||||
const selection = normalizeSelection(entry.selection);
|
||||
if (!selection) throw new TypeError("The playlist selection cannot be stored safely.");
|
||||
const pageText = normalizePageTextForSave(pageTextValue);
|
||||
return Object.freeze([
|
||||
entry.enabled ? "1" : "0",
|
||||
selection.groupCode,
|
||||
selection.subject,
|
||||
selection.graphicType,
|
||||
selection.subtype,
|
||||
pageText,
|
||||
selection.dataCode
|
||||
]);
|
||||
}
|
||||
|
||||
function legacyListText(fieldsValue) {
|
||||
if (!Array.isArray(fieldsValue) || fieldsValue.length !== 7 ||
|
||||
fieldsValue.some(field => typeof field !== "string" || field.includes("^") ||
|
||||
controlCharacterPattern.test(field)) ||
|
||||
!["0", "1"].includes(fieldsValue[0]) ||
|
||||
fieldsValue.slice(1, 5).some(field => exactText(field, 256, true) === null) ||
|
||||
exactText(fieldsValue[6], 256, true) === null ||
|
||||
(fieldsValue[5] !== "" && !parseStoredPageText(fieldsValue[5]))) {
|
||||
throw new TypeError("Exactly seven safe legacy playlist fields are required.");
|
||||
}
|
||||
const value = fieldsValue.join("^");
|
||||
if (value.length > 4000) throw new RangeError("The legacy LIST_TEXT value is too long.");
|
||||
return value;
|
||||
}
|
||||
|
||||
function createReplaceRequest(
|
||||
requestIdValue,
|
||||
programCodeValue,
|
||||
playlistEntries,
|
||||
options = {}) {
|
||||
const request = createLoadRequest(requestIdValue, programCodeValue);
|
||||
if (!Array.isArray(playlistEntries) || playlistEntries.length > MAXIMUM_ITEMS) {
|
||||
throw new RangeError(`A named playlist can contain at most ${MAXIMUM_ITEMS} entries.`);
|
||||
}
|
||||
if (!isPlainObject(options) || typeof options.isTrustedEntry !== "function") {
|
||||
throw new TypeError("A trusted-entry verifier is required before database storage.");
|
||||
}
|
||||
if (options.pageTextForEntry !== undefined && typeof options.pageTextForEntry !== "function") {
|
||||
throw new TypeError("pageTextForEntry must be a function when supplied.");
|
||||
}
|
||||
|
||||
const items = playlistEntries.map((entry, itemIndex) => {
|
||||
if (options.isTrustedEntry(entry, itemIndex) !== true) {
|
||||
throw new TypeError(`Playlist entry ${itemIndex} did not pass trusted restoration.`);
|
||||
}
|
||||
const pageText = options.pageTextForEntry
|
||||
? options.pageTextForEntry(entry, itemIndex)
|
||||
: "1/1";
|
||||
const fields = toLegacySevenFields(entry, pageText);
|
||||
legacyListText(fields);
|
||||
return Object.freeze({
|
||||
itemIndex,
|
||||
enabled: fields[0] === "1",
|
||||
groupCode: fields[1],
|
||||
subject: fields[2],
|
||||
graphicType: fields[3],
|
||||
subtype: fields[4],
|
||||
pageText: fields[5],
|
||||
dataCode: fields[6]
|
||||
});
|
||||
});
|
||||
return Object.freeze({ ...request, items: Object.freeze(items) });
|
||||
}
|
||||
|
||||
function normalizeDefinition(value) {
|
||||
if (!hasExactKeys(value, ["programCode", "title"])) return null;
|
||||
const code = programCode(value.programCode);
|
||||
const title = exactText(value.title, 128, false, true);
|
||||
return code && title ? Object.freeze({ programCode: code, title }) : null;
|
||||
}
|
||||
|
||||
function normalizeListResponse(value) {
|
||||
const keys = [
|
||||
"requestId", "retrievedAt", "totalRowCount", "truncated",
|
||||
"suggestedProgramCode", "canMutate", "writeQuarantined", "definitions"
|
||||
];
|
||||
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
|
||||
!isIsoTimestamp(value.retrievedAt) || !Number.isInteger(value.totalRowCount) ||
|
||||
value.totalRowCount < 0 || typeof value.truncated !== "boolean" ||
|
||||
!programCode(value.suggestedProgramCode) || typeof value.canMutate !== "boolean" ||
|
||||
typeof value.writeQuarantined !== "boolean" || !Array.isArray(value.definitions) ||
|
||||
value.definitions.length !== value.totalRowCount ||
|
||||
value.definitions.length > MAXIMUM_DEFINITIONS ||
|
||||
(value.writeQuarantined && value.canMutate)) return null;
|
||||
const definitions = [];
|
||||
const codes = new Set();
|
||||
for (const raw of value.definitions) {
|
||||
const definition = normalizeDefinition(raw);
|
||||
if (!definition || codes.has(definition.programCode)) return null;
|
||||
codes.add(definition.programCode);
|
||||
definitions.push(definition);
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId: value.requestId,
|
||||
retrievedAt: value.retrievedAt,
|
||||
totalRowCount: value.totalRowCount,
|
||||
truncated: value.truncated,
|
||||
suggestedProgramCode: value.suggestedProgramCode,
|
||||
canMutate: value.canMutate,
|
||||
writeQuarantined: value.writeQuarantined,
|
||||
definitions: Object.freeze(definitions)
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRawRow(value, expectedIndex) {
|
||||
const keys = [
|
||||
"itemIndex", "enabled", "groupCode", "subject", "graphicType",
|
||||
"subtype", "pageText", "dataCode"
|
||||
];
|
||||
if (!hasExactKeys(value, keys) || value.itemIndex !== expectedIndex ||
|
||||
typeof value.enabled !== "boolean") return null;
|
||||
const selection = normalizeSelection({
|
||||
groupCode: value.groupCode,
|
||||
subject: value.subject,
|
||||
graphicType: value.graphicType,
|
||||
subtype: value.subtype,
|
||||
dataCode: value.dataCode
|
||||
});
|
||||
if (!selection || typeof value.pageText !== "string" || value.pageText.length > 9) return null;
|
||||
const pageState = value.pageText === "" ? null : parseStoredPageText(value.pageText);
|
||||
if (value.pageText !== "" && !pageState) return null;
|
||||
const legacyFields = Object.freeze([
|
||||
value.enabled ? "1" : "0",
|
||||
selection.groupCode,
|
||||
selection.subject,
|
||||
selection.graphicType,
|
||||
selection.subtype,
|
||||
value.pageText,
|
||||
selection.dataCode
|
||||
]);
|
||||
const listText = legacyListText(legacyFields);
|
||||
return Object.freeze({
|
||||
itemIndex: value.itemIndex,
|
||||
enabled: value.enabled,
|
||||
groupCode: selection.groupCode,
|
||||
subject: selection.subject,
|
||||
graphicType: selection.graphicType,
|
||||
subtype: selection.subtype,
|
||||
pageText: value.pageText,
|
||||
dataCode: selection.dataCode,
|
||||
legacyFields,
|
||||
listText,
|
||||
pageState,
|
||||
requiresTrustedRestore: true,
|
||||
pageRecalculationRequired: pageState !== null,
|
||||
historicalPageOutOfBounds: pageState?.historicalPageOutOfBounds === true
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLoadResponse(value) {
|
||||
const keys = [
|
||||
"requestId", "definition", "retrievedAt", "totalRowCount",
|
||||
"canMutate", "writeQuarantined", "rows"
|
||||
];
|
||||
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
|
||||
!isIsoTimestamp(value.retrievedAt) || !Number.isInteger(value.totalRowCount) ||
|
||||
value.totalRowCount < 0 || value.totalRowCount > MAXIMUM_ITEMS ||
|
||||
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.writeQuarantined && value.canMutate) || !Array.isArray(value.rows) ||
|
||||
value.rows.length !== value.totalRowCount) return null;
|
||||
const definition = normalizeDefinition(value.definition);
|
||||
if (!definition) return null;
|
||||
const rawRows = [];
|
||||
for (let index = 0; index < value.rows.length; index += 1) {
|
||||
const row = normalizeRawRow(value.rows[index], index);
|
||||
if (!row) return null;
|
||||
rawRows.push(row);
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "named-playlist-load",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
requestId: value.requestId,
|
||||
definition,
|
||||
retrievedAt: value.retrievedAt,
|
||||
totalRowCount: value.totalRowCount,
|
||||
canMutate: value.canMutate,
|
||||
writeQuarantined: value.writeQuarantined,
|
||||
rawRows: Object.freeze(rawRows)
|
||||
});
|
||||
}
|
||||
|
||||
function sameSelection(entry, rawRow) {
|
||||
const selection = normalizeSelection(entry?.selection);
|
||||
return selection !== null && selection.groupCode === rawRow.groupCode &&
|
||||
selection.subject === rawRow.subject && selection.graphicType === rawRow.graphicType &&
|
||||
selection.subtype === rawRow.subtype && selection.dataCode === rawRow.dataCode;
|
||||
}
|
||||
|
||||
function freezeTrustedEntry(entry) {
|
||||
const copy = {
|
||||
...entry,
|
||||
selection: Object.freeze({ ...entry.selection })
|
||||
};
|
||||
if (Array.isArray(entry.aliases)) copy.aliases = Object.freeze([...entry.aliases]);
|
||||
if (isPlainObject(entry.operator)) copy.operator = Object.freeze({ ...entry.operator });
|
||||
return Object.freeze(copy);
|
||||
}
|
||||
|
||||
function trustedEntryMatchesRaw(entry, rawRow) {
|
||||
return isPlainObject(entry) && requestId(entry.id) &&
|
||||
typeof entry.builderKey === "string" && builderKeyPattern.test(entry.builderKey) &&
|
||||
typeof entry.code === "string" && cutCodePattern.test(entry.code) &&
|
||||
entry.enabled === rawRow.enabled && sameSelection(entry, rawRow);
|
||||
}
|
||||
|
||||
function restoreRawRows(loadValue, trustedResolver) {
|
||||
const load = loadValue?.kind === "named-playlist-load"
|
||||
? loadValue
|
||||
: normalizeLoadResponse(loadValue);
|
||||
if (!load || typeof trustedResolver !== "function") {
|
||||
throw new TypeError("A normalized load response and trusted resolver are required.");
|
||||
}
|
||||
const rows = load.rawRows.map(rawRow => {
|
||||
let candidate = null;
|
||||
try {
|
||||
candidate = trustedResolver(rawRow, rawRow.itemIndex);
|
||||
} catch {
|
||||
candidate = null;
|
||||
}
|
||||
const trusted = trustedEntryMatchesRaw(candidate, rawRow);
|
||||
return Object.freeze({
|
||||
rawRow,
|
||||
entry: trusted ? freezeTrustedEntry(candidate) : null,
|
||||
trusted,
|
||||
pageRecalculationRequired: rawRow.pageRecalculationRequired,
|
||||
pageRecalculated: !rawRow.pageRecalculationRequired,
|
||||
pagePreflightCompleted: false,
|
||||
pageUnavailableReason: null,
|
||||
recalculatedPagePlan: null
|
||||
});
|
||||
});
|
||||
return freezeRestoration(load.definition, rows);
|
||||
}
|
||||
|
||||
function preparePagePreflight(restoration, isPagedEntry) {
|
||||
if (!restoration || restoration.kind !== "named-playlist-restoration" ||
|
||||
typeof isPagedEntry !== "function") {
|
||||
throw new TypeError("A valid restoration and paged-entry classifier are required.");
|
||||
}
|
||||
const rows = restoration.rows.map(row => {
|
||||
const paged = row.trusted && isPagedEntry(row.entry, row.rawRow.itemIndex) === true;
|
||||
return Object.freeze({
|
||||
...row,
|
||||
pageRecalculationRequired: paged,
|
||||
pageRecalculated: !paged,
|
||||
pagePreflightCompleted: false,
|
||||
pageUnavailableReason: null,
|
||||
recalculatedPagePlan: null
|
||||
});
|
||||
});
|
||||
return freezeRestoration(restoration.definition, rows);
|
||||
}
|
||||
|
||||
function pagePlanEntry(entry) {
|
||||
const definition = pagedBuilderByCode[entry?.code];
|
||||
const selection = normalizeSelection(entry?.selection);
|
||||
const fadeDuration = Number(entry?.fadeDuration);
|
||||
if (!definition || definition.builderKey !== entry?.builderKey ||
|
||||
!requestId(entry?.id) || typeof entry?.enabled !== "boolean" ||
|
||||
!selection || !Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
id: entry.id,
|
||||
code: entry.code,
|
||||
enabled: entry.enabled,
|
||||
fadeDuration,
|
||||
selection
|
||||
});
|
||||
}
|
||||
|
||||
function createPagePlanRequest(requestIdValue, restoration) {
|
||||
const id = requestId(requestIdValue);
|
||||
if (!id || !restoration || restoration.kind !== "named-playlist-restoration") {
|
||||
throw new TypeError("A safe request id and trusted restoration are required.");
|
||||
}
|
||||
const entries = [];
|
||||
const identifiers = new Set();
|
||||
for (const row of restoration.rows) {
|
||||
if (!row.trusted || !row.pageRecalculationRequired) continue;
|
||||
const entry = pagePlanEntry(row.entry);
|
||||
if (!entry || identifiers.has(entry.id)) {
|
||||
throw new TypeError("A paged restoration entry is not safe for page preflight.");
|
||||
}
|
||||
identifiers.add(entry.id);
|
||||
entries.push(entry);
|
||||
}
|
||||
if (!entries.length) return null;
|
||||
return Object.freeze({ requestId: id, entries: Object.freeze(entries) });
|
||||
}
|
||||
|
||||
function normalizePagePlanResult(value, expectedEntry) {
|
||||
const keys = [
|
||||
"entryId", "itemCount", "pageSize", "pageCount",
|
||||
"accessibleItemCount", "isTruncated", "builderKey"
|
||||
];
|
||||
const definition = pagedBuilderByCode[expectedEntry?.code];
|
||||
if (!hasExactKeys(value, keys) || !definition ||
|
||||
value.entryId !== expectedEntry.id || value.builderKey !== definition.builderKey ||
|
||||
value.pageSize !== definition.pageSize ||
|
||||
!Number.isSafeInteger(value.itemCount) || value.itemCount < 0 ||
|
||||
!Number.isInteger(value.pageCount) || value.pageCount < 0 ||
|
||||
value.pageCount > MAXIMUM_PAGE_COUNT ||
|
||||
!Number.isSafeInteger(value.accessibleItemCount) || value.accessibleItemCount < 0 ||
|
||||
typeof value.isTruncated !== "boolean") return null;
|
||||
|
||||
const expectedPageCount = value.itemCount === 0
|
||||
? 0
|
||||
: Math.min(MAXIMUM_PAGE_COUNT, Math.ceil(value.itemCount / value.pageSize));
|
||||
const expectedAccessible = Math.min(
|
||||
value.itemCount,
|
||||
expectedPageCount * value.pageSize);
|
||||
if (value.pageCount !== expectedPageCount ||
|
||||
value.accessibleItemCount !== expectedAccessible ||
|
||||
value.isTruncated !== (value.itemCount > expectedAccessible)) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizePagePlanResponse(value, expectedRequest) {
|
||||
if (!expectedRequest || !hasExactKeys(expectedRequest, ["requestId", "entries"]) ||
|
||||
!hasExactKeys(value, ["requestId", "calculatedAt", "plans"]) ||
|
||||
value.requestId !== expectedRequest.requestId || !isIsoTimestamp(value.calculatedAt) ||
|
||||
!Array.isArray(value.plans) || value.plans.length !== expectedRequest.entries.length) {
|
||||
return null;
|
||||
}
|
||||
const plans = [];
|
||||
for (let index = 0; index < expectedRequest.entries.length; index += 1) {
|
||||
const plan = normalizePagePlanResult(value.plans[index], expectedRequest.entries[index]);
|
||||
if (!plan) return null;
|
||||
plans.push(plan);
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId: value.requestId,
|
||||
calculatedAt: value.calculatedAt,
|
||||
plans: Object.freeze(plans)
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePagePlanError(value, expectedRequestId) {
|
||||
if (!hasExactKeys(value, ["requestId", "code", "message", "retryable"]) ||
|
||||
value.requestId !== expectedRequestId || !requestId(value.requestId) ||
|
||||
!exactText(value.code, 64) || !exactText(value.message, 1024) ||
|
||||
typeof value.retryable !== "boolean") return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizePagePlan(value) {
|
||||
if (!hasExactKeys(value, ["itemCount", "pageSize", "pageCount"]) ||
|
||||
!Number.isInteger(value.itemCount) || value.itemCount < 0 ||
|
||||
!Number.isInteger(value.pageSize) || value.pageSize < 0 || value.pageSize > 12 ||
|
||||
!Number.isInteger(value.pageCount) || value.pageCount < 1 ||
|
||||
value.pageCount > MAXIMUM_PAGE_COUNT) return null;
|
||||
return Object.freeze({
|
||||
itemCount: value.itemCount,
|
||||
pageSize: value.pageSize,
|
||||
pageCount: value.pageCount
|
||||
});
|
||||
}
|
||||
|
||||
function markPageRecalculated(restoration, itemIndex, pagePlanValue) {
|
||||
if (!restoration || restoration.kind !== "named-playlist-restoration" ||
|
||||
!Number.isInteger(itemIndex) || itemIndex < 0 || itemIndex >= restoration.rows.length) {
|
||||
throw new TypeError("A valid restoration row is required.");
|
||||
}
|
||||
const pagePlan = normalizePagePlan(pagePlanValue);
|
||||
if (!pagePlan) throw new RangeError("The recalculated page plan is invalid or exceeds 20 pages.");
|
||||
const rows = restoration.rows.map((row, index) => index === itemIndex
|
||||
? Object.freeze({
|
||||
...row,
|
||||
pageRecalculated: true,
|
||||
pagePreflightCompleted: true,
|
||||
pageUnavailableReason: null,
|
||||
recalculatedPagePlan: pagePlan
|
||||
})
|
||||
: row);
|
||||
return freezeRestoration(restoration.definition, rows);
|
||||
}
|
||||
|
||||
function applyPagePlanResponse(restoration, response) {
|
||||
if (!restoration || restoration.kind !== "named-playlist-restoration" ||
|
||||
!response || !Array.isArray(response.plans)) {
|
||||
throw new TypeError("A valid restoration and normalized page-plan response are required.");
|
||||
}
|
||||
let current = restoration;
|
||||
for (const plan of response.plans) {
|
||||
const itemIndex = current.rows.findIndex(row =>
|
||||
row.trusted && row.entry?.id === plan.entryId &&
|
||||
row.entry?.builderKey === plan.builderKey && row.pageRecalculationRequired);
|
||||
if (itemIndex < 0) throw new TypeError("The page-plan result does not match the restoration.");
|
||||
if (plan.itemCount === 0) {
|
||||
const rows = current.rows.map((row, index) => index === itemIndex
|
||||
? Object.freeze({
|
||||
...row,
|
||||
pageRecalculated: false,
|
||||
pagePreflightCompleted: true,
|
||||
pageUnavailableReason: "empty-result",
|
||||
recalculatedPagePlan: Object.freeze({ ...plan })
|
||||
})
|
||||
: row);
|
||||
current = freezeRestoration(current.definition, rows);
|
||||
continue;
|
||||
}
|
||||
current = markPageRecalculated(current, itemIndex, {
|
||||
itemCount: plan.itemCount,
|
||||
pageSize: plan.pageSize,
|
||||
pageCount: plan.pageCount
|
||||
});
|
||||
const rows = current.rows.map((row, index) => index === itemIndex
|
||||
? Object.freeze({ ...row, recalculatedPagePlan: Object.freeze({ ...plan }) })
|
||||
: row);
|
||||
current = freezeRestoration(current.definition, rows);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function prepareBlockers(restoration) {
|
||||
if (!restoration || restoration.kind !== "named-playlist-restoration") {
|
||||
return Object.freeze([Object.freeze({ itemIndex: -1, reason: "invalid-restoration" })]);
|
||||
}
|
||||
const blockers = [];
|
||||
for (const row of restoration.rows) {
|
||||
if (!row.trusted) blockers.push(Object.freeze({
|
||||
itemIndex: row.rawRow.itemIndex,
|
||||
reason: "trusted-restore-required"
|
||||
}));
|
||||
if (row.pageRecalculationRequired && !row.pageRecalculated) blockers.push(Object.freeze({
|
||||
itemIndex: row.rawRow.itemIndex,
|
||||
reason: row.pagePreflightCompleted && row.pageUnavailableReason === "empty-result"
|
||||
? "page-result-empty"
|
||||
: "page-recalculation-required"
|
||||
}));
|
||||
}
|
||||
return Object.freeze(blockers);
|
||||
}
|
||||
|
||||
function freezeRestoration(definition, rowsValue) {
|
||||
const rows = Object.freeze([...rowsValue]);
|
||||
const provisional = { kind: "named-playlist-restoration", schemaVersion: SCHEMA_VERSION, definition, rows };
|
||||
const blockers = prepareBlockers(provisional);
|
||||
return Object.freeze({ ...provisional, blockers, readyForPrepare: blockers.length === 0 });
|
||||
}
|
||||
|
||||
function canPrepareRestoredRows(restoration) {
|
||||
return prepareBlockers(restoration).length === 0;
|
||||
}
|
||||
|
||||
function materializeTrustedPlaylist(restoration) {
|
||||
if (!canPrepareRestoredRows(restoration)) {
|
||||
throw new Error("Trusted restoration and fresh page calculation are required before PREPARE.");
|
||||
}
|
||||
return Object.freeze(restoration.rows.map(row => row.entry));
|
||||
}
|
||||
|
||||
function pageStatusLabel(rawRow) {
|
||||
if (!rawRow || rawRow.requiresTrustedRestore !== true) return "Invalid stored row";
|
||||
if (!rawRow.pageState) return "No stored page; trusted restoration is required";
|
||||
if (rawRow.historicalPageOutOfBounds) {
|
||||
return `Stored page ${rawRow.pageText}; recalculate before PREPARE (20-page maximum)`;
|
||||
}
|
||||
return `Stored page ${rawRow.pageText}; recalculate before PREPARE`;
|
||||
}
|
||||
|
||||
function normalizeMutationResponse(value) {
|
||||
const keys = ["requestId", "operation", "programCode", "completedAt", "writeQuarantined"];
|
||||
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
|
||||
!["create", "replace", "delete"].includes(value.operation) ||
|
||||
!programCode(value.programCode) || !isIsoTimestamp(value.completedAt) ||
|
||||
typeof value.writeQuarantined !== "boolean") return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizeError(value) {
|
||||
const keys = [
|
||||
"requestId", "operation", "programCode", "code", "message",
|
||||
"retryable", "outcomeUnknown", "writeQuarantined"
|
||||
];
|
||||
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
|
||||
!operationSet.has(value.operation) ||
|
||||
(value.programCode !== "" && !programCode(value.programCode)) ||
|
||||
!exactText(value.code, 64) || !exactText(value.message, 1024) ||
|
||||
typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" ||
|
||||
typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.outcomeUnknown && (value.retryable || !value.writeQuarantined))) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function isWriteBlocked(value) {
|
||||
return value?.writeQuarantined === true;
|
||||
}
|
||||
|
||||
return {
|
||||
bridgeContract,
|
||||
pagePlanBridgeContract,
|
||||
operations,
|
||||
createListRequest,
|
||||
createLoadRequest,
|
||||
createDefinitionRequest,
|
||||
createReplaceRequest,
|
||||
createDeleteRequest,
|
||||
parseStoredPageText,
|
||||
toLegacySevenFields,
|
||||
legacyListText,
|
||||
normalizeListResponse,
|
||||
normalizeLoadResponse,
|
||||
restoreRawRows,
|
||||
preparePagePreflight,
|
||||
createPagePlanRequest,
|
||||
normalizePagePlanResponse,
|
||||
normalizePagePlanError,
|
||||
applyPagePlanResponse,
|
||||
markPageRecalculated,
|
||||
prepareBlockers,
|
||||
canPrepareRestoredRows,
|
||||
materializeTrustedPlaylist,
|
||||
pageStatusLabel,
|
||||
normalizeMutationResponse,
|
||||
normalizeError,
|
||||
isWriteBlocked
|
||||
};
|
||||
});
|
||||
56
Web/operator-catalog-ui.css
Normal file
56
Web/operator-catalog-ui.css
Normal file
@@ -0,0 +1,56 @@
|
||||
.mbn-operator-catalog-dialog {
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
padding: 0;
|
||||
border: 1px solid #29415b;
|
||||
border-radius: 12px;
|
||||
background: #081522;
|
||||
color: #c6d4e3;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, .55);
|
||||
}
|
||||
|
||||
.mbn-operator-catalog-dialog::backdrop { background: rgba(2, 8, 14, .72); }
|
||||
.mbn-operator-catalog-shell { display: grid; gap: 10px; padding: 14px; }
|
||||
.mbn-operator-catalog-header { display: flex; align-items: center; gap: 10px; }
|
||||
.mbn-operator-catalog-header h2 { flex: 1; margin: 0; font-size: 16px; }
|
||||
.mbn-operator-catalog-status { color: #8bdcc4; font: 11px Consolas, monospace; }
|
||||
.mbn-operator-catalog-dialog button,
|
||||
.mbn-operator-catalog-dialog input,
|
||||
.mbn-operator-catalog-dialog select {
|
||||
min-height: 32px;
|
||||
border: 1px solid #29415b;
|
||||
border-radius: 6px;
|
||||
background: #0c1f31;
|
||||
color: #c6d4e3;
|
||||
}
|
||||
.mbn-operator-catalog-dialog button { padding: 0 10px; cursor: pointer; }
|
||||
.mbn-operator-catalog-dialog button:disabled { cursor: not-allowed; opacity: .38; }
|
||||
.mbn-operator-catalog-dialog input,
|
||||
.mbn-operator-catalog-dialog select { min-width: 0; padding: 0 8px; }
|
||||
.mbn-operator-catalog-tabs,
|
||||
.mbn-operator-catalog-toolbar,
|
||||
.mbn-operator-catalog-actions,
|
||||
.mbn-operator-catalog-item-form { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.mbn-operator-catalog-tabs button[aria-pressed="true"] { border-color: #32d5a4; color: #68e2bd; }
|
||||
.mbn-operator-catalog-toolbar [data-role="query"] { flex: 1 1 240px; }
|
||||
.mbn-operator-catalog-body { display: grid; grid-template-columns: minmax(240px, .8fr) minmax(360px, 1.2fr); gap: 10px; min-height: 360px; }
|
||||
.mbn-operator-catalog-list,
|
||||
.mbn-operator-catalog-draft { min-height: 0; max-height: 52vh; margin: 0; padding: 6px; overflow: auto; border: 1px solid #1c334a; border-radius: 8px; list-style: none; }
|
||||
.mbn-operator-catalog-row button { width: 100%; margin-bottom: 4px; text-align: left; }
|
||||
.mbn-operator-catalog-row button.selected { border-color: #32d5a4; background: rgba(50, 213, 164, .12); }
|
||||
.mbn-operator-catalog-editor { display: grid; align-content: start; gap: 10px; }
|
||||
.mbn-operator-catalog-identity { display: grid; grid-template-columns: 96px 1fr; gap: 8px; align-items: center; }
|
||||
.mbn-operator-catalog-identity output { color: #8bdcc4; font: 12px Consolas, monospace; }
|
||||
.mbn-operator-catalog-item-form [data-role="stock-code"] { width: 120px; }
|
||||
.mbn-operator-catalog-item-form [data-role="stock-name"] { flex: 1 1 180px; }
|
||||
.mbn-operator-catalog-item-form [data-role="buy-amount"] { width: 130px; }
|
||||
.mbn-operator-catalog-draft-row { display: grid; grid-template-columns: 1fr auto auto auto; gap: 5px; align-items: center; padding: 5px; border-bottom: 1px solid #172b3e; }
|
||||
.mbn-operator-catalog-draft-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mbn-operator-catalog-dialog .mbn-danger { border-color: rgba(255, 102, 128, .45); color: #ff9aad; }
|
||||
.mbn-operator-catalog-error { margin: 0; padding: 8px; border: 1px solid rgba(255, 102, 128, .35); border-radius: 6px; color: #ff9aad; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.mbn-operator-catalog-body { grid-template-columns: 1fr; }
|
||||
.mbn-operator-catalog-list,
|
||||
.mbn-operator-catalog-draft { max-height: 30vh; }
|
||||
}
|
||||
1018
Web/operator-catalog-ui.js
Normal file
1018
Web/operator-catalog-ui.js
Normal file
File diff suppressed because it is too large
Load Diff
761
Web/operator-catalog-workflow.js
Normal file
761
Web/operator-catalog-workflow.js
Normal file
@@ -0,0 +1,761 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnOperatorCatalogWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const MAXIMUM_THEME_ITEMS = 240;
|
||||
const MAXIMUM_RECOMMENDATIONS = 100;
|
||||
const MAXIMUM_RESULTS = 500;
|
||||
const MAXIMUM_QUERY_LENGTH = 64;
|
||||
const MAXIMUM_WEB_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
|
||||
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const themeCodePattern = /^[0-9]{8}$/;
|
||||
const expertCodePattern = /^[0-9]{4}$/;
|
||||
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
|
||||
const bridgeContract = Object.freeze({
|
||||
requests: Object.freeze({
|
||||
themeList: "request-theme-catalog-list",
|
||||
expertList: "request-expert-catalog-list",
|
||||
schemaPreflight: "request-operator-catalog-schema",
|
||||
themeNextCode: "request-theme-catalog-next-code",
|
||||
expertNextCode: "request-expert-catalog-next-code",
|
||||
themeCreate: "create-theme-catalog",
|
||||
themeReplace: "replace-theme-catalog",
|
||||
themeDelete: "delete-theme-catalog",
|
||||
expertCreate: "create-expert-catalog",
|
||||
expertReplace: "replace-expert-catalog",
|
||||
expertDelete: "delete-expert-catalog"
|
||||
}),
|
||||
events: Object.freeze({
|
||||
themeList: "theme-catalog-list-results",
|
||||
expertList: "expert-catalog-list-results",
|
||||
schemaPreflight: "operator-catalog-schema-results",
|
||||
themeNextCode: "theme-catalog-next-code-results",
|
||||
expertNextCode: "expert-catalog-next-code-results",
|
||||
mutation: "operator-catalog-mutation-results",
|
||||
error: "operator-catalog-error"
|
||||
})
|
||||
});
|
||||
|
||||
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 hasOnlyKeys(value, keys) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) &&
|
||||
Object.keys(value).every(key => keys.includes(key));
|
||||
}
|
||||
|
||||
function isSafeCanonicalText(value, maximumLength, allowEmpty = false) {
|
||||
return typeof value === "string" &&
|
||||
(allowEmpty ? value.length >= 0 : value.length > 0) &&
|
||||
value.length <= maximumLength && value === value.trim() &&
|
||||
!unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function requireRequestId(value) {
|
||||
if (typeof value !== "string" || !requestIdPattern.test(value)) {
|
||||
throw new TypeError("A safe operator-catalog request id is required.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeQuery(value) {
|
||||
if (typeof value !== "string" || unsafeTextPattern.test(value)) return null;
|
||||
const query = value.trim();
|
||||
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query)
|
||||
? query
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeMarket(value) {
|
||||
return value === "krx" || value === "nxt" ? value : null;
|
||||
}
|
||||
|
||||
function normalizeSession(value, market) {
|
||||
if (market === "krx") return value === "notApplicable" ? value : null;
|
||||
if (market === "nxt") {
|
||||
return value === "preMarket" || value === "afterMarket" ? value : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeThemeIdentity(value) {
|
||||
if (!hasExactKeys(value, ["market", "session", "themeCode", "themeTitle"])) return null;
|
||||
const market = normalizeMarket(value.market);
|
||||
if (!market || !normalizeSession(value.session, market) ||
|
||||
!themeCodePattern.test(value.themeCode) ||
|
||||
!isSafeCanonicalText(value.themeTitle, 128) ||
|
||||
(market === "nxt" && /\(NXT\)/i.test(value.themeTitle))) return null;
|
||||
return Object.freeze({
|
||||
market,
|
||||
session: value.session,
|
||||
themeCode: value.themeCode,
|
||||
themeTitle: value.themeTitle
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeThemeItem(value, expectedIndex, market) {
|
||||
if (!hasExactKeys(value, ["inputIndex", "itemCode", "itemName"]) ||
|
||||
value.inputIndex !== expectedIndex || !Number.isInteger(value.inputIndex) ||
|
||||
!isSafeCanonicalText(value.itemCode, 13) ||
|
||||
!isSafeCanonicalText(value.itemName, 200)) return null;
|
||||
const prefix = value.itemCode[0];
|
||||
const suffix = value.itemCode.slice(1);
|
||||
if (!/^[A-Za-z0-9]{1,12}$/.test(suffix) ||
|
||||
(market === "krx" && prefix !== "P" && prefix !== "D") ||
|
||||
(market === "nxt" && prefix !== "X" && prefix !== "T")) return null;
|
||||
return Object.freeze({
|
||||
inputIndex: value.inputIndex,
|
||||
itemCode: value.itemCode,
|
||||
itemName: value.itemName
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeThemeItems(value, market) {
|
||||
if (!Array.isArray(value) || value.length > MAXIMUM_THEME_ITEMS) return null;
|
||||
const items = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const item = normalizeThemeItem(value[index], index, market);
|
||||
if (!item || codes.has(item.itemCode) || names.has(item.itemName)) return null;
|
||||
codes.add(item.itemCode);
|
||||
names.add(item.itemName);
|
||||
items.push(item);
|
||||
}
|
||||
return Object.freeze(items);
|
||||
}
|
||||
|
||||
function normalizeThemeDraft(value) {
|
||||
if (!hasExactKeys(value, ["market", "themeCode", "themeTitle", "items"])) return null;
|
||||
const market = normalizeMarket(value.market);
|
||||
if (!market || !themeCodePattern.test(value.themeCode) ||
|
||||
!isSafeCanonicalText(value.themeTitle, 128) ||
|
||||
(market === "nxt" && /\(NXT\)/i.test(value.themeTitle))) return null;
|
||||
const items = normalizeThemeItems(value.items, market);
|
||||
return items && Object.freeze({
|
||||
market,
|
||||
themeCode: value.themeCode,
|
||||
themeTitle: value.themeTitle,
|
||||
items
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExpertIdentity(value) {
|
||||
if (!hasExactKeys(value, ["expertCode", "expertName"]) ||
|
||||
!expertCodePattern.test(value.expertCode) ||
|
||||
!isSafeCanonicalText(value.expertName, 128)) return null;
|
||||
return Object.freeze({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRecommendation(value, expectedIndex) {
|
||||
if (!hasExactKeys(value, ["playIndex", "stockCode", "stockName", "buyAmount"]) ||
|
||||
value.playIndex !== expectedIndex || !Number.isInteger(value.playIndex) ||
|
||||
!stockCodePattern.test(value.stockCode) ||
|
||||
!isSafeCanonicalText(value.stockName, 200) ||
|
||||
!Number.isSafeInteger(value.buyAmount) || value.buyAmount <= 0 ||
|
||||
value.buyAmount > MAXIMUM_WEB_SAFE_INTEGER) return null;
|
||||
return Object.freeze({
|
||||
playIndex: value.playIndex,
|
||||
stockCode: value.stockCode,
|
||||
stockName: value.stockName,
|
||||
buyAmount: value.buyAmount
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRecommendations(value) {
|
||||
if (!Array.isArray(value) || value.length > MAXIMUM_RECOMMENDATIONS) return null;
|
||||
const items = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const item = normalizeRecommendation(value[index], index);
|
||||
if (!item || codes.has(item.stockCode) || names.has(item.stockName)) return null;
|
||||
codes.add(item.stockCode);
|
||||
names.add(item.stockName);
|
||||
items.push(item);
|
||||
}
|
||||
return Object.freeze(items);
|
||||
}
|
||||
|
||||
function normalizeExpertDraft(value) {
|
||||
if (!hasExactKeys(value, ["expertCode", "expertName", "recommendations"])) return null;
|
||||
const identity = normalizeExpertIdentity({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName
|
||||
});
|
||||
const recommendations = normalizeRecommendations(value.recommendations);
|
||||
return identity && recommendations && Object.freeze({
|
||||
expertCode: identity.expertCode,
|
||||
expertName: identity.expertName,
|
||||
recommendations
|
||||
});
|
||||
}
|
||||
|
||||
function createThemeCatalogListRequest(requestId, query = "", nxtSession = "preMarket", maximumResults) {
|
||||
requireRequestId(requestId);
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
if (normalizedQuery === null) throw new TypeError("A safe theme catalog query is required.");
|
||||
if (nxtSession !== "preMarket" && nxtSession !== "afterMarket") {
|
||||
throw new TypeError("An explicit NXT catalog session is required.");
|
||||
}
|
||||
if (maximumResults !== undefined &&
|
||||
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
|
||||
throw new RangeError("Theme catalog results must be limited from 1 through 500.");
|
||||
}
|
||||
const request = { requestId, query: normalizedQuery, nxtSession };
|
||||
if (maximumResults !== undefined) request.maximumResults = maximumResults;
|
||||
return Object.freeze(request);
|
||||
}
|
||||
|
||||
function createExpertCatalogListRequest(requestId, query = "", maximumResults) {
|
||||
requireRequestId(requestId);
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
if (normalizedQuery === null) throw new TypeError("A safe expert catalog query is required.");
|
||||
if (maximumResults !== undefined &&
|
||||
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
|
||||
throw new RangeError("Expert catalog results must be limited from 1 through 500.");
|
||||
}
|
||||
const request = { requestId, query: normalizedQuery };
|
||||
if (maximumResults !== undefined) request.maximumResults = maximumResults;
|
||||
return Object.freeze(request);
|
||||
}
|
||||
|
||||
function createSchemaPreflightRequest(requestId) {
|
||||
return Object.freeze({ requestId: requireRequestId(requestId) });
|
||||
}
|
||||
|
||||
function createThemeNextCodeRequest(requestId, market) {
|
||||
requireRequestId(requestId);
|
||||
if (!normalizeMarket(market)) throw new TypeError("A KRX or NXT market is required.");
|
||||
return Object.freeze({ requestId, market });
|
||||
}
|
||||
|
||||
function createExpertNextCodeRequest(requestId) {
|
||||
return Object.freeze({ requestId: requireRequestId(requestId) });
|
||||
}
|
||||
|
||||
function normalizeThemeCatalogSelection(value) {
|
||||
if (!hasExactKeys(value, ["market", "session", "themeCode", "themeTitle", "source"])) return null;
|
||||
const identity = normalizeThemeIdentity({
|
||||
market: value.market,
|
||||
session: value.session,
|
||||
themeCode: value.themeCode,
|
||||
themeTitle: value.themeTitle
|
||||
});
|
||||
if (!identity ||
|
||||
(identity.market === "krx" && value.source !== "oracle") ||
|
||||
(identity.market === "nxt" && value.source !== "mariaDb")) return null;
|
||||
return Object.freeze({ ...identity, source: value.source });
|
||||
}
|
||||
|
||||
function normalizeExpertCatalogSelection(value) {
|
||||
if (!hasExactKeys(value, ["expertCode", "expertName", "source"]) || value.source !== "oracle") {
|
||||
return null;
|
||||
}
|
||||
const identity = normalizeExpertIdentity({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName
|
||||
});
|
||||
return identity && Object.freeze({ ...identity, source: "oracle" });
|
||||
}
|
||||
|
||||
function normalizeThemePreviewForEdit(value, selection) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items"
|
||||
]) || !requestIdPattern.test(value.requestId) ||
|
||||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
||||
value.totalRowCount > MAXIMUM_THEME_ITEMS || value.truncated !== false ||
|
||||
!hasExactKeys(value.selection, ["market", "session", "themeCode", "title"])) return null;
|
||||
const previewIdentity = normalizeThemeIdentity({
|
||||
market: value.selection.market,
|
||||
session: value.selection.session,
|
||||
themeCode: value.selection.themeCode,
|
||||
themeTitle: value.selection.title
|
||||
});
|
||||
if (!previewIdentity || ["market", "session", "themeCode", "themeTitle"]
|
||||
.some(key => previewIdentity[key] !== selection[key])) return null;
|
||||
if (!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null;
|
||||
const items = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
let previousIndex = -1;
|
||||
for (const raw of value.items) {
|
||||
if (!hasExactKeys(raw, ["inputIndex", "itemCode", "itemName"]) ||
|
||||
!Number.isInteger(raw.inputIndex) || raw.inputIndex <= previousIndex || raw.inputIndex > 9999 ||
|
||||
!isSafeCanonicalText(raw.itemCode, 13) || !isSafeCanonicalText(raw.itemName, 200)) return null;
|
||||
const prefix = raw.itemCode[0];
|
||||
const suffix = raw.itemCode.slice(1);
|
||||
if (!/^[A-Za-z0-9]{1,12}$/.test(suffix) ||
|
||||
(selection.market === "krx" && prefix !== "P" && prefix !== "D") ||
|
||||
(selection.market === "nxt" && prefix !== "X" && prefix !== "T") ||
|
||||
codes.has(raw.itemCode) || names.has(raw.itemName)) return null;
|
||||
previousIndex = raw.inputIndex;
|
||||
codes.add(raw.itemCode);
|
||||
names.add(raw.itemName);
|
||||
items.push(Object.freeze({
|
||||
inputIndex: items.length,
|
||||
itemCode: raw.itemCode,
|
||||
itemName: raw.itemName
|
||||
}));
|
||||
}
|
||||
return Object.freeze(items);
|
||||
}
|
||||
|
||||
function normalizeExpertPreviewForEdit(value, selection) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items"
|
||||
]) || !requestIdPattern.test(value.requestId) ||
|
||||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
||||
value.totalRowCount > MAXIMUM_RECOMMENDATIONS || value.truncated !== false) return null;
|
||||
const previewIdentity = normalizeExpertIdentity(value.selection);
|
||||
if (!previewIdentity || previewIdentity.expertCode !== selection.expertCode ||
|
||||
previewIdentity.expertName !== selection.expertName) return null;
|
||||
if (!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null;
|
||||
const items = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
let previousIndex = -1;
|
||||
for (const raw of value.items) {
|
||||
if (!hasExactKeys(raw, ["playIndex", "stockCode", "stockName", "buyAmount"]) ||
|
||||
!Number.isInteger(raw.playIndex) || raw.playIndex <= previousIndex || raw.playIndex > 9999 ||
|
||||
!stockCodePattern.test(raw.stockCode) || !isSafeCanonicalText(raw.stockName, 200) ||
|
||||
!Number.isSafeInteger(raw.buyAmount) || raw.buyAmount <= 0 ||
|
||||
codes.has(raw.stockCode) || names.has(raw.stockName)) return null;
|
||||
previousIndex = raw.playIndex;
|
||||
codes.add(raw.stockCode);
|
||||
names.add(raw.stockName);
|
||||
items.push(Object.freeze({
|
||||
playIndex: items.length,
|
||||
stockCode: raw.stockCode,
|
||||
stockName: raw.stockName,
|
||||
buyAmount: raw.buyAmount
|
||||
}));
|
||||
}
|
||||
return Object.freeze(items);
|
||||
}
|
||||
|
||||
function createThemeEditDraft(selectionValue, previewValue) {
|
||||
const selection = normalizeThemeCatalogSelection(selectionValue);
|
||||
if (!selection) throw new TypeError("A typed theme catalog selection is required.");
|
||||
const items = normalizeThemePreviewForEdit(previewValue, selection);
|
||||
if (!items) throw new TypeError("A complete, correlated theme preview is required for editing.");
|
||||
return {
|
||||
expectedIdentity: {
|
||||
market: selection.market,
|
||||
session: selection.session,
|
||||
themeCode: selection.themeCode,
|
||||
themeTitle: selection.themeTitle
|
||||
},
|
||||
draft: {
|
||||
market: selection.market,
|
||||
themeCode: selection.themeCode,
|
||||
themeTitle: selection.themeTitle,
|
||||
items: items.map(item => ({ ...item }))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createExpertEditDraft(selectionValue, previewValue) {
|
||||
const selection = normalizeExpertCatalogSelection(selectionValue);
|
||||
if (!selection) throw new TypeError("A typed expert catalog selection is required.");
|
||||
const recommendations = normalizeExpertPreviewForEdit(previewValue, selection);
|
||||
if (!recommendations) throw new TypeError("A complete, correlated expert preview is required for editing.");
|
||||
return {
|
||||
expectedIdentity: {
|
||||
expertCode: selection.expertCode,
|
||||
expertName: selection.expertName
|
||||
},
|
||||
draft: {
|
||||
expertCode: selection.expertCode,
|
||||
expertName: selection.expertName,
|
||||
recommendations: recommendations.map(item => ({ ...item }))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createBlankThemeDraft(market, themeCode) {
|
||||
if (!normalizeMarket(market) || !themeCodePattern.test(themeCode)) {
|
||||
throw new TypeError("A typed market and suggested eight-digit theme code are required.");
|
||||
}
|
||||
return { market, themeCode, themeTitle: "", items: [] };
|
||||
}
|
||||
|
||||
function createBlankExpertDraft(expertCode) {
|
||||
if (!expertCodePattern.test(expertCode)) {
|
||||
throw new TypeError("A suggested four-digit expert code is required.");
|
||||
}
|
||||
return { expertCode, expertName: "", recommendations: [] };
|
||||
}
|
||||
|
||||
function cloneThemeDraft(draft) {
|
||||
const normalized = normalizeThemeDraft(draft);
|
||||
return normalized && {
|
||||
market: normalized.market,
|
||||
themeCode: normalized.themeCode,
|
||||
themeTitle: normalized.themeTitle,
|
||||
items: normalized.items.map(item => ({ ...item }))
|
||||
};
|
||||
}
|
||||
|
||||
function cloneExpertDraft(draft) {
|
||||
const normalized = normalizeExpertDraft(draft);
|
||||
return normalized && {
|
||||
expertCode: normalized.expertCode,
|
||||
expertName: normalized.expertName,
|
||||
recommendations: normalized.recommendations.map(item => ({ ...item }))
|
||||
};
|
||||
}
|
||||
|
||||
function createThemeCreateRequest(requestId, draftValue) {
|
||||
requireRequestId(requestId);
|
||||
const draft = cloneThemeDraft(draftValue);
|
||||
if (!draft) throw new TypeError("A complete theme draft is required.");
|
||||
return Object.freeze({ requestId, draft });
|
||||
}
|
||||
|
||||
function createThemeReplaceRequest(requestId, editValue) {
|
||||
requireRequestId(requestId);
|
||||
if (!hasExactKeys(editValue, ["expectedIdentity", "draft"])) {
|
||||
throw new TypeError("An explicit theme edit identity and draft are required.");
|
||||
}
|
||||
const identity = normalizeThemeIdentity(editValue.expectedIdentity);
|
||||
const draft = cloneThemeDraft(editValue.draft);
|
||||
if (!identity || !draft || identity.market !== draft.market ||
|
||||
identity.themeCode !== draft.themeCode) {
|
||||
throw new TypeError("The theme edit identity does not match its draft.");
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId,
|
||||
expectedIdentity: { ...identity },
|
||||
newTitle: draft.themeTitle,
|
||||
items: draft.items
|
||||
});
|
||||
}
|
||||
|
||||
function createThemeDeleteRequest(requestId, identityValue) {
|
||||
requireRequestId(requestId);
|
||||
const identity = normalizeThemeIdentity(identityValue);
|
||||
if (!identity) throw new TypeError("An explicit theme identity is required.");
|
||||
return Object.freeze({ requestId, identity: { ...identity } });
|
||||
}
|
||||
|
||||
function createExpertCreateRequest(requestId, draftValue) {
|
||||
requireRequestId(requestId);
|
||||
const draft = cloneExpertDraft(draftValue);
|
||||
if (!draft) throw new TypeError("A complete expert draft is required.");
|
||||
return Object.freeze({ requestId, draft });
|
||||
}
|
||||
|
||||
function createExpertReplaceRequest(requestId, editValue) {
|
||||
requireRequestId(requestId);
|
||||
if (!hasExactKeys(editValue, ["expectedIdentity", "draft"])) {
|
||||
throw new TypeError("An explicit expert edit identity and draft are required.");
|
||||
}
|
||||
const identity = normalizeExpertIdentity(editValue.expectedIdentity);
|
||||
const draft = cloneExpertDraft(editValue.draft);
|
||||
if (!identity || !draft || identity.expertCode !== draft.expertCode) {
|
||||
throw new TypeError("The expert edit identity does not match its draft.");
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId,
|
||||
expectedIdentity: { ...identity },
|
||||
newName: draft.expertName,
|
||||
recommendations: draft.recommendations
|
||||
});
|
||||
}
|
||||
|
||||
function createExpertDeleteRequest(requestId, identityValue) {
|
||||
requireRequestId(requestId);
|
||||
const identity = normalizeExpertIdentity(identityValue);
|
||||
if (!identity) throw new TypeError("An explicit expert identity is required.");
|
||||
return Object.freeze({ requestId, identity: { ...identity } });
|
||||
}
|
||||
|
||||
function normalizeThemeListResult(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "query", "nxtSession", "retrievedAt", "totalRowCount", "truncated",
|
||||
"canMutate", "writeQuarantined", "results"
|
||||
]) || !requestIdPattern.test(value.requestId) || normalizeQuery(value.query) !== value.query ||
|
||||
(value.nxtSession !== "preMarket" && value.nxtSession !== "afterMarket") ||
|
||||
!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" ||
|
||||
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.writeQuarantined && value.canMutate) || !Array.isArray(value.results) ||
|
||||
value.results.length !== value.totalRowCount) return null;
|
||||
if (expectedRequest && (value.requestId !== expectedRequest.requestId ||
|
||||
value.query !== expectedRequest.query || value.nxtSession !== expectedRequest.nxtSession ||
|
||||
value.results.length > (expectedRequest.maximumResults ?? 100))) return null;
|
||||
const results = [];
|
||||
const identities = new Set();
|
||||
const titles = new Set();
|
||||
for (const raw of value.results) {
|
||||
const item = normalizeThemeCatalogSelection(raw);
|
||||
const key = item && `${item.market}:${item.themeCode}`;
|
||||
const titleKey = item && `${item.market}:${item.themeTitle}`;
|
||||
if (!item || identities.has(key) || titles.has(titleKey)) return null;
|
||||
identities.add(key);
|
||||
titles.add(titleKey);
|
||||
results.push(item);
|
||||
}
|
||||
return Object.freeze({ ...value, results: Object.freeze(results) });
|
||||
}
|
||||
|
||||
function normalizeExpertListResult(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "canMutate",
|
||||
"writeQuarantined", "results"
|
||||
]) || !requestIdPattern.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" ||
|
||||
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.writeQuarantined && value.canMutate) || !Array.isArray(value.results) ||
|
||||
value.results.length !== value.totalRowCount) return null;
|
||||
if (expectedRequest && (value.requestId !== expectedRequest.requestId ||
|
||||
value.query !== expectedRequest.query ||
|
||||
value.results.length > (expectedRequest.maximumResults ?? 100))) return null;
|
||||
const results = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
for (const raw of value.results) {
|
||||
const item = normalizeExpertCatalogSelection(raw);
|
||||
if (!item || codes.has(item.expertCode) || names.has(item.expertName)) return null;
|
||||
codes.add(item.expertCode);
|
||||
names.add(item.expertName);
|
||||
results.push(item);
|
||||
}
|
||||
return Object.freeze({ ...value, results: Object.freeze(results) });
|
||||
}
|
||||
|
||||
function normalizeThemeNextCodeResult(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "market", "themeCode", "canMutate", "writeQuarantined"
|
||||
]) || !requestIdPattern.test(value.requestId) || !normalizeMarket(value.market) ||
|
||||
!themeCodePattern.test(value.themeCode) || typeof value.canMutate !== "boolean" ||
|
||||
typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.writeQuarantined && value.canMutate)) return null;
|
||||
if (expectedRequest && (value.requestId !== expectedRequest.requestId ||
|
||||
value.market !== expectedRequest.market)) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizeExpertNextCodeResult(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "expertCode", "canMutate", "writeQuarantined"
|
||||
]) || !requestIdPattern.test(value.requestId) || !expertCodePattern.test(value.expertCode) ||
|
||||
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.writeQuarantined && value.canMutate)) return null;
|
||||
if (expectedRequest && value.requestId !== expectedRequest.requestId) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizeSchemaResult(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "validatedAt", "validatedSources", "themeCanMutate", "expertCanMutate",
|
||||
"writeQuarantined"
|
||||
]) || !requestIdPattern.test(value.requestId) ||
|
||||
!isSafeCanonicalText(value.validatedAt, 64) || !Number.isFinite(Date.parse(value.validatedAt)) ||
|
||||
!Array.isArray(value.validatedSources) || value.validatedSources.length !== 2 ||
|
||||
value.validatedSources[0] !== "oracle" || value.validatedSources[1] !== "mariaDb" ||
|
||||
typeof value.themeCanMutate !== "boolean" || typeof value.expertCanMutate !== "boolean" ||
|
||||
typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.writeQuarantined && (value.themeCanMutate || value.expertCanMutate))) return null;
|
||||
if (expectedRequest && value.requestId !== expectedRequest.requestId) return null;
|
||||
return Object.freeze({ ...value, validatedSources: Object.freeze([...value.validatedSources]) });
|
||||
}
|
||||
|
||||
const mutationOperations = new Set(["create", "replace", "delete"]);
|
||||
function normalizeMutationResult(value, expected) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "entity", "operation", "identityCode", "operationId", "committedAt",
|
||||
"writeQuarantined"
|
||||
]) || !requestIdPattern.test(value.requestId) ||
|
||||
(value.entity !== "theme" && value.entity !== "expert") ||
|
||||
!mutationOperations.has(value.operation) ||
|
||||
!(value.entity === "theme" ? themeCodePattern : expertCodePattern).test(value.identityCode) ||
|
||||
!uuidPattern.test(value.operationId) || !isSafeCanonicalText(value.committedAt, 64) ||
|
||||
!Number.isFinite(Date.parse(value.committedAt)) || value.writeQuarantined !== false) return null;
|
||||
if (expected && ["requestId", "entity", "operation", "identityCode"]
|
||||
.some(key => value[key] !== expected[key])) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
const errorCodes = new Set([
|
||||
"INVALID_REQUEST", "DATABASE_UNAVAILABLE", "INVALID_DATA", "INVALID_SCHEMA", "DATABASE_ERROR",
|
||||
"PLAYOUT_PRIORITY", "PLAYOUT_ACTIVE", "DATABASE_BUSY", "WRITE_BUSY", "CONFLICT", "WRITE_FAILED",
|
||||
"CANCELLED", "OUTCOME_UNKNOWN"
|
||||
]);
|
||||
function normalizeCatalogError(value, expected) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "entity", "operation", "identityCode", "code", "message", "retryable",
|
||||
"outcomeUnknown", "writeQuarantined"
|
||||
]) || (value.requestId !== "" && !requestIdPattern.test(value.requestId)) ||
|
||||
!["theme", "expert", "catalog"].includes(value.entity) ||
|
||||
!["list", "nextCode", "create", "replace", "delete", "schemaPreflight"].includes(value.operation) ||
|
||||
typeof value.identityCode !== "string" || value.identityCode.length > 8 ||
|
||||
!errorCodes.has(value.code) || !isSafeCanonicalText(value.message, 512) ||
|
||||
typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" ||
|
||||
typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.outcomeUnknown && (value.retryable || !value.writeQuarantined)) ||
|
||||
(mutationOperations.has(value.operation) && value.retryable)) return null;
|
||||
if (expected && ["requestId", "entity", "operation", "identityCode"]
|
||||
.some(key => value[key] !== expected[key])) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function describeMutationRequest(type, payload) {
|
||||
try {
|
||||
switch (type) {
|
||||
case bridgeContract.requests.themeCreate: {
|
||||
const request = createThemeCreateRequest(payload?.requestId, payload?.draft);
|
||||
return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "create",
|
||||
identityCode: request.draft.themeCode });
|
||||
}
|
||||
case bridgeContract.requests.themeReplace: {
|
||||
if (!hasExactKeys(payload, ["requestId", "expectedIdentity", "newTitle", "items"])) return null;
|
||||
const request = createThemeReplaceRequest(payload.requestId, {
|
||||
expectedIdentity: payload.expectedIdentity,
|
||||
draft: {
|
||||
market: payload.expectedIdentity?.market,
|
||||
themeCode: payload.expectedIdentity?.themeCode,
|
||||
themeTitle: payload.newTitle,
|
||||
items: payload.items
|
||||
}
|
||||
});
|
||||
return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "replace",
|
||||
identityCode: request.expectedIdentity.themeCode });
|
||||
}
|
||||
case bridgeContract.requests.themeDelete: {
|
||||
if (!hasExactKeys(payload, ["requestId", "identity"])) return null;
|
||||
const request = createThemeDeleteRequest(payload.requestId, payload.identity);
|
||||
return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "delete",
|
||||
identityCode: request.identity.themeCode });
|
||||
}
|
||||
case bridgeContract.requests.expertCreate: {
|
||||
const request = createExpertCreateRequest(payload?.requestId, payload?.draft);
|
||||
return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "create",
|
||||
identityCode: request.draft.expertCode });
|
||||
}
|
||||
case bridgeContract.requests.expertReplace: {
|
||||
if (!hasExactKeys(payload, ["requestId", "expectedIdentity", "newName", "recommendations"])) return null;
|
||||
const request = createExpertReplaceRequest(payload.requestId, {
|
||||
expectedIdentity: payload.expectedIdentity,
|
||||
draft: {
|
||||
expertCode: payload.expectedIdentity?.expertCode,
|
||||
expertName: payload.newName,
|
||||
recommendations: payload.recommendations
|
||||
}
|
||||
});
|
||||
return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "replace",
|
||||
identityCode: request.expectedIdentity.expertCode });
|
||||
}
|
||||
case bridgeContract.requests.expertDelete: {
|
||||
if (!hasExactKeys(payload, ["requestId", "identity"])) return null;
|
||||
const request = createExpertDeleteRequest(payload.requestId, payload.identity);
|
||||
return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "delete",
|
||||
identityCode: request.identity.expertCode });
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createMutationCoordinator() {
|
||||
let active = null;
|
||||
let quarantined = false;
|
||||
return Object.freeze({
|
||||
begin(type, payload) {
|
||||
if (quarantined) throw new Error("Operator catalog writes are quarantined for this process.");
|
||||
if (active) throw new Error("Another operator catalog write is already active.");
|
||||
const description = describeMutationRequest(type, payload);
|
||||
if (!description) throw new TypeError("A valid operator catalog mutation request is required.");
|
||||
active = description;
|
||||
return description;
|
||||
},
|
||||
acceptResult(payload) {
|
||||
if (!active) return null;
|
||||
const result = normalizeMutationResult(payload, active);
|
||||
if (!result) return null;
|
||||
active = null;
|
||||
return result;
|
||||
},
|
||||
acceptError(payload) {
|
||||
if (!active) return null;
|
||||
const error = normalizeCatalogError(payload, active);
|
||||
if (!error) return null;
|
||||
if (error.outcomeUnknown || error.writeQuarantined) quarantined = true;
|
||||
active = null;
|
||||
return error;
|
||||
},
|
||||
loseCorrelation() {
|
||||
if (active) quarantined = true;
|
||||
active = null;
|
||||
},
|
||||
getState() {
|
||||
return Object.freeze({ active, quarantined });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
bridgeContract,
|
||||
limits: Object.freeze({
|
||||
maximumThemeItems: MAXIMUM_THEME_ITEMS,
|
||||
maximumRecommendations: MAXIMUM_RECOMMENDATIONS,
|
||||
maximumResults: MAXIMUM_RESULTS,
|
||||
maximumQueryLength: MAXIMUM_QUERY_LENGTH
|
||||
}),
|
||||
normalizeThemeIdentity,
|
||||
normalizeThemeDraft,
|
||||
normalizeExpertIdentity,
|
||||
normalizeExpertDraft,
|
||||
createThemeCatalogListRequest,
|
||||
createExpertCatalogListRequest,
|
||||
createSchemaPreflightRequest,
|
||||
createThemeNextCodeRequest,
|
||||
createExpertNextCodeRequest,
|
||||
normalizeThemeCatalogSelection,
|
||||
normalizeExpertCatalogSelection,
|
||||
createThemeEditDraft,
|
||||
createExpertEditDraft,
|
||||
createBlankThemeDraft,
|
||||
createBlankExpertDraft,
|
||||
createThemeCreateRequest,
|
||||
createThemeReplaceRequest,
|
||||
createThemeDeleteRequest,
|
||||
createExpertCreateRequest,
|
||||
createExpertReplaceRequest,
|
||||
createExpertDeleteRequest,
|
||||
normalizeThemeListResult,
|
||||
normalizeExpertListResult,
|
||||
normalizeThemeNextCodeResult,
|
||||
normalizeExpertNextCodeResult,
|
||||
normalizeSchemaResult,
|
||||
normalizeMutationResult,
|
||||
normalizeCatalogError,
|
||||
describeMutationRequest,
|
||||
createMutationCoordinator
|
||||
};
|
||||
});
|
||||
440
Web/operator-workflow.js
Normal file
440
Web/operator-workflow.js
Normal file
@@ -0,0 +1,440 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnOperatorWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_FADE_DURATION = 6;
|
||||
const MAX_TEXT_LENGTH = 128;
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
|
||||
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
|
||||
const runtimeStockAsset = Object.freeze({
|
||||
relativePath: "bin/Debug/Res/종목.ini",
|
||||
sha256: "45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20"
|
||||
});
|
||||
|
||||
function freezeCut(cut) {
|
||||
return Object.freeze({
|
||||
...cut,
|
||||
section: cut.group,
|
||||
alias: cut.aliases[0],
|
||||
cutCode: cut.aliases[0],
|
||||
aliases: Object.freeze([...cut.aliases])
|
||||
});
|
||||
}
|
||||
|
||||
// bin/Debug/Res/종목.ini (SHA-256 45DFB180...940409E20), in runtime order.
|
||||
// The two '-' separator rows are deliberately absent. The source RES copy differs
|
||||
// at the after-hours label, so the operator-visible runtime asset is authoritative.
|
||||
const stockCuts = Object.freeze([
|
||||
freezeCut({ id: "basic-expected", label: "1열판기본_예상체결가", group: "1열판 기본", detail: "예상체결가", builderKey: "s5001", aliases: ["5001"], kind: "basic", subtype: "EXPECTED_OPENING" }),
|
||||
freezeCut({ id: "basic-current", label: "1열판기본_현재가", group: "1열판 기본", detail: "현재가", builderKey: "s5001", aliases: ["5001", "N5001"], kind: "basic", subtype: "CURRENT" }),
|
||||
freezeCut({ id: "basic-after-hours", label: "1열판기본_시간외단일가", group: "1열판 기본", detail: "시간외단일가", builderKey: "s5001", aliases: ["5001"], kind: "basic", subtype: "AFTER_HOURS_SINGLE_PRICE" }),
|
||||
|
||||
freezeCut({ id: "detail-open", label: "1열판상세_시가", group: "1열판 상세", detail: "시가", builderKey: "s5006", aliases: ["5006"], kind: "open-detail", subtype: "시가" }),
|
||||
freezeCut({ id: "detail-face-value", label: "1열판상세_액면가", group: "1열판 상세", detail: "액면가", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "FaceValue" }),
|
||||
freezeCut({ id: "detail-pbr", label: "1열판상세_PBR", group: "1열판 상세", detail: "PBR", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "Valuation" }),
|
||||
freezeCut({ id: "detail-volume", label: "1열판상세_거래량", group: "1열판 상세", detail: "거래량", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "Volume" }),
|
||||
|
||||
freezeCut({ id: "yield-5d", label: "수익률그래프_5일", group: "수익률 그래프", detail: "5일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "5일" }),
|
||||
freezeCut({ id: "yield-20d", label: "수익률그래프_20일", group: "수익률 그래프", detail: "20일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "20일" }),
|
||||
freezeCut({ id: "yield-60d", label: "수익률그래프_60일", group: "수익률 그래프", detail: "60일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "60일" }),
|
||||
freezeCut({ id: "yield-120d", label: "수익률그래프_120일", group: "수익률 그래프", detail: "120일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "120일" }),
|
||||
freezeCut({ id: "yield-240d", label: "수익률그래프_240일", group: "수익률 그래프", detail: "240일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "240일" }),
|
||||
|
||||
freezeCut({ id: "candle-daily", label: "캔들그래프_일봉", group: "캔들 그래프", detail: "일봉", builderKey: "s8010", aliases: ["8035"], kind: "candle", subtype: "PRICE" }),
|
||||
freezeCut({ id: "candle-5d", label: "캔들그래프_5일", group: "캔들 그래프", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "PRICE" }),
|
||||
freezeCut({ id: "candle-20d", label: "캔들그래프_20일", group: "캔들 그래프", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "PRICE" }),
|
||||
freezeCut({ id: "candle-60d", label: "캔들그래프_60일", group: "캔들 그래프", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "PRICE" }),
|
||||
freezeCut({ id: "candle-120d", label: "캔들그래프_120일", group: "캔들 그래프", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "PRICE" }),
|
||||
freezeCut({ id: "candle-240d", label: "캔들그래프_240일", group: "캔들 그래프", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "PRICE" }),
|
||||
|
||||
freezeCut({ id: "candle-volume-daily", label: "캔들그래프(거래량)_일봉", group: "캔들 그래프 · 거래량", detail: "일봉", builderKey: "s8010", aliases: ["8035"], kind: "candle", subtype: "VOLUME" }),
|
||||
freezeCut({ id: "candle-volume-5d", label: "캔들그래프(거래량)_5일", group: "캔들 그래프 · 거래량", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "VOLUME" }),
|
||||
freezeCut({ id: "candle-volume-20d", label: "캔들그래프(거래량)_20일", group: "캔들 그래프 · 거래량", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "VOLUME" }),
|
||||
freezeCut({ id: "candle-volume-60d", label: "캔들그래프(거래량)_60일", group: "캔들 그래프 · 거래량", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "VOLUME" }),
|
||||
freezeCut({ id: "candle-volume-120d", label: "캔들그래프(거래량)_120일", group: "캔들 그래프 · 거래량", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "VOLUME" }),
|
||||
freezeCut({ id: "candle-volume-240d", label: "캔들그래프(거래량)_240일", group: "캔들 그래프 · 거래량", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "VOLUME" }),
|
||||
|
||||
freezeCut({ id: "candle-expected-5d", label: "캔들그래프(예상체결가)_5일", group: "캔들 그래프 · 예상체결가", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "EXPECTED" }),
|
||||
freezeCut({ id: "candle-expected-20d", label: "캔들그래프(예상체결가)_20일", group: "캔들 그래프 · 예상체결가", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "EXPECTED" }),
|
||||
freezeCut({ id: "candle-expected-60d", label: "캔들그래프(예상체결가)_60일", group: "캔들 그래프 · 예상체결가", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "EXPECTED" }),
|
||||
freezeCut({ id: "candle-expected-120d", label: "캔들그래프(예상체결가)_120일", group: "캔들 그래프 · 예상체결가", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "EXPECTED" }),
|
||||
freezeCut({ id: "candle-expected-240d", label: "캔들그래프(예상체결가)_240일", group: "캔들 그래프 · 예상체결가", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "EXPECTED" }),
|
||||
|
||||
freezeCut({ id: "order-book", label: "호가창_표그래프", group: "표 그래프", detail: "호가창", builderKey: "s8003", aliases: ["8003"], kind: "order-book", subtype: "TABLE_GRAPH" }),
|
||||
freezeCut({ id: "traders", label: "거래원_표그래프", group: "표 그래프", detail: "거래원", builderKey: "s5037", aliases: ["5037"], kind: "traders", subtype: "TABLE_GRAPH" })
|
||||
]);
|
||||
|
||||
if (stockCuts.length !== 31 || new Set(stockCuts.map(cut => cut.id)).size !== 31) {
|
||||
throw new Error("The legacy stock workflow must contain exactly 31 unique cut variants.");
|
||||
}
|
||||
|
||||
function manualAction(id, label, builderKey, alias, table, prerequisite) {
|
||||
return Object.freeze({
|
||||
id,
|
||||
label,
|
||||
builderKey,
|
||||
alias,
|
||||
cutCode: alias,
|
||||
aliases: Object.freeze([alias]),
|
||||
available: false,
|
||||
autoAdd: false,
|
||||
status: "unavailable",
|
||||
prerequisiteTable: table,
|
||||
prerequisites: Object.freeze([
|
||||
`선택 종목에 대한 ${table} 수동 입력 데이터`,
|
||||
prerequisite,
|
||||
"수동 데이터 검증과 명시적 플레이리스트 추가 흐름"
|
||||
])
|
||||
});
|
||||
}
|
||||
|
||||
// These buttons opened GraphE/INPUT_* manual-data workflows in the source program.
|
||||
// A stock search result alone is not sufficient input, so they must never auto-add a cut.
|
||||
const manualStockActions = Object.freeze([
|
||||
manualAction("manual-revenue-mix", "주요매출 구성", "s5076", "5076", "INPUT_PIE", "구성 항목과 비중 및 기준일 입력"),
|
||||
manualAction("manual-growth-metrics", "성장성 지표", "s5079", "5079", "INPUT_GROW", "성장성 지표 값과 분기 입력"),
|
||||
manualAction("manual-sales", "매출액", "s5080", "5080", "INPUT_SELL", "기간별 매출액 수동 값 입력"),
|
||||
manualAction("manual-operating-profit", "영업이익", "s5081", "5081", "INPUT_PROFIT", "기간별 영업이익 수동 값 입력")
|
||||
]);
|
||||
|
||||
const cutsById = new Map(stockCuts.map(cut => [cut.id, cut]));
|
||||
|
||||
function valueFrom(raw, names) {
|
||||
for (const name of names) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, name) && raw[name] !== undefined && raw[name] !== null) {
|
||||
return raw[name];
|
||||
}
|
||||
}
|
||||
|
||||
const keys = Object.keys(raw);
|
||||
for (const name of names) {
|
||||
const key = keys.find(candidate => candidate.toLocaleUpperCase("en-US") === name.toLocaleUpperCase("en-US"));
|
||||
if (key !== undefined && raw[key] !== undefined && raw[key] !== null) return raw[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const text = value.trim();
|
||||
return text.length > 0 && text.length <= MAX_TEXT_LENGTH && !controlCharacterPattern.test(text)
|
||||
? text
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeMarket(value) {
|
||||
const text = String(value ?? "")
|
||||
.trim()
|
||||
.toLocaleUpperCase("en-US")
|
||||
.replace(/[\s-]+/g, "_");
|
||||
const isNxt = text.includes("NXT");
|
||||
if (["KOSPI", "KOSPI_STOCK", "코스피", "코스피종목", "NXT_KOSPI", "KOSPI_NXT", "코스피_NXT", "NXT코스피"].includes(text)) {
|
||||
return { market: "kospi", isNxt };
|
||||
}
|
||||
if (["KOSDAQ", "KOSDAQ_STOCK", "코스닥", "코스닥종목", "NXT_KOSDAQ", "KOSDAQ_NXT", "코스닥_NXT", "NXT코스닥"].includes(text)) {
|
||||
return { market: "kosdaq", isNxt };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeBoolean(value) {
|
||||
if (value === true || value === false) return value;
|
||||
if (value === 1 || value === "1") return true;
|
||||
if (value === 0 || value === "0") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateStockResult(raw) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { valid: false, error: "stock-object-required", value: null };
|
||||
}
|
||||
|
||||
const marketValue = valueFrom(raw, ["market", "MARKET", "marketName", "MARKET_NAME", "groupCode", "GROUP_CODE"]);
|
||||
const normalizedMarket = normalizeMarket(marketValue);
|
||||
if (!normalizedMarket) return { valid: false, error: "unsupported-market", value: null };
|
||||
|
||||
const rawStockName = cleanText(valueFrom(raw, [
|
||||
"stockName", "STOCK_NAME", "F_STOCK_NAME", "F_STOCK_WANNAME", "F_KNAM", "F_INPUT_NAME", "NAME", "WANNAME"
|
||||
]));
|
||||
const rawDisplayName = cleanText(valueFrom(raw, [
|
||||
"displayName", "DISPLAY_NAME", "F_STOCK_WANNAME", "F_STOCK_NAME", "F_KNAM", "F_INPUT_NAME", "NAME", "WANNAME"
|
||||
]));
|
||||
if (!rawStockName && !rawDisplayName) {
|
||||
return { valid: false, error: "stock-name-required", value: null };
|
||||
}
|
||||
|
||||
const rawCode = valueFrom(raw, [
|
||||
"stockCode", "STOCK_CODE", "F_STOCK_CODE", "F_SYMB", "SYMB", "F_PART_CODE", "CODE"
|
||||
]);
|
||||
const stockCode = typeof rawCode === "number" && Number.isSafeInteger(rawCode) && rawCode >= 0
|
||||
? String(rawCode)
|
||||
: cleanText(rawCode);
|
||||
if (!stockCode || !stockCodePattern.test(stockCode)) {
|
||||
return { valid: false, error: "invalid-stock-code", value: null };
|
||||
}
|
||||
|
||||
const explicitNxtValue = valueFrom(raw, ["isNxt", "IS_NXT", "nxt", "NXT"]);
|
||||
const explicitNxt = explicitNxtValue === undefined ? null : normalizeBoolean(explicitNxtValue);
|
||||
if (explicitNxtValue !== undefined && explicitNxt === null) {
|
||||
return { valid: false, error: "invalid-nxt-flag", value: null };
|
||||
}
|
||||
|
||||
const nameImpliesNxt = `${rawStockName || ""} ${rawDisplayName || ""}`.includes("(NXT)");
|
||||
const inferredNxt = normalizedMarket.isNxt || nameImpliesNxt;
|
||||
if (explicitNxt === false && inferredNxt) {
|
||||
return { valid: false, error: "inconsistent-nxt-selection", value: null };
|
||||
}
|
||||
const isNxt = explicitNxt === true || inferredNxt;
|
||||
|
||||
const withoutNxtSuffix = value => value.replace(/\s*\(NXT\)\s*/g, "").trim();
|
||||
const stockName = cleanText(withoutNxtSuffix(rawStockName || rawDisplayName));
|
||||
if (!stockName) return { valid: false, error: "stock-name-required", value: null };
|
||||
const baseDisplayName = cleanText(rawDisplayName || rawStockName);
|
||||
if (!baseDisplayName) return { valid: false, error: "display-name-required", value: null };
|
||||
const displayName = isNxt
|
||||
? `${withoutNxtSuffix(baseDisplayName)}(NXT)`
|
||||
: baseDisplayName;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
value: Object.freeze({
|
||||
market: normalizedMarket.market,
|
||||
stockName,
|
||||
displayName,
|
||||
stockCode,
|
||||
isNxt
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStockResult(raw) {
|
||||
const validation = validateStockResult(raw);
|
||||
return validation.valid ? validation.value : null;
|
||||
}
|
||||
|
||||
function getStockCut(cutId) {
|
||||
return typeof cutId === "string" ? cutsById.get(cutId) || null : null;
|
||||
}
|
||||
|
||||
function isStockCutAllowed(stock, cutId) {
|
||||
const normalized = normalizeStockResult(stock);
|
||||
const cut = getStockCut(cutId);
|
||||
return Boolean(normalized && cut && (!normalized.isNxt || cut.id === "basic-current"));
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new TypeError("Stock playlist options are required.");
|
||||
}
|
||||
const id = cleanText(options.id);
|
||||
if (!id || !identifierPattern.test(id)) {
|
||||
throw new TypeError("A safe stock playlist entry id is required.");
|
||||
}
|
||||
|
||||
const ma5 = options.ma5 === undefined ? false : normalizeBoolean(options.ma5);
|
||||
const ma20 = options.ma20 === undefined ? false : normalizeBoolean(options.ma20);
|
||||
if (ma5 === null || ma20 === null) {
|
||||
throw new TypeError("Moving-average flags must be boolean values.");
|
||||
}
|
||||
|
||||
const fadeDuration = options.fadeDuration === undefined
|
||||
? DEFAULT_FADE_DURATION
|
||||
: Number(options.fadeDuration);
|
||||
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
||||
throw new RangeError("Fade duration must be an integer from 0 through 60.");
|
||||
}
|
||||
return { id, ma5, ma20, fadeDuration };
|
||||
}
|
||||
|
||||
function marketGroup(stock, stockSuffix) {
|
||||
const prefix = stock.isNxt ? "NXT_" : "";
|
||||
return `${prefix}${stock.market.toLocaleUpperCase("en-US")}${stockSuffix}`;
|
||||
}
|
||||
|
||||
function selectionFor(stock, cut, movingAverages) {
|
||||
const common = {
|
||||
subject: stock.displayName,
|
||||
dataCode: stock.stockCode
|
||||
};
|
||||
switch (cut.kind) {
|
||||
case "basic":
|
||||
return {
|
||||
groupCode: marketGroup(stock, ""),
|
||||
...common,
|
||||
graphicType: "1열판기본",
|
||||
subtype: cut.subtype
|
||||
};
|
||||
case "open-detail":
|
||||
return {
|
||||
groupCode: marketGroup(stock, "_STOCK"),
|
||||
...common,
|
||||
graphicType: "1열판상세",
|
||||
subtype: cut.subtype
|
||||
};
|
||||
case "detail":
|
||||
return {
|
||||
groupCode: marketGroup(stock, "_STOCK"),
|
||||
...common,
|
||||
graphicType: "1열판상세",
|
||||
subtype: cut.subtype
|
||||
};
|
||||
case "yield":
|
||||
return {
|
||||
groupCode: marketGroup(stock, ""),
|
||||
...common,
|
||||
graphicType: "수익률그래프",
|
||||
subtype: cut.subtype
|
||||
};
|
||||
case "candle": {
|
||||
const flags = [];
|
||||
if (movingAverages.ma5) flags.push("MA5");
|
||||
if (movingAverages.ma20) flags.push("MA20");
|
||||
return {
|
||||
groupCode: marketGroup(stock, "_STOCK"),
|
||||
...common,
|
||||
graphicType: flags.join(","),
|
||||
subtype: cut.subtype
|
||||
};
|
||||
}
|
||||
case "order-book":
|
||||
return {
|
||||
groupCode: marketGroup(stock, ""),
|
||||
...common,
|
||||
graphicType: "ORDER_BOOK",
|
||||
subtype: "TABLE_GRAPH"
|
||||
};
|
||||
case "traders":
|
||||
return {
|
||||
groupCode: marketGroup(stock, ""),
|
||||
...common,
|
||||
graphicType: "TRADER",
|
||||
subtype: "TABLE_GRAPH"
|
||||
};
|
||||
default:
|
||||
throw new Error("The stock cut mapping is unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
function createStockPlaylistEntry(stock, cutId, options) {
|
||||
const normalized = normalizeStockResult(stock);
|
||||
if (!normalized) throw new TypeError("A valid selected stock is required.");
|
||||
const cut = getStockCut(cutId);
|
||||
if (!cut) throw new RangeError("The selected stock cut is unknown.");
|
||||
if (!isStockCutAllowed(normalized, cut.id)) {
|
||||
throw new RangeError("NXT stocks support only 1열판기본_현재가.");
|
||||
}
|
||||
|
||||
const normalizedOptions = requireOptions(options);
|
||||
const movingAverages = Object.freeze({
|
||||
ma5: cut.kind === "candle" && normalizedOptions.ma5,
|
||||
ma20: cut.kind === "candle" && normalizedOptions.ma20
|
||||
});
|
||||
const selection = Object.freeze(selectionFor(normalized, cut, movingAverages));
|
||||
const code = normalized.isNxt ? "N5001" : cut.aliases[0];
|
||||
const operator = Object.freeze({
|
||||
source: "legacy-stock-workflow",
|
||||
cutId: cut.id,
|
||||
legacyLabel: cut.label,
|
||||
legacyGroup: cut.group,
|
||||
legacyDetail: cut.detail,
|
||||
stock: normalized,
|
||||
movingAverages
|
||||
});
|
||||
|
||||
return {
|
||||
id: normalizedOptions.id,
|
||||
builderKey: cut.builderKey,
|
||||
code,
|
||||
aliases: [...cut.aliases],
|
||||
title: normalized.displayName,
|
||||
detail: cut.label,
|
||||
category: "stock",
|
||||
market: normalized.market,
|
||||
stockName: normalized.stockName,
|
||||
displayName: normalized.displayName,
|
||||
stockCode: normalized.stockCode,
|
||||
isNxt: normalized.isNxt,
|
||||
selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
graphicType: selection.graphicType,
|
||||
subtype: selection.subtype,
|
||||
operator
|
||||
};
|
||||
}
|
||||
|
||||
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 restoreStockPlaylistEntry(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-stock-workflow" ||
|
||||
typeof operator.cutId !== "string" ||
|
||||
!operator.movingAverages || typeof operator.movingAverages !== "object" ||
|
||||
typeof operator.movingAverages.ma5 !== "boolean" ||
|
||||
typeof operator.movingAverages.ma20 !== "boolean") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let recreated;
|
||||
try {
|
||||
recreated = createStockPlaylistEntry(operator.stock, operator.cutId, {
|
||||
id: value.id,
|
||||
ma5: operator.movingAverages.ma5,
|
||||
ma20: operator.movingAverages.ma20,
|
||||
fadeDuration: value.fadeDuration
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trustedScalarKeys = [
|
||||
"id", "builderKey", "code", "title", "detail", "category", "market",
|
||||
"stockName", "displayName", "stockCode", "isNxt", "fadeDuration",
|
||||
"graphicType", "subtype"
|
||||
];
|
||||
if (!trustedScalarKeys.every(key => value[key] === recreated[key]) ||
|
||||
!sameStringArray(value.aliases, recreated.aliases) ||
|
||||
!sameSelection(value.selection, recreated.selection) ||
|
||||
operator.legacyLabel !== recreated.operator.legacyLabel ||
|
||||
operator.legacyGroup !== recreated.operator.legacyGroup ||
|
||||
operator.legacyDetail !== recreated.operator.legacyDetail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
runtimeStockAsset,
|
||||
stockCuts,
|
||||
manualStockActions,
|
||||
normalizeMarket,
|
||||
validateStockResult,
|
||||
normalizeStockResult,
|
||||
getStockCut,
|
||||
isStockCutAllowed,
|
||||
createStockPlaylistEntry,
|
||||
restoreStockPlaylistEntry
|
||||
};
|
||||
});
|
||||
521
Web/overseas-workflow.js
Normal file
521
Web/overseas-workflow.js
Normal file
@@ -0,0 +1,521 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnOverseasWorkflow = 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 symbolPattern = /^[A-Za-z0-9@._$:-]{1,64}$/;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
|
||||
const bridgeContracts = Object.freeze({
|
||||
industry: Object.freeze({
|
||||
requestType: "search-overseas-industries",
|
||||
resultType: "overseas-industry-results",
|
||||
errorType: "overseas-industry-error",
|
||||
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
|
||||
maximumResults: MAXIMUM_RESULTS,
|
||||
maximumQueryLength: MAXIMUM_QUERY_LENGTH
|
||||
}),
|
||||
stock: Object.freeze({
|
||||
requestType: "search-world-stocks",
|
||||
resultType: "world-stock-search-results",
|
||||
errorType: "world-stock-search-error",
|
||||
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
|
||||
maximumResults: MAXIMUM_RESULTS,
|
||||
maximumQueryLength: MAXIMUM_QUERY_LENGTH
|
||||
})
|
||||
});
|
||||
|
||||
const sourceContract = Object.freeze({
|
||||
originalControl: "Control/UC5.cs",
|
||||
industry: Object.freeze({
|
||||
fdtc: "0",
|
||||
nationCodes: Object.freeze(["US"]),
|
||||
lookupField: "F_KNAM",
|
||||
currentCutCode: "5001"
|
||||
}),
|
||||
stock: Object.freeze({
|
||||
fdtc: "1",
|
||||
nationCodes: Object.freeze(["US", "TW"]),
|
||||
lookupField: "F_INPUT_NAME",
|
||||
currentCutCode: "5001",
|
||||
candleCutCodes: Object.freeze(["8061", "8040", "8046", "8051", "8056"])
|
||||
})
|
||||
});
|
||||
|
||||
function action(id, label, builderKey, code, kind, periodDays = null) {
|
||||
return Object.freeze({
|
||||
id,
|
||||
label,
|
||||
builderKey,
|
||||
code,
|
||||
aliases: Object.freeze([code]),
|
||||
kind,
|
||||
periodDays
|
||||
});
|
||||
}
|
||||
|
||||
const industryActions = Object.freeze([
|
||||
action("industry-current", "\uBBF8\uAD6D \uD574\uC678\uC5C5\uC885 \uD604\uC7AC\uAC00 1\uC5F4\uD310", "s5001", "5001", "current")
|
||||
]);
|
||||
const stockActions = Object.freeze([
|
||||
action("stock-current", "\uD574\uC678\uC885\uBAA9 \uD604\uC7AC\uAC00 1\uC5F4\uD310", "s5001", "5001", "current"),
|
||||
action("stock-candle-5d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 5\uC77C", "s8010", "8061", "candle", 5),
|
||||
action("stock-candle-20d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 20\uC77C", "s8010", "8040", "candle", 20),
|
||||
action("stock-candle-60d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 60\uC77C", "s8010", "8046", "candle", 60),
|
||||
action("stock-candle-120d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 120\uC77C", "s8010", "8051", "candle", 120),
|
||||
action("stock-candle-240d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 240\uC77C", "s8010", "8056", "candle", 240)
|
||||
]);
|
||||
const actionsById = new Map(
|
||||
[...industryActions, ...stockActions].map(value => [value.id, value]));
|
||||
|
||||
function hasExactKeys(value, expected) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const actual = Object.keys(value).sort();
|
||||
const required = [...expected].sort();
|
||||
return actual.length === required.length && actual.every((key, index) => key === required[index]);
|
||||
}
|
||||
|
||||
function isSafeCanonicalText(value, maximumLength, allowEmpty = false) {
|
||||
return typeof value === "string" &&
|
||||
value.length <= maximumLength &&
|
||||
(allowEmpty || value.length > 0) &&
|
||||
value === value.trim() &&
|
||||
!unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function normalizeQuery(value) {
|
||||
if (typeof value !== "string" || unsafeTextPattern.test(value)) return null;
|
||||
const query = value.trim();
|
||||
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query) ? query : null;
|
||||
}
|
||||
|
||||
function normalizeSymbol(value) {
|
||||
return isSafeCanonicalText(value, 64) && symbolPattern.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function normalizeIndustry(value) {
|
||||
if (!hasExactKeys(value, [
|
||||
"koreanName", "inputName", "symbol", "nationCode", "fdtc"
|
||||
])) return null;
|
||||
if (!isSafeCanonicalText(value.koreanName, 200) || value.koreanName.includes("|") ||
|
||||
!isSafeCanonicalText(value.inputName, 200) || value.inputName.includes("|") ||
|
||||
!normalizeSymbol(value.symbol) || value.nationCode !== "US" || value.fdtc !== "0") {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
koreanName: value.koreanName,
|
||||
inputName: value.inputName,
|
||||
symbol: value.symbol,
|
||||
nationCode: "US",
|
||||
fdtc: "0"
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStock(value) {
|
||||
if (!hasExactKeys(value, ["inputName", "symbol", "nationCode", "fdtc"])) return null;
|
||||
if (!isSafeCanonicalText(value.inputName, 200) || value.inputName.includes("|") ||
|
||||
!normalizeSymbol(value.symbol) || !["US", "TW"].includes(value.nationCode) ||
|
||||
value.fdtc !== "1") {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
inputName: value.inputName,
|
||||
symbol: value.symbol,
|
||||
nationCode: value.nationCode,
|
||||
fdtc: "1"
|
||||
});
|
||||
}
|
||||
|
||||
function createSearchRequest(requestId, query, maximumResults) {
|
||||
if (!isSafeCanonicalText(requestId, 128) || !identifierPattern.test(requestId)) {
|
||||
throw new TypeError("A safe overseas search request id is required.");
|
||||
}
|
||||
const normalizedQuery = normalizeQuery(query ?? "");
|
||||
if (normalizedQuery === null) {
|
||||
throw new TypeError(`An overseas search query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`);
|
||||
}
|
||||
if (maximumResults !== undefined &&
|
||||
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
|
||||
throw new RangeError(`Overseas results must be limited from 1 through ${MAXIMUM_RESULTS}.`);
|
||||
}
|
||||
const result = { requestId, query: normalizedQuery };
|
||||
if (maximumResults !== undefined) result.maximumResults = maximumResults;
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
function createIndustrySearchRequest(requestId, query = "", maximumResults) {
|
||||
return createSearchRequest(requestId, query, maximumResults);
|
||||
}
|
||||
|
||||
function createStockSearchRequest(requestId, query = "", maximumResults) {
|
||||
return createSearchRequest(requestId, query, maximumResults);
|
||||
}
|
||||
|
||||
function compareUpper(left, right) {
|
||||
const upperLeft = left.toLocaleUpperCase("en-US");
|
||||
const upperRight = right.toLocaleUpperCase("en-US");
|
||||
if (upperLeft < upperRight) return -1;
|
||||
if (upperLeft > upperRight) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function compareOrdinal(left, right) {
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function compareIndustries(left, right) {
|
||||
return compareUpper(left.koreanName, right.koreanName) ||
|
||||
compareOrdinal(left.inputName, right.inputName) ||
|
||||
compareOrdinal(left.symbol, right.symbol);
|
||||
}
|
||||
|
||||
function compareStocks(left, right) {
|
||||
return compareUpper(left.inputName, right.inputName) ||
|
||||
compareOrdinal(left.symbol, right.symbol) ||
|
||||
compareOrdinal(left.nationCode, right.nationCode);
|
||||
}
|
||||
|
||||
function normalizeSearchResponse(value, expectedRequest, normalizeItem, compare, duplicateKeys) {
|
||||
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) {
|
||||
let expected;
|
||||
try {
|
||||
expected = createSearchRequest(
|
||||
expectedRequest.requestId,
|
||||
expectedRequest.query,
|
||||
expectedRequest.maximumResults);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const expectedLimit = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS;
|
||||
if (value.requestId !== expected.requestId || value.query !== expected.query ||
|
||||
value.results.length > expectedLimit) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const keySets = duplicateKeys.map(() => new Set());
|
||||
for (const raw of value.results) {
|
||||
const item = normalizeItem(raw);
|
||||
if (!item) return null;
|
||||
for (let index = 0; index < duplicateKeys.length; index += 1) {
|
||||
const key = duplicateKeys[index](item);
|
||||
if (keySets[index].has(key)) return null;
|
||||
keySets[index].add(key);
|
||||
}
|
||||
if (results.length && compare(results[results.length - 1], item) > 0) return null;
|
||||
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 normalizeIndustrySearchResponse(value, expectedRequest) {
|
||||
return normalizeSearchResponse(
|
||||
value,
|
||||
expectedRequest,
|
||||
normalizeIndustry,
|
||||
compareIndustries,
|
||||
[item => item.koreanName, item => item.inputName, item => item.symbol]);
|
||||
}
|
||||
|
||||
function normalizeStockSearchResponse(value, expectedRequest) {
|
||||
return normalizeSearchResponse(
|
||||
value,
|
||||
expectedRequest,
|
||||
normalizeStock,
|
||||
compareStocks,
|
||||
[item => item.inputName, item => `${item.inputName}\u0000${item.symbol}\u0000${item.nationCode}`]);
|
||||
}
|
||||
|
||||
function normalizeSearchError(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 normalizeIndustrySearchError(value, expectedRequest) {
|
||||
return normalizeSearchError(value, expectedRequest);
|
||||
}
|
||||
|
||||
function normalizeStockSearchError(value, expectedRequest) {
|
||||
return normalizeSearchError(value, expectedRequest);
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options) ||
|
||||
!isSafeCanonicalText(options.id, 128) || !identifierPattern.test(options.id)) {
|
||||
throw new TypeError("Safe overseas playlist options are required.");
|
||||
}
|
||||
const fadeDuration = options.fadeDuration === undefined
|
||||
? DEFAULT_FADE_DURATION
|
||||
: options.fadeDuration;
|
||||
const ma5 = options.ma5 === undefined ? false : options.ma5;
|
||||
const ma20 = options.ma20 === undefined ? false : options.ma20;
|
||||
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
||||
throw new RangeError("Fade duration must be an integer from 0 through 60.");
|
||||
}
|
||||
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 encodeIndustryIdentity(value) {
|
||||
return `${value.inputName}|${value.symbol}|${value.nationCode}|${value.fdtc}`;
|
||||
}
|
||||
|
||||
function encodeStockIdentity(value) {
|
||||
return `${value.symbol}|${value.nationCode}|${value.fdtc}`;
|
||||
}
|
||||
|
||||
function movingAverageFlags(actionValue, options) {
|
||||
const enabled = actionValue.kind === "candle";
|
||||
const movingAverages = Object.freeze({
|
||||
ma5: enabled && options.ma5,
|
||||
ma20: enabled && options.ma20
|
||||
});
|
||||
const flags = [];
|
||||
if (movingAverages.ma5) flags.push("MA5");
|
||||
if (movingAverages.ma20) flags.push("MA20");
|
||||
return Object.freeze({ movingAverages, graphicType: flags.join(",") });
|
||||
}
|
||||
|
||||
function buildOverseasIndustrySelection(industryValue) {
|
||||
const industry = normalizeIndustry(industryValue);
|
||||
if (!industry) throw new TypeError("A valid US overseas-industry identity is required.");
|
||||
return Object.freeze({
|
||||
builderKey: "s5001",
|
||||
code: "5001",
|
||||
aliases: Object.freeze(["5001"]),
|
||||
selection: Object.freeze({
|
||||
groupCode: "FOREIGN_INDUSTRY",
|
||||
subject: industry.koreanName,
|
||||
graphicType: "\u0031\uC5F4\uD310\uAE30\uBCF8",
|
||||
subtype: "CURRENT",
|
||||
dataCode: encodeIndustryIdentity(industry)
|
||||
}),
|
||||
movingAverages: Object.freeze({ ma5: false, ma20: false })
|
||||
});
|
||||
}
|
||||
|
||||
function buildOverseasStockSelection(stockValue, actionId, options = {}) {
|
||||
const stock = normalizeStock(stockValue);
|
||||
const selectedAction = actionsById.get(actionId);
|
||||
if (!stock) throw new TypeError("A valid US/TW overseas-stock identity is required.");
|
||||
if (!selectedAction || !stockActions.includes(selectedAction)) {
|
||||
throw new RangeError("The overseas-stock action is unknown.");
|
||||
}
|
||||
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 flags = movingAverageFlags(selectedAction, { ma5, ma20 });
|
||||
return Object.freeze({
|
||||
builderKey: selectedAction.builderKey,
|
||||
code: selectedAction.code,
|
||||
aliases: selectedAction.aliases,
|
||||
selection: Object.freeze({
|
||||
groupCode: "FOREIGN_STOCK",
|
||||
subject: stock.inputName,
|
||||
graphicType: selectedAction.kind === "current"
|
||||
? "\u0031\uC5F4\uD310\uAE30\uBCF8"
|
||||
: flags.graphicType,
|
||||
subtype: selectedAction.kind === "current" ? "CURRENT" : "PRICE",
|
||||
dataCode: encodeStockIdentity(stock)
|
||||
}),
|
||||
movingAverages: flags.movingAverages
|
||||
});
|
||||
}
|
||||
|
||||
function createOverseasIndustryPlaylistEntry(industryValue, options) {
|
||||
const industry = normalizeIndustry(industryValue);
|
||||
if (!industry) throw new TypeError("A valid US overseas-industry identity is required.");
|
||||
const normalizedOptions = requireOptions(options);
|
||||
const mapping = buildOverseasIndustrySelection(industry);
|
||||
const actionValue = industryActions[0];
|
||||
return {
|
||||
id: normalizedOptions.id,
|
||||
builderKey: mapping.builderKey,
|
||||
code: mapping.code,
|
||||
aliases: [...mapping.aliases],
|
||||
title: industry.koreanName,
|
||||
detail: actionValue.label,
|
||||
category: "plate",
|
||||
market: "overseas",
|
||||
symbol: industry.symbol,
|
||||
nationCode: industry.nationCode,
|
||||
fdtc: industry.fdtc,
|
||||
selection: mapping.selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
graphicType: mapping.selection.graphicType,
|
||||
subtype: mapping.selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-overseas-workflow",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
kind: "industry",
|
||||
actionId: actionValue.id,
|
||||
identity: industry,
|
||||
movingAverages: mapping.movingAverages
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function createOverseasStockPlaylistEntry(stockValue, actionId, options) {
|
||||
const stock = normalizeStock(stockValue);
|
||||
if (!stock) throw new TypeError("A valid US/TW overseas-stock identity is required.");
|
||||
const selectedAction = actionsById.get(actionId);
|
||||
if (!selectedAction || !stockActions.includes(selectedAction)) {
|
||||
throw new RangeError("The overseas-stock action is unknown.");
|
||||
}
|
||||
const normalizedOptions = requireOptions(options);
|
||||
const mapping = buildOverseasStockSelection(stock, actionId, normalizedOptions);
|
||||
return {
|
||||
id: normalizedOptions.id,
|
||||
builderKey: mapping.builderKey,
|
||||
code: mapping.code,
|
||||
aliases: [...mapping.aliases],
|
||||
title: stock.inputName,
|
||||
detail: selectedAction.label,
|
||||
category: selectedAction.kind === "candle" ? "chart" : "stock",
|
||||
market: "overseas",
|
||||
symbol: stock.symbol,
|
||||
nationCode: stock.nationCode,
|
||||
fdtc: stock.fdtc,
|
||||
selection: mapping.selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
graphicType: mapping.selection.graphicType,
|
||||
subtype: mapping.selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-overseas-workflow",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
kind: "stock",
|
||||
actionId,
|
||||
identity: stock,
|
||||
movingAverages: mapping.movingAverages
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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 sameAliases(value, recreated) {
|
||||
return Array.isArray(value.aliases) && value.aliases.length === 1 &&
|
||||
value.aliases[0] === recreated.code;
|
||||
}
|
||||
|
||||
function restoreOverseasPlaylistEntry(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
||||
typeof value.enabled !== "boolean") return null;
|
||||
const operator = value.operator;
|
||||
if (!hasExactKeys(operator, [
|
||||
"source", "schemaVersion", "kind", "actionId", "identity", "movingAverages"
|
||||
]) || operator.source !== "legacy-overseas-workflow" ||
|
||||
operator.schemaVersion !== SCHEMA_VERSION ||
|
||||
!hasExactKeys(operator.movingAverages, ["ma5", "ma20"]) ||
|
||||
typeof operator.movingAverages.ma5 !== "boolean" ||
|
||||
typeof operator.movingAverages.ma20 !== "boolean") return null;
|
||||
|
||||
let recreated;
|
||||
try {
|
||||
recreated = operator.kind === "industry"
|
||||
? createOverseasIndustryPlaylistEntry(operator.identity, {
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration,
|
||||
ma5: operator.movingAverages.ma5,
|
||||
ma20: operator.movingAverages.ma20
|
||||
})
|
||||
: operator.kind === "stock"
|
||||
? createOverseasStockPlaylistEntry(operator.identity, operator.actionId, {
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration,
|
||||
ma5: operator.movingAverages.ma5,
|
||||
ma20: operator.movingAverages.ma20
|
||||
})
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!recreated || operator.actionId !== recreated.operator.actionId) return null;
|
||||
const scalars = [
|
||||
"id", "builderKey", "code", "title", "detail", "category", "market",
|
||||
"symbol", "nationCode", "fdtc", "fadeDuration", "graphicType", "subtype"
|
||||
];
|
||||
if (!scalars.every(key => value[key] === recreated[key]) ||
|
||||
!sameAliases(value, recreated) || !sameSelection(value.selection, recreated.selection) ||
|
||||
JSON.stringify(operator) !== JSON.stringify(recreated.operator)) return null;
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
function isActionAllowed(kind, actionId, identity) {
|
||||
if (kind === "industry") {
|
||||
return actionId === "industry-current" && Boolean(normalizeIndustry(identity));
|
||||
}
|
||||
return kind === "stock" && Boolean(normalizeStock(identity)) &&
|
||||
stockActions.some(value => value.id === actionId);
|
||||
}
|
||||
|
||||
return {
|
||||
bridgeContracts,
|
||||
sourceContract,
|
||||
industryActions,
|
||||
stockActions,
|
||||
normalizeIndustry,
|
||||
normalizeStock,
|
||||
createIndustrySearchRequest,
|
||||
createStockSearchRequest,
|
||||
normalizeIndustrySearchResponse,
|
||||
normalizeStockSearchResponse,
|
||||
normalizeIndustrySearchError,
|
||||
normalizeStockSearchError,
|
||||
isActionAllowed,
|
||||
buildOverseasIndustrySelection,
|
||||
buildOverseasStockSelection,
|
||||
createOverseasIndustryPlaylistEntry,
|
||||
createOverseasStockPlaylistEntry,
|
||||
restoreOverseasPlaylistEntry,
|
||||
refreshOverseasPlaylistEntry: restoreOverseasPlaylistEntry
|
||||
};
|
||||
});
|
||||
@@ -42,6 +42,14 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
function legacyTakeInShortcutAction(snapshot) {
|
||||
if (!snapshot || snapshot.pending) return "blocked";
|
||||
if (!snapshot.takeInAllowed) return "take-in-locked";
|
||||
if (snapshot.engineState === "PROGRAM" || snapshot.onAirCode) return "next-required";
|
||||
if (snapshot.engineState === "PREPARED" || snapshot.preparedCode) return "take-in";
|
||||
return "prepare-then-take-in";
|
||||
}
|
||||
|
||||
function matchesPendingCommand(pending, payload) {
|
||||
return Boolean(
|
||||
pending && payload &&
|
||||
@@ -94,6 +102,7 @@
|
||||
normalizeConnectionState,
|
||||
responseTimeoutFromNative,
|
||||
commandBlockReason,
|
||||
legacyTakeInShortcutAction,
|
||||
matchesPendingCommand,
|
||||
canClearCommandError,
|
||||
playlistSnapshotLocked,
|
||||
|
||||
367
Web/styles.css
367
Web/styles.css
@@ -28,6 +28,7 @@ body { background: var(--bg); color: var(--text); font-size: 13px; }
|
||||
button, input { font: inherit; }
|
||||
|
||||
button { color: inherit; }
|
||||
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
|
||||
|
||||
.app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); width: 100%; height: 100%; }
|
||||
|
||||
@@ -160,6 +161,324 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor
|
||||
.catalog-tabs { display: flex; gap: 5px; padding: 0 13px 11px; border-bottom: 1px solid var(--border-soft); }
|
||||
.catalog-tabs button { padding: 5px 9px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); font-size: 10px; cursor: pointer; }
|
||||
.catalog-tabs button.active { border-color: var(--border); background: var(--surface-3); color: white; }
|
||||
.stock-workflow { display: flex; min-height: 405px; max-height: 56%; flex: 0 0 430px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; }
|
||||
.stock-workflow[hidden] { display: none; }
|
||||
.stock-workflow.collapsed { min-height: 49px; max-height: 49px; flex-basis: 49px; }
|
||||
.stock-workflow.collapsed > :not(.stock-workflow-header) { display: none; }
|
||||
.stock-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 6px; border-bottom: 1px solid var(--border-soft); }
|
||||
.stock-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
|
||||
.stock-workflow-header .panel-kicker { margin-bottom: 3px; }
|
||||
.stock-workflow-tools { display: flex; align-items: center; gap: 5px; }
|
||||
.stock-workflow-tools button { min-height: 23px; padding: 0 7px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); font-size: 7px; cursor: pointer; }
|
||||
.stock-search-form { display: grid; grid-template-columns: minmax(0, 1fr) 48px; gap: 6px; padding: 8px 10px 6px; }
|
||||
.stock-search-form input { min-width: 0; height: 31px; padding: 0 9px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #d4e0ec; font-size: 10px; }
|
||||
.stock-search-form input:focus { border-color: rgba(86,168,255,.68); box-shadow: 0 0 0 2px rgba(86,168,255,.08); }
|
||||
.stock-search-form button { height: 31px; min-height: 31px; border-color: rgba(86,168,255,.35); background: rgba(86,168,255,.1); color: #91c8ff; font-size: 9px; cursor: pointer; }
|
||||
.stock-search-form button:disabled { cursor: wait; opacity: .55; }
|
||||
.stock-search-state { min-height: 27px; margin: 0 10px 6px; padding: 5px 7px; border: 1px solid var(--border-soft); border-radius: 5px; color: #70849c; font-size: 8px; line-height: 1.45; }
|
||||
.stock-search-state.loading { border-color: rgba(86,168,255,.22); color: #8ec4ff; }
|
||||
.stock-search-state.error { border-color: rgba(255,102,128,.22); color: #ff9bad; }
|
||||
.stock-search-state.ready { border-color: rgba(50,213,164,.18); color: #8bdcc4; }
|
||||
.stock-search-results { min-height: 55px; max-height: 112px; margin: 0 10px 7px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
|
||||
.stock-result { display: grid; width: 100%; min-height: 37px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 7px; padding: 5px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.32); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
|
||||
.stock-result:last-child { border-bottom: 0; }
|
||||
.stock-result:hover, .stock-result.selected { background: rgba(86,168,255,.08); }
|
||||
.stock-result.selected { box-shadow: inset 2px 0 var(--mint); }
|
||||
.stock-result strong, .stock-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.stock-result strong { margin-bottom: 2px; color: #d6e2ef; font-size: 9px; }
|
||||
.stock-result small { color: #68809a; font: 7px Consolas, monospace; }
|
||||
.stock-market-badge { padding: 3px 5px; border: 1px solid rgba(86,168,255,.22); border-radius: 5px; color: #8ec4ff; font: 7px Consolas, monospace; white-space: nowrap; }
|
||||
.stock-results-empty { padding: 18px 8px; color: #5f7288; font-size: 8px; text-align: center; }
|
||||
.selected-stock { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 7px; margin: 0 10px 7px; padding: 6px 7px; border: 1px solid rgba(50,213,164,.2); border-radius: 6px; background: rgba(50,213,164,.055); }
|
||||
.selected-stock[hidden] { display: none; }
|
||||
.selected-stock span { color: #6f8da4; font-size: 7px; }
|
||||
.selected-stock strong { overflow: hidden; color: #9ce4cf; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.selected-stock small { color: #6f8da4; font: 7px Consolas, monospace; white-space: nowrap; }
|
||||
.stock-cut-header { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 7px; min-height: 34px; padding: 0 10px; border-top: 1px solid rgba(53,75,99,.25); border-bottom: 1px solid rgba(53,75,99,.25); }
|
||||
.stock-cut-header strong, .stock-cut-header small { display: block; }
|
||||
.stock-cut-header strong { color: #aebed0; font-size: 9px; }
|
||||
.stock-cut-header small { margin-top: 1px; color: #5f7288; font-size: 7px; }
|
||||
.stock-cut-header label { display: flex; align-items: center; gap: 3px; color: #8296ad; font-size: 7px; white-space: nowrap; }
|
||||
.stock-cut-header input { width: 11px; height: 11px; }
|
||||
.stock-cut-list { min-height: 90px; flex: 1; overflow-y: auto; }
|
||||
.stock-cut-section { position: sticky; z-index: 1; top: 0; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.35); background: #0b1827; color: #6f89a4; font: 7px Consolas, monospace; letter-spacing: .05em; }
|
||||
.stock-cut-row { display: grid; min-height: 31px; grid-template-columns: 28px minmax(0, 1fr) 26px; align-items: center; gap: 6px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); cursor: default; }
|
||||
.stock-cut-row:hover { background: rgba(255,255,255,.025); }
|
||||
.stock-cut-row.disabled { opacity: .38; }
|
||||
.stock-cut-number { color: #536b84; font: 7px Consolas, monospace; text-align: center; }
|
||||
.stock-cut-copy strong, .stock-cut-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.stock-cut-copy strong { color: #b6c6d8; font-size: 8px; }
|
||||
.stock-cut-copy small { margin-top: 2px; color: #5f7288; font: 7px Consolas, monospace; }
|
||||
.stock-cut-add { display: grid; width: 24px; height: 24px; min-height: 24px; place-items: center; padding: 0; border-color: #29415b; color: var(--mint); cursor: pointer; }
|
||||
.stock-cut-add:disabled { cursor: not-allowed; opacity: .35; }
|
||||
.stock-cut-separator { height: 5px; border-bottom: 1px solid rgba(53,75,99,.35); background: rgba(255,255,255,.012); }
|
||||
.manual-stock-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; padding: 6px 8px; border-top: 1px solid var(--border-soft); }
|
||||
.manual-stock-actions button { min-width: 0; height: 25px; min-height: 25px; padding: 0 4px; overflow: hidden; color: #7489a0; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.manual-stock-actions button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.legacy-fixed-workflow { display: flex; min-height: 405px; max-height: 64%; flex: 0 0 455px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; }
|
||||
.legacy-fixed-workflow[hidden] { display: none; }
|
||||
.legacy-fixed-workflow.collapsed { min-height: 49px; max-height: 49px; flex-basis: 49px; }
|
||||
.legacy-fixed-workflow.collapsed > :not(.legacy-fixed-header) { display: none; }
|
||||
.legacy-fixed-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 6px; border-bottom: 1px solid var(--border-soft); }
|
||||
.legacy-fixed-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
|
||||
.legacy-fixed-header .panel-kicker { margin-bottom: 3px; }
|
||||
.legacy-fixed-tools { display: flex; align-items: center; gap: 5px; }
|
||||
.legacy-fixed-tools button { min-height: 23px; padding: 0 7px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); font-size: 7px; cursor: pointer; }
|
||||
.legacy-fixed-search { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 4px; height: 36px; margin: 8px 10px 5px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; background: #07111d; }
|
||||
.legacy-fixed-search span { color: #60748c; font-size: 14px; }
|
||||
.legacy-fixed-search input { min-width: 0; border: 0; outline: 0; background: transparent; color: #d4e0ec; font-size: 9px; }
|
||||
.legacy-fixed-state { min-height: 24px; padding: 3px 11px 5px; color: #6d8299; font-size: 7px; }
|
||||
.legacy-fixed-sections { min-height: 0; flex: 1; overflow-y: auto; }
|
||||
.legacy-fixed-section { border-top: 1px solid rgba(53,75,99,.28); }
|
||||
.legacy-fixed-section > header { position: sticky; z-index: 2; top: 0; display: flex; min-height: 28px; align-items: center; justify-content: space-between; padding: 0 9px; background: #0b1827; color: #7890aa; font: 8px Consolas, "Malgun Gothic", sans-serif; }
|
||||
.legacy-fixed-section > header span { color: #536b84; font-size: 7px; }
|
||||
.legacy-fixed-row { display: grid; min-height: 34px; grid-template-columns: 29px minmax(0, 1fr) 26px; align-items: center; gap: 6px; padding: 3px 7px; border-top: 1px solid rgba(53,75,99,.2); }
|
||||
.legacy-fixed-row:hover { background: rgba(255,255,255,.025); }
|
||||
.legacy-fixed-row.unavailable { opacity: .48; }
|
||||
.legacy-fixed-number { color: #536b84; font: 7px Consolas, monospace; text-align: center; }
|
||||
.legacy-fixed-copy strong, .legacy-fixed-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.legacy-fixed-copy strong { color: #bac9d9; font-size: 8px; }
|
||||
.legacy-fixed-copy small { margin-top: 2px; color: #60748c; font: 7px Consolas, monospace; }
|
||||
.legacy-fixed-add { display: grid; width: 24px; height: 24px; min-height: 24px; place-items: center; padding: 0; border-color: #29415b; color: var(--mint); cursor: pointer; }
|
||||
.legacy-fixed-add:disabled { cursor: not-allowed; color: #697b90; opacity: .45; }
|
||||
.industry-workflow { display: flex; min-height: 405px; max-height: 64%; flex: 0 0 455px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; }
|
||||
.industry-workflow[hidden] { display: none; }
|
||||
.industry-workflow.collapsed { min-height: 49px; max-height: 49px; flex-basis: 49px; }
|
||||
.industry-workflow.collapsed > :not(.industry-workflow-header) { display: none; }
|
||||
.industry-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); }
|
||||
.industry-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
|
||||
.industry-workflow-header .panel-kicker { margin-bottom: 3px; }
|
||||
.industry-workflow-tools { display: flex; align-items: center; gap: 4px; }
|
||||
.industry-workflow-tools button { min-height: 23px; padding: 0 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); font-size: 7px; cursor: pointer; }
|
||||
.industry-workflow-tools button:disabled { cursor: wait; opacity: .5; }
|
||||
.industry-search { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 4px; height: 34px; margin: 7px 9px 4px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; background: #07111d; }
|
||||
.industry-search span { color: #60748c; font-size: 14px; }
|
||||
.industry-search input { min-width: 0; border: 0; outline: 0; background: transparent; color: #d4e0ec; font-size: 9px; }
|
||||
.industry-state { min-height: 23px; padding: 3px 10px 5px; color: #6d8299; font-size: 7px; }
|
||||
.industry-state.error { color: #ff9bad; }
|
||||
.industry-results { min-height: 70px; max-height: 105px; margin: 0 9px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
|
||||
.industry-result { display: grid; width: 100%; min-height: 30px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
|
||||
.industry-result:last-child { border-bottom: 0; }
|
||||
.industry-result:hover, .industry-result.selected { background: rgba(86,168,255,.075); }
|
||||
.industry-result strong { overflow: hidden; color: #b9c8d8; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.industry-result small { color: #60748c; font: 7px Consolas, monospace; }
|
||||
.industry-pair { display: grid; min-height: 48px; grid-template-columns: minmax(0,1fr) 27px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
|
||||
.industry-pair div { min-width: 0; }
|
||||
.industry-pair span, .industry-pair strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.industry-pair span { margin-bottom: 2px; color: #5e748c; font-size: 6px; }
|
||||
.industry-pair strong { color: #aebed0; font-size: 8px; }
|
||||
.industry-pair button { min-height: 25px; padding: 0 6px; color: #8298af; font-size: 8px; }
|
||||
.industry-cut-list { min-height: 90px; flex: 1; overflow-y: auto; }
|
||||
.industry-cut-section { position: sticky; z-index: 1; top: 0; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.35); background: #0b1827; color: #6f89a4; font: 7px Consolas, monospace; letter-spacing: .04em; }
|
||||
.industry-cut-row { display: grid; min-height: 31px; grid-template-columns: 28px minmax(0,1fr) 26px; align-items: center; gap: 6px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); }
|
||||
.industry-cut-row:hover { background: rgba(255,255,255,.025); }
|
||||
.industry-cut-row.disabled { opacity: .4; }
|
||||
.industry-cut-number { color: #536b84; font: 7px Consolas, monospace; text-align: center; }
|
||||
.industry-cut-copy strong, .industry-cut-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.industry-cut-copy strong { color: #b6c6d8; font-size: 8px; }
|
||||
.industry-cut-copy small { margin-top: 2px; color: #5f7288; font: 7px Consolas, monospace; }
|
||||
.industry-cut-add { display: grid; width: 24px; height: 24px; min-height: 24px; place-items: center; padding: 0; border-color: #29415b; color: var(--mint); cursor: pointer; }
|
||||
.industry-cut-add:disabled { cursor: not-allowed; opacity: .35; }
|
||||
.comparison-workflow { display: flex; min-height: 560px; max-height: 76%; flex: 0 0 620px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; }
|
||||
.comparison-workflow[hidden] { display: none; }
|
||||
.comparison-workflow-header { display: flex; min-height: 49px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); }
|
||||
.comparison-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
|
||||
.comparison-workflow-header .panel-kicker { margin-bottom: 3px; }
|
||||
.comparison-source-tabs { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.comparison-source-tabs button { min-height: 26px; padding: 0 5px; color: #6f849c; font-size: 7px; }
|
||||
.comparison-source-tabs button.active { border-color: rgba(50,213,164,.35); background: rgba(50,213,164,.08); color: #8be2c8; }
|
||||
.comparison-source-panel { flex: 0 0 auto; }
|
||||
.comparison-source-panel[hidden] { display: none; }
|
||||
.comparison-search-form { display: grid; grid-template-columns: minmax(0,1fr) 45px; gap: 5px; padding: 6px 8px 4px; }
|
||||
.comparison-search-form input { min-width: 0; height: 29px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #d4e0ec; font-size: 8px; }
|
||||
.comparison-search-form button { min-height: 29px; padding: 0 6px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 8px; }
|
||||
.comparison-search-state { min-height: 21px; padding: 2px 9px 4px; color: #667c94; font-size: 7px; }
|
||||
.comparison-search-state.loading { color: #8ec4ff; }
|
||||
.comparison-search-state.error { color: #ff9bad; }
|
||||
.comparison-search-state.ready { color: #8bdcc4; }
|
||||
.comparison-target-results { min-height: 70px; max-height: 105px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
|
||||
.comparison-target-results.fixed { display: grid; max-height: 125px; grid-template-columns: repeat(2,minmax(0,1fr)); margin-top: 6px; }
|
||||
.comparison-target { display: grid; width: 100%; min-height: 29px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; color: #b9c8d8; text-align: left; cursor: pointer; }
|
||||
.comparison-target:hover { background: rgba(86,168,255,.075); }
|
||||
.comparison-target strong { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.comparison-target small { color: #60748c; font: 6px Consolas,monospace; white-space: nowrap; }
|
||||
.comparison-target-empty { padding: 18px 8px; color: #5f7288; font-size: 8px; text-align: center; }
|
||||
.comparison-draft { display: grid; min-height: 51px; grid-template-columns: minmax(0,1fr) 25px minmax(0,1fr) auto auto; align-items: center; gap: 4px; padding: 5px 7px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
|
||||
.comparison-draft div { min-width: 0; }
|
||||
.comparison-draft span,.comparison-draft strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.comparison-draft span { margin-bottom: 2px; color: #5e748c; font-size: 6px; }
|
||||
.comparison-draft strong { color: #aebed0; font-size: 8px; }
|
||||
.comparison-draft button { min-height: 25px; padding: 0 5px; color: #8298af; font-size: 7px; }
|
||||
.comparison-draft button.primary { border-color: rgba(50,213,164,.3); color: #83dec3; }
|
||||
.comparison-pair-header,.comparison-action-header { display: flex; min-height: 35px; align-items: center; justify-content: space-between; gap: 6px; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.28); }
|
||||
.comparison-pair-header strong,.comparison-pair-header small,.comparison-action-header strong,.comparison-action-header small { display: block; }
|
||||
.comparison-pair-header strong,.comparison-action-header strong { color: #aebed0; font-size: 8px; }
|
||||
.comparison-pair-header small,.comparison-action-header small { margin-top: 1px; color: #5f7288; font-size: 6px; }
|
||||
.comparison-pair-header > div:last-child { display: flex; gap: 3px; }
|
||||
.comparison-pair-header button,.comparison-action-header button { min-height: 23px; padding: 0 5px; color: #8298af; font-size: 6px; }
|
||||
.comparison-pair-list { min-height: 55px; max-height: 95px; overflow-y: auto; }
|
||||
.comparison-pair-row { display: grid; width: 100%; min-height: 31px; grid-template-columns: 25px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 3px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.24); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
|
||||
.comparison-pair-row:hover,.comparison-pair-row.selected { background: rgba(50,213,164,.055); }
|
||||
.comparison-pair-row.selected { box-shadow: inset 2px 0 var(--mint); }
|
||||
.comparison-pair-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; }
|
||||
.comparison-pair-row strong,.comparison-pair-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.comparison-pair-row strong { color: #b6c6d8; font-size: 8px; }
|
||||
.comparison-pair-row small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; }
|
||||
.comparison-pair-row > span:last-child { color: #68809a; font: 6px Consolas,monospace; }
|
||||
.comparison-action-list { min-height: 120px; flex: 1 0 auto; }
|
||||
.comparison-action-section { position: sticky; z-index: 1; top: 0; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.35); background: #0b1827; color: #6f89a4; font: 7px Consolas,monospace; }
|
||||
.comparison-action-row { display: grid; min-height: 31px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); }
|
||||
.comparison-action-row.disabled { opacity: .38; }
|
||||
.comparison-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; }
|
||||
.comparison-action-copy strong,.comparison-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.comparison-action-copy strong { color: #b6c6d8; font-size: 8px; }
|
||||
.comparison-action-copy small { margin-top: 1px; color: #5f7288; font: 6px Consolas,monospace; }
|
||||
.comparison-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); }
|
||||
.theme-workflow { display: flex; min-height: 545px; max-height: 76%; flex: 0 0 610px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; }
|
||||
.theme-workflow[hidden] { display: none; }
|
||||
.theme-workflow-header { display: flex; min-height: 49px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); }
|
||||
.theme-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
|
||||
.theme-workflow-header .panel-kicker { margin-bottom: 3px; }
|
||||
.theme-search-form { display: grid; grid-template-columns: minmax(0,1fr) 76px 42px; gap: 5px; padding: 7px 8px 5px; }
|
||||
.theme-search-form input,.theme-search-form select { min-width: 0; height: 29px; padding: 0 7px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #c7d5e4; font-size: 7px; }
|
||||
.theme-search-form button { min-height: 29px; padding: 0 5px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 7px; }
|
||||
.theme-search-state { min-height: 22px; padding: 2px 9px 5px; color: #667c94; font-size: 7px; }
|
||||
.theme-search-state.loading,.theme-preview-state.loading { color: #8ec4ff; }
|
||||
.theme-search-state.error,.theme-preview-state.error { color: #ff9bad; }
|
||||
.theme-search-state.ready,.theme-preview-state.ready { color: #8bdcc4; }
|
||||
.theme-results { min-height: 78px; max-height: 118px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
|
||||
.theme-result { display: grid; width: 100%; min-height: 31px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
|
||||
.theme-result:hover,.theme-result.selected { background: rgba(86,168,255,.075); }
|
||||
.theme-result.selected { box-shadow: inset 2px 0 var(--mint); }
|
||||
.theme-result strong,.theme-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.theme-result strong { color: #b9c8d8; font-size: 8px; }
|
||||
.theme-result small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; }
|
||||
.theme-market-badge { padding: 3px 5px; border: 1px solid rgba(86,168,255,.22); border-radius: 5px; color: #8ec4ff; font: 6px Consolas,monospace; }
|
||||
.selected-theme { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 7px; min-height: 47px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
|
||||
.selected-theme[hidden] { display: none; }
|
||||
.selected-theme span,.selected-theme strong,.selected-theme small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.selected-theme span { color: #5e748c; font-size: 6px; }
|
||||
.selected-theme strong { margin: 2px 0; color: #9ce4cf; font-size: 9px; }
|
||||
.selected-theme small { color: #60748c; font: 6px Consolas,monospace; }
|
||||
.selected-theme label { color: #667c94; font-size: 6px; }
|
||||
.selected-theme select { display: block; min-width: 80px; height: 26px; margin-top: 2px; padding: 0 5px; border: 1px solid #29415b; border-radius: 5px; background: #07111d; color: #aebed0; font-size: 7px; }
|
||||
.theme-preview-header { display: flex; min-height: 34px; align-items: center; justify-content: space-between; gap: 7px; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.28); }
|
||||
.theme-preview-header strong,.theme-preview-header small { display: block; }
|
||||
.theme-preview-header strong { color: #aebed0; font-size: 8px; }
|
||||
.theme-preview-header small { color: #5f7288; font-size: 6px; }
|
||||
.theme-preview-header > span { color: #67809a; font: 6px Consolas,monospace; }
|
||||
.theme-preview-state { min-height: 20px; padding: 3px 9px; color: #667c94; font-size: 7px; }
|
||||
.theme-preview-items { min-height: 55px; max-height: 92px; overflow-y: auto; }
|
||||
.theme-preview-item { display: grid; min-height: 25px; grid-template-columns: 28px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 3px 8px; border-bottom: 1px solid rgba(53,75,99,.2); }
|
||||
.theme-preview-item span { color: #536b84; font: 6px Consolas,monospace; text-align: center; }
|
||||
.theme-preview-item strong { overflow: hidden; color: #aebed0; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.theme-preview-item small { color: #60748c; font: 6px Consolas,monospace; }
|
||||
.theme-action-header { min-height: 33px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.28); border-bottom: 1px solid rgba(53,75,99,.28); }
|
||||
.theme-action-header strong,.theme-action-header small { display: block; }
|
||||
.theme-action-header strong { color: #aebed0; font-size: 8px; }
|
||||
.theme-action-header small { margin-top: 1px; color: #5f7288; font-size: 6px; }
|
||||
.theme-action-list { min-height: 120px; }
|
||||
.theme-action-row { display: grid; min-height: 32px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); }
|
||||
.theme-action-row.disabled { opacity: .38; }
|
||||
.theme-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; }
|
||||
.theme-action-copy strong,.theme-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.theme-action-copy strong { color: #b6c6d8; font-size: 8px; }
|
||||
.theme-action-copy small { color: #5f7288; font: 6px Consolas,monospace; }
|
||||
.theme-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); }
|
||||
.theme-crud-notice { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border-top: 1px solid rgba(255,190,80,.14); color: #8a7554; font-size: 6px; }
|
||||
.theme-crud-notice button,.expert-crud-notice button { min-height: 25px; padding: 0 8px; border: 1px solid rgba(255,190,80,.24); border-radius: 6px; background: rgba(255,190,80,.08); color: #d3a85f; cursor: pointer; font-size: 7px; }
|
||||
.theme-crud-notice button:disabled,.expert-crud-notice button:disabled { cursor: not-allowed; opacity: .4; }
|
||||
.overseas-workflow,.expert-workflow { display: flex; min-height: 470px; max-height: 76%; flex: 0 0 540px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; }
|
||||
.overseas-workflow[hidden],.expert-workflow[hidden] { display: none; }
|
||||
.overseas-workflow-header,.expert-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); }
|
||||
.overseas-workflow-header h3,.expert-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
|
||||
.overseas-workflow-header .panel-kicker,.expert-workflow-header .panel-kicker { margin-bottom: 3px; }
|
||||
.overseas-source-tabs { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px; padding: 6px 8px 2px; }
|
||||
.overseas-source-tabs button { min-height: 27px; color: #6f849c; font-size: 7px; }
|
||||
.overseas-source-tabs button.active { border-color: rgba(50,213,164,.35); background: rgba(50,213,164,.08); color: #8be2c8; }
|
||||
.overseas-search-form,.expert-search-form { display: grid; grid-template-columns: minmax(0,1fr) 44px; gap: 5px; padding: 6px 8px 5px; }
|
||||
.overseas-search-form input,.expert-search-form input { min-width: 0; height: 29px; padding: 0 7px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #c7d5e4; font-size: 8px; }
|
||||
.overseas-search-form button,.expert-search-form button { min-height: 29px; padding: 0 5px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 8px; }
|
||||
.overseas-search-state,.expert-search-state,.expert-preview-state { min-height: 22px; padding: 2px 9px 5px; color: #667c94; font-size: 7px; }
|
||||
.overseas-search-state.loading,.expert-search-state.loading,.expert-preview-state.loading { color: #8ec4ff; }
|
||||
.overseas-search-state.error,.expert-search-state.error,.expert-preview-state.error { color: #ff9bad; }
|
||||
.overseas-search-state.ready,.expert-search-state.ready,.expert-preview-state.ready { color: #8bdcc4; }
|
||||
.overseas-results,.expert-results { min-height: 82px; max-height: 135px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
|
||||
.overseas-result,.expert-result { display: grid; width: 100%; min-height: 31px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
|
||||
.overseas-result:hover,.overseas-result.selected,.expert-result:hover,.expert-result.selected { background: rgba(86,168,255,.075); }
|
||||
.overseas-result.selected,.expert-result.selected { box-shadow: inset 2px 0 var(--mint); }
|
||||
.overseas-result strong,.overseas-result small,.expert-result strong,.expert-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.overseas-result strong,.expert-result strong { color: #b9c8d8; font-size: 8px; }
|
||||
.overseas-result small,.expert-result small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; }
|
||||
.overseas-result > span:last-child,.expert-result > span:last-child { padding: 3px 5px; border: 1px solid rgba(86,168,255,.22); border-radius: 5px; color: #8ec4ff; font: 6px Consolas,monospace; }
|
||||
.selected-overseas { display: grid; min-height: 48px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
|
||||
.selected-overseas[hidden],.selected-expert[hidden] { display: none; }
|
||||
.selected-overseas span,.selected-overseas strong,.selected-overseas small,.selected-expert span,.selected-expert strong,.selected-expert small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.selected-overseas span,.selected-expert span { color: #5e748c; font-size: 6px; }
|
||||
.selected-overseas strong,.selected-expert strong { margin: 2px 0; color: #9ce4cf; font-size: 9px; }
|
||||
.selected-overseas small,.selected-expert small { color: #60748c; font: 6px Consolas,monospace; }
|
||||
.overseas-ma-options { display: flex; gap: 6px; color: #72879e; font-size: 7px; }
|
||||
.overseas-ma-options label { display: flex; align-items: center; gap: 3px; white-space: nowrap; }
|
||||
.overseas-action-header,.expert-action-header,.expert-preview-header { display: flex; min-height: 34px; align-items: center; justify-content: space-between; gap: 6px; padding: 5px 8px; border-bottom: 1px solid rgba(53,75,99,.28); }
|
||||
.overseas-action-header strong,.overseas-action-header small,.expert-action-header strong,.expert-action-header small,.expert-preview-header strong,.expert-preview-header small { display: block; }
|
||||
.overseas-action-header strong,.expert-action-header strong,.expert-preview-header strong { color: #aebed0; font-size: 8px; }
|
||||
.overseas-action-header small,.expert-action-header small,.expert-preview-header small { color: #5f7288; font-size: 6px; }
|
||||
.expert-preview-header > span { color: #67809a; font: 6px Consolas,monospace; }
|
||||
.overseas-action-list,.expert-action-list { min-height: 75px; }
|
||||
.overseas-action-row,.expert-action-row { display: grid; min-height: 32px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); }
|
||||
.overseas-action-row.disabled,.expert-action-row.disabled { opacity: .38; }
|
||||
.overseas-action-row > span:first-child,.expert-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; }
|
||||
.overseas-action-copy strong,.overseas-action-copy small,.expert-action-copy strong,.expert-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.overseas-action-copy strong,.expert-action-copy strong { color: #b6c6d8; font-size: 8px; }
|
||||
.overseas-action-copy small,.expert-action-copy small { color: #5f7288; font: 6px Consolas,monospace; }
|
||||
.overseas-action-add,.expert-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); }
|
||||
.selected-expert { min-height: 45px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
|
||||
.expert-preview-items { min-height: 66px; max-height: 116px; overflow-y: auto; }
|
||||
.expert-preview-item { display: grid; min-height: 27px; grid-template-columns: 28px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 3px 8px; border-bottom: 1px solid rgba(53,75,99,.2); }
|
||||
.expert-preview-item span { color: #536b84; font: 6px Consolas,monospace; text-align: center; }
|
||||
.expert-preview-item strong { overflow: hidden; color: #aebed0; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.expert-preview-item small { color: #60748c; font: 6px Consolas,monospace; }
|
||||
.expert-crud-notice { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border-top: 1px solid rgba(255,190,80,.14); color: #8a7554; font-size: 6px; }
|
||||
.trading-halt-workflow { display: flex; min-height: 420px; max-height: 76%; flex: 0 0 475px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; }
|
||||
.trading-halt-workflow[hidden] { display: none; }
|
||||
.trading-halt-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); }
|
||||
.trading-halt-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
|
||||
.trading-halt-workflow-header .panel-kicker { margin-bottom: 3px; }
|
||||
.trading-halt-search-form { display: grid; grid-template-columns: minmax(0,1fr) 44px; gap: 5px; padding: 7px 8px 5px; }
|
||||
.trading-halt-search-form input { min-width: 0; height: 29px; padding: 0 7px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #c7d5e4; font-size: 8px; }
|
||||
.trading-halt-search-form button { min-height: 29px; padding: 0 5px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 8px; }
|
||||
.trading-halt-search-state { min-height: 22px; padding: 2px 9px 5px; color: #667c94; font-size: 7px; }
|
||||
.trading-halt-search-state.loading { color: #8ec4ff; }
|
||||
.trading-halt-search-state.error { color: #ff9bad; }
|
||||
.trading-halt-search-state.ready { color: #8bdcc4; }
|
||||
.trading-halt-results { min-height: 100px; max-height: 175px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
|
||||
.trading-halt-result { display: grid; width: 100%; min-height: 31px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
|
||||
.trading-halt-result:hover,.trading-halt-result.selected { background: rgba(86,168,255,.075); }
|
||||
.trading-halt-result.selected { box-shadow: inset 2px 0 var(--mint); }
|
||||
.trading-halt-result strong,.trading-halt-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trading-halt-result strong { color: #b9c8d8; font-size: 8px; }
|
||||
.trading-halt-result small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; }
|
||||
.trading-halt-market { padding: 3px 5px; border: 1px solid rgba(255,102,128,.2); border-radius: 5px; color: #ff9bad; font: 6px Consolas,monospace; }
|
||||
.selected-trading-halt { display: grid; min-height: 48px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
|
||||
.selected-trading-halt[hidden] { display: none; }
|
||||
.selected-trading-halt span,.selected-trading-halt strong,.selected-trading-halt small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.selected-trading-halt span { color: #5e748c; font-size: 6px; }
|
||||
.selected-trading-halt strong { margin: 2px 0; color: #9ce4cf; font-size: 9px; }
|
||||
.selected-trading-halt small { color: #60748c; font: 6px Consolas,monospace; }
|
||||
.trading-halt-ma-options { display: flex; gap: 6px; color: #72879e; font-size: 7px; }
|
||||
.trading-halt-ma-options label { display: flex; align-items: center; gap: 3px; white-space: nowrap; }
|
||||
.trading-halt-action-header { padding: 6px 8px; border-bottom: 1px solid rgba(53,75,99,.28); }
|
||||
.trading-halt-action-header strong,.trading-halt-action-header small { display: block; }
|
||||
.trading-halt-action-header strong { color: #aebed0; font-size: 8px; }
|
||||
.trading-halt-action-header small { margin-top: 1px; color: #5f7288; font-size: 6px; }
|
||||
.trading-halt-action-list { min-height: 74px; }
|
||||
.trading-halt-action-row { display: grid; min-height: 34px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); }
|
||||
.trading-halt-action-row.disabled { opacity: .38; }
|
||||
.trading-halt-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; }
|
||||
.trading-halt-action-copy strong,.trading-halt-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trading-halt-action-copy strong { color: #b6c6d8; font-size: 8px; }
|
||||
.trading-halt-action-copy small { color: #5f7288; font: 6px Consolas,monospace; }
|
||||
.trading-halt-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); }
|
||||
.live-data-panel { display: flex; min-height: 235px; max-height: 54%; flex: 0 1 365px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; }
|
||||
.live-data-header { display: flex; min-height: 51px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 7px; border-bottom: 1px solid var(--border-soft); }
|
||||
.live-data-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
|
||||
@@ -217,10 +536,43 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor
|
||||
.add-cut:hover { border-color: var(--mint); background: var(--mint-soft); }
|
||||
.catalog-empty { padding: 35px 10px; color: var(--muted); text-align: center; }
|
||||
|
||||
.playlist-actions { display: grid; grid-template-columns: auto 30px 30px auto 1fr auto auto; gap: 5px; padding: 10px 12px; border-bottom: 1px solid var(--border-soft); }
|
||||
.playlist-actions { display: grid; grid-template-columns: auto auto 30px 30px auto auto 1fr auto auto auto; gap: 5px; padding: 10px 12px; border-bottom: 1px solid var(--border-soft); }
|
||||
.playlist-actions button { padding: 0 9px; font-size: 9px; }
|
||||
.playlist-actions button.danger { color: #ff9aad; }
|
||||
.playlist-actions button.primary-soft { border-color: rgba(50,213,164,.23); background: var(--mint-soft); color: var(--mint); }
|
||||
.playlist-actions button.database-soft { border-color: rgba(86,168,255,.25); background: rgba(86,168,255,.1); color: #8fc3fa; }
|
||||
.global-candle-options { display: flex; align-items: center; justify-content: flex-end; min-width: 0; gap: 7px; padding: 0 4px; color: #8196ad; font-size: 8px; white-space: nowrap; }
|
||||
.global-candle-options > span { color: #5f7690; font: 7px Consolas, monospace; letter-spacing: .08em; }
|
||||
.global-candle-options label { display: inline-flex; align-items: center; gap: 3px; cursor: pointer; }
|
||||
.global-candle-options input { width: 13px; height: 13px; margin: 0; accent-color: var(--mint); }
|
||||
.global-candle-options input:disabled + * { opacity: .45; }
|
||||
.named-playlist-panel { flex: 0 0 auto; max-height: 352px; padding: 10px 12px 9px; border-bottom: 1px solid var(--border); background: linear-gradient(145deg, rgba(12,31,49,.98), rgba(8,21,35,.98)); overflow-y: auto; }
|
||||
.named-playlist-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.named-playlist-header h3 { margin: 0; color: #d9e6f4; font-size: 11px; }
|
||||
.named-playlist-header-actions { display: flex; align-items: center; gap: 5px; }
|
||||
.named-playlist-panel button { min-height: 27px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; background: var(--surface-2); color: #9db0c5; font-size: 8px; cursor: pointer; }
|
||||
.named-playlist-panel button:hover:not(:disabled) { border-color: #426487; color: white; }
|
||||
.named-playlist-panel button.primary-soft { border-color: rgba(50,213,164,.25); background: var(--mint-soft); color: var(--mint); }
|
||||
.named-playlist-panel button.danger { border-color: rgba(255,102,128,.2); color: #ff9aad; }
|
||||
.named-playlist-panel button:disabled { cursor: not-allowed; opacity: .38; }
|
||||
.named-playlist-status { min-height: 29px; margin: 8px 0; padding: 6px 8px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,18,30,.72); color: #7f94ab; font-size: 8px; line-height: 1.45; }
|
||||
.named-playlist-status.error { border-color: rgba(255,102,128,.25); color: #ff9aad; }
|
||||
.named-playlist-status.ready { border-color: rgba(50,213,164,.2); color: #8bdcc4; }
|
||||
.named-playlist-status.quarantined { border-color: rgba(255,186,85,.28); color: var(--amber); }
|
||||
.named-playlist-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 5px; }
|
||||
.named-playlist-definitions-label, .named-playlist-create label { color: #637991; font: 7px Consolas, monospace; text-transform: uppercase; }
|
||||
.named-playlist-definitions { width: 100%; min-height: 88px; padding: 3px; border: 1px solid var(--border); border-radius: 6px; outline: none; background: #081522; color: #b8c8da; font: 9px/1.5 Consolas, "Malgun Gothic", sans-serif; }
|
||||
.named-playlist-definitions:focus { border-color: #426487; }
|
||||
.named-playlist-definitions option { padding: 3px 5px; }
|
||||
.named-playlist-selection-summary { min-height: 17px; color: #70869e; font-size: 8px; }
|
||||
.named-playlist-load-actions, .named-playlist-mutation-actions { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.named-playlist-create { display: grid; grid-template-columns: auto 78px auto minmax(100px,1fr) auto; align-items: center; gap: 5px; padding-top: 5px; border-top: 1px solid var(--border-soft); }
|
||||
.named-playlist-create input { min-width: 0; height: 27px; padding: 0 7px; border: 1px solid var(--border); border-radius: 6px; outline: none; background: #081522; color: #c6d4e3; font-size: 8px; }
|
||||
.named-playlist-create input:focus { border-color: #426487; }
|
||||
.named-playlist-guard-summary { margin: 7px 0 0; color: #61778f; font-size: 7px; line-height: 1.45; }
|
||||
.named-playlist-guard-summary.blocked { color: var(--amber); }
|
||||
tbody tr.named-restore-invalid { box-shadow: inset 3px 0 var(--red); }
|
||||
tbody tr.named-page-pending { box-shadow: inset 3px 0 var(--amber); }
|
||||
.table-wrap { position: relative; min-height: 0; flex: 1; overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
thead { position: sticky; z-index: 2; top: 0; background: #0d1929; }
|
||||
@@ -229,10 +581,15 @@ td { height: 47px; border-bottom: 1px solid var(--border-soft); color: #b9c7d8;
|
||||
th, td { padding: 0 8px; }
|
||||
.check-cell { width: 35px; text-align: center; }
|
||||
.number-cell { width: 34px; color: var(--muted-2); text-align: center; }
|
||||
.page-cell { width: 48px; color: var(--muted-2); text-align: center; }
|
||||
.drag-cell { width: 42px; color: var(--muted-2); text-align: center; }
|
||||
.playlist-target-cell { width: 24%; }
|
||||
.playlist-cut-cell { width: 25%; }
|
||||
.playlist-detail-cell { width: 28%; }
|
||||
tbody tr { cursor: pointer; transition: .12s ease; }
|
||||
tbody tr:hover { background: rgba(255,255,255,.022); }
|
||||
tbody tr.active { background: var(--mint-soft); }
|
||||
tbody tr.selected { box-shadow: inset 2px 0 rgba(86,168,255,.8); }
|
||||
tbody tr.dragging { opacity: .42; }
|
||||
tbody tr.drag-over { box-shadow: inset 0 2px var(--mint); }
|
||||
td strong, td small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
@@ -257,6 +614,14 @@ input[type="checkbox"] { accent-color: var(--mint); }
|
||||
.playout-health-item.safety strong { grid-column: 1; color: var(--amber); }
|
||||
.playout-health-item.safety.live-allowed strong { color: var(--mint); }
|
||||
.playout-health-item.safety.live-locked strong { color: var(--red); }
|
||||
.background-control-bar { display: grid; min-height: 42px; grid-template-columns: minmax(0,1fr) auto auto; align-items: center; gap: 6px; margin: 7px 13px 0; padding: 5px 7px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.6); }
|
||||
.background-control-bar > div { min-width: 0; }
|
||||
.background-control-bar small,.background-control-bar strong,.background-control-bar span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.background-control-bar small { color: #5e748c; font: 6px Consolas,monospace; }
|
||||
.background-control-bar strong { margin: 1px 0; color: #aebed0; font-size: 8px; }
|
||||
.background-control-bar span { color: #60748c; font-size: 6px; }
|
||||
.background-control-bar button { min-height: 27px; padding: 0 7px; border-color: #29415b; color: #8fa7c0; font-size: 7px; }
|
||||
.background-control-bar button:disabled { cursor: not-allowed; opacity: .4; }
|
||||
.playout-status-message { min-height: 29px; margin: 7px 13px 0; padding: 7px 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.52); color: #8296ad; font-size: 8px; line-height: 1.45; }
|
||||
.playout-status-message.pending { border-color: rgba(86,168,255,.23); color: #8ec4ff; }
|
||||
.playout-status-message.error { border-color: rgba(255,102,128,.22); color: #ff9bad; }
|
||||
|
||||
325
Web/theme-workflow.js
Normal file
325
Web/theme-workflow.js
Normal file
@@ -0,0 +1,325 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnThemeWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_FADE_DURATION = 6;
|
||||
const SCHEMA_VERSION = 1;
|
||||
const MAXIMUM_PAGE_COUNT = 20;
|
||||
const MAXIMUM_PREVIEW_ITEMS = 240;
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const themeCodePattern = /^[0-9]{8}$/;
|
||||
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
const sourceContract = Object.freeze({
|
||||
originalControl: "Control/UC4.cs",
|
||||
krxResolverGroup: "PAGED_THEME",
|
||||
nxtResolverGroup: "PAGED_NXT_THEME",
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
||||
maximumPreviewItems: MAXIMUM_PREVIEW_ITEMS
|
||||
});
|
||||
|
||||
const themeSorts = Object.freeze([
|
||||
Object.freeze({ id: "INPUT_ORDER", label: "입력순" }),
|
||||
Object.freeze({ id: "GAIN_DESC", label: "상승률순" }),
|
||||
Object.freeze({ id: "GAIN_ASC", label: "하락률순" })
|
||||
]);
|
||||
const themeSortsById = new Map(themeSorts.map(value => [value.id, value]));
|
||||
|
||||
const nxtSessions = Object.freeze([
|
||||
Object.freeze({ id: "PRE_MARKET", label: "프리마켓" }),
|
||||
Object.freeze({ id: "AFTER_MARKET", label: "애프터마켓" })
|
||||
]);
|
||||
|
||||
function action(id, label, builderKey, code, pageSize, valueKind) {
|
||||
return Object.freeze({
|
||||
id,
|
||||
label,
|
||||
builderKey,
|
||||
code,
|
||||
aliases: Object.freeze([code]),
|
||||
pageSize,
|
||||
valueKind
|
||||
});
|
||||
}
|
||||
|
||||
const actions = Object.freeze([
|
||||
action("five-row-current", "5단 표그래프 · 현재가", "s5074", "5074", 5, "CURRENT"),
|
||||
action("five-row-expected", "5단 표그래프 · 예상체결가", "s5074", "5074", 5, "EXPECTED"),
|
||||
action("six-row-current", "6종목 현재가", "s5077", "5077", 6, "CURRENT"),
|
||||
action("twelve-row-current", "12종목 현재가", "s5088", "5088", 12, "CURRENT")
|
||||
]);
|
||||
const actionsById = new Map(actions.map(value => [value.id, value]));
|
||||
|
||||
function cleanText(value, maximumLength = 128) {
|
||||
if (typeof value !== "string") return null;
|
||||
const text = value.trim();
|
||||
return text && text.length <= maximumLength && !controlCharacterPattern.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function normalizeMarket(value) {
|
||||
const text = String(value || "").trim().toLocaleLowerCase("en-US");
|
||||
return text === "krx" || text === "nxt" ? text : null;
|
||||
}
|
||||
|
||||
function normalizeNxtSession(value) {
|
||||
const text = String(value || "")
|
||||
.trim()
|
||||
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
||||
.replace(/-/g, "_")
|
||||
.toLocaleUpperCase("en-US");
|
||||
return text === "PRE_MARKET" || text === "AFTER_MARKET" ? text : null;
|
||||
}
|
||||
|
||||
function isKrxEmptySession(value) {
|
||||
if (value === undefined || value === null || value === "") return true;
|
||||
const text = String(value)
|
||||
.trim()
|
||||
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
||||
.replace(/-/g, "_")
|
||||
.toLocaleUpperCase("en-US");
|
||||
return text === "NOT_APPLICABLE";
|
||||
}
|
||||
|
||||
function themeValidationReason(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return "invalid-theme";
|
||||
const market = normalizeMarket(value.market ?? value.Market);
|
||||
if (!market) return "invalid-theme-market";
|
||||
const rawThemeCode = value.themeCode ?? value.ThemeCode ?? value.code;
|
||||
const themeCode = cleanText(rawThemeCode, 8);
|
||||
if (!themeCode || themeCode !== rawThemeCode || !themeCodePattern.test(themeCode)) {
|
||||
return "invalid-theme-code";
|
||||
}
|
||||
const title = cleanText(value.title ?? value.themeTitle ?? value.ThemeTitle);
|
||||
if (!title || title !== String(value.title ?? value.themeTitle ?? value.ThemeTitle) ||
|
||||
title.includes("(NXT)")) return "invalid-theme-title";
|
||||
const sessionValue = value.session ?? value.Session;
|
||||
if (market === "nxt" && !normalizeNxtSession(sessionValue)) return "nxt-session-required";
|
||||
if (market === "krx" && !isKrxEmptySession(sessionValue)) return "krx-session-must-be-empty";
|
||||
const itemCountValue = value.itemCount ?? value.previewItemCount ??
|
||||
(Array.isArray(value.items) ? value.items.length : null);
|
||||
if (itemCountValue !== null && itemCountValue !== undefined &&
|
||||
(!Number.isInteger(itemCountValue) || itemCountValue < 0 || itemCountValue > MAXIMUM_PREVIEW_ITEMS)) {
|
||||
return "invalid-theme-item-count";
|
||||
}
|
||||
const canonicalDisplay = market === "nxt" ? `${title}(NXT)` : title;
|
||||
if (value.displayTitle !== undefined && value.displayTitle !== canonicalDisplay) {
|
||||
return "invalid-theme-display-title";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTheme(value) {
|
||||
if (themeValidationReason(value)) return null;
|
||||
const market = normalizeMarket(value.market ?? value.Market);
|
||||
const themeCode = value.themeCode ?? value.ThemeCode ?? value.code;
|
||||
const title = value.title ?? value.themeTitle ?? value.ThemeTitle;
|
||||
const itemCountValue = value.itemCount ?? value.previewItemCount ??
|
||||
(Array.isArray(value.items) ? value.items.length : null);
|
||||
const session = market === "nxt" ? normalizeNxtSession(value.session ?? value.Session) : null;
|
||||
return Object.freeze({
|
||||
market,
|
||||
themeCode,
|
||||
title,
|
||||
displayTitle: market === "nxt" ? `${title}(NXT)` : title,
|
||||
session,
|
||||
itemCount: itemCountValue === undefined ? null : itemCountValue
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeThemeSort(value) {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) value = value.id;
|
||||
const id = String(value || "").trim().toLocaleUpperCase("en-US");
|
||||
return themeSortsById.get(id) ?? null;
|
||||
}
|
||||
|
||||
function unsupported(actionId, reason) {
|
||||
return Object.freeze({ actionId, supported: false, reason });
|
||||
}
|
||||
|
||||
function getCompatibility(actionId, themeValue, sortValue) {
|
||||
const selectedAction = actionsById.get(actionId);
|
||||
if (!selectedAction) return unsupported(actionId, "unknown-action");
|
||||
const reason = themeValidationReason(themeValue);
|
||||
if (reason) return unsupported(actionId, reason);
|
||||
const theme = normalizeTheme(themeValue);
|
||||
if (!normalizeThemeSort(sortValue)) return unsupported(actionId, "invalid-theme-sort");
|
||||
if (theme.itemCount === 0) return unsupported(actionId, "theme-preview-is-empty");
|
||||
if (selectedAction.valueKind === "EXPECTED" && theme.market !== "krx") {
|
||||
return unsupported(actionId, "expected-theme-is-krx-only");
|
||||
}
|
||||
return Object.freeze({ actionId, supported: true, reason: null });
|
||||
}
|
||||
|
||||
function isActionAllowed(actionId, themeValue, sortValue) {
|
||||
return getCompatibility(actionId, themeValue, sortValue).supported;
|
||||
}
|
||||
|
||||
function calculatePagePreview(actionId, themeValue) {
|
||||
const selectedAction = actionsById.get(actionId);
|
||||
const theme = normalizeTheme(themeValue);
|
||||
if (!selectedAction || !theme) return null;
|
||||
const maximumItems = selectedAction.pageSize * MAXIMUM_PAGE_COUNT;
|
||||
if (theme.itemCount === null) {
|
||||
return Object.freeze({
|
||||
itemCount: null,
|
||||
accessibleItemCount: null,
|
||||
pageSize: selectedAction.pageSize,
|
||||
pageCount: null,
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
||||
isTruncated: false
|
||||
});
|
||||
}
|
||||
const accessibleItemCount = Math.min(theme.itemCount, maximumItems);
|
||||
return Object.freeze({
|
||||
itemCount: theme.itemCount,
|
||||
accessibleItemCount,
|
||||
pageSize: selectedAction.pageSize,
|
||||
pageCount: Math.ceil(accessibleItemCount / selectedAction.pageSize),
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
||||
isTruncated: theme.itemCount > maximumItems
|
||||
});
|
||||
}
|
||||
|
||||
function buildThemeSelection(actionId, themeValue, sortValue) {
|
||||
const selectedAction = actionsById.get(actionId);
|
||||
const theme = normalizeTheme(themeValue);
|
||||
const sort = normalizeThemeSort(sortValue);
|
||||
const compatibility = getCompatibility(actionId, themeValue, sortValue);
|
||||
if (!selectedAction || !theme || !sort || !compatibility.supported) {
|
||||
throw new RangeError(`The theme action is unavailable: ${compatibility.reason || "invalid-selection"}.`);
|
||||
}
|
||||
const selection = theme.market === "krx"
|
||||
? Object.freeze({
|
||||
groupCode: "PAGED_THEME",
|
||||
subject: theme.title,
|
||||
graphicType: sort.id,
|
||||
subtype: selectedAction.valueKind,
|
||||
dataCode: theme.themeCode
|
||||
})
|
||||
: Object.freeze({
|
||||
groupCode: "PAGED_NXT_THEME",
|
||||
subject: theme.displayTitle,
|
||||
graphicType: theme.session,
|
||||
subtype: sort.id,
|
||||
dataCode: theme.themeCode
|
||||
});
|
||||
return Object.freeze({
|
||||
builderKey: selectedAction.builderKey,
|
||||
code: selectedAction.code,
|
||||
aliases: selectedAction.aliases,
|
||||
selection,
|
||||
pageSize: selectedAction.pageSize,
|
||||
pagePreview: calculatePagePreview(actionId, theme)
|
||||
});
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new TypeError("Theme playlist options are required.");
|
||||
}
|
||||
const id = cleanText(options.id);
|
||||
if (!id || !identifierPattern.test(id)) throw new TypeError("A safe theme playlist id is required.");
|
||||
const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : Number(options.fadeDuration);
|
||||
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
||||
throw new RangeError("Fade duration must be an integer from 0 through 60.");
|
||||
}
|
||||
return { id, fadeDuration };
|
||||
}
|
||||
|
||||
function createThemePlaylistEntry(actionId, themeValue, sortValue, options) {
|
||||
const selectedAction = actionsById.get(actionId);
|
||||
if (!selectedAction) throw new RangeError("The theme action is unknown.");
|
||||
const theme = normalizeTheme(themeValue);
|
||||
if (!theme) throw new RangeError(`The theme selection is unavailable: ${themeValidationReason(themeValue)}.`);
|
||||
const sort = normalizeThemeSort(sortValue);
|
||||
if (!sort) throw new RangeError("The theme selection is unavailable: invalid-theme-sort.");
|
||||
const normalized = requireOptions(options);
|
||||
const mapping = buildThemeSelection(actionId, theme, sort);
|
||||
return {
|
||||
id: normalized.id,
|
||||
builderKey: mapping.builderKey,
|
||||
code: mapping.code,
|
||||
aliases: mapping.aliases,
|
||||
title: theme.displayTitle,
|
||||
detail: `${selectedAction.label} · ${sort.label}`,
|
||||
category: "plate",
|
||||
market: "theme",
|
||||
selection: mapping.selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalized.fadeDuration,
|
||||
graphicType: mapping.selection.graphicType,
|
||||
subtype: mapping.selection.subtype,
|
||||
pageSize: mapping.pageSize,
|
||||
pagePreview: mapping.pagePreview,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-theme-workflow",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
actionId,
|
||||
theme,
|
||||
sort: sort.id,
|
||||
resolverGroup: mapping.selection.groupCode
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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 samePagePreview(left, right) {
|
||||
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
|
||||
return ["itemCount", "accessibleItemCount", "pageSize", "pageCount", "maximumPageCount", "isTruncated"]
|
||||
.every(key => left[key] === right[key]);
|
||||
}
|
||||
|
||||
function restoreThemePlaylistEntry(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-theme-workflow" ||
|
||||
operator.schemaVersion !== SCHEMA_VERSION || !actionsById.has(operator.actionId)) return null;
|
||||
let recreated;
|
||||
try {
|
||||
recreated = createThemePlaylistEntry(operator.actionId, operator.theme, operator.sort, {
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const scalars = [
|
||||
"id", "builderKey", "code", "title", "detail", "category", "market",
|
||||
"fadeDuration", "graphicType", "subtype", "pageSize"
|
||||
];
|
||||
if (!scalars.every(key => value[key] === recreated[key]) ||
|
||||
!Array.isArray(value.aliases) || value.aliases.length !== recreated.aliases.length ||
|
||||
!value.aliases.every((alias, index) => alias === recreated.aliases[index]) ||
|
||||
!sameSelection(value.selection, recreated.selection) ||
|
||||
!samePagePreview(value.pagePreview, recreated.pagePreview) ||
|
||||
operator.resolverGroup !== recreated.operator.resolverGroup) return null;
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
sourceContract,
|
||||
themeSorts,
|
||||
nxtSessions,
|
||||
actions,
|
||||
themeActions: actions,
|
||||
normalizeTheme,
|
||||
normalizeThemeSort,
|
||||
getCompatibility,
|
||||
isActionAllowed,
|
||||
calculatePagePreview,
|
||||
buildThemeSelection,
|
||||
createThemePlaylistEntry,
|
||||
restoreThemePlaylistEntry,
|
||||
refreshThemePlaylistEntry: restoreThemePlaylistEntry
|
||||
};
|
||||
});
|
||||
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