"use strict"; var __assign = (this && this.__assign) || function () { __assign = Object.assign || function(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; return __assign.apply(this, arguments); }; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (g && (g = 0, op[0] && (_ = 0)), _) try { if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [op[0] & 2, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; } }; var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { if (ar || !(i in from)) { if (!ar) ar = Array.prototype.slice.call(from, 0, i); ar[i] = from[i]; } } return to.concat(ar || Array.prototype.slice.call(from)); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.STOCK_ALERT_TYPE_OPTIONS = exports.STOCK_ALERT_LAYOUT_NAME = exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = exports.STOCK_ALERT_TABLE = void 0; exports.resolveVolumeRate5dFromHistory = resolveVolumeRate5dFromHistory; exports.searchStockAlertCandidates = searchStockAlertCandidates; exports.resolveLatestQuoteFromMeta = resolveLatestQuoteFromMeta; exports.resolveLatestQuoteFromNaverRealtime = resolveLatestQuoteFromNaverRealtime; exports.fetchQuotesByCodes = fetchQuotesByCodes; exports.listStockAlerts = listStockAlerts; exports.createStockAlert = createStockAlert; exports.updateStockAlert = updateStockAlert; exports.deleteStockAlert = deleteStockAlert; exports.saveStockAlerts = saveStockAlerts; exports.buildCurrentPriceStockAlertLines = buildCurrentPriceStockAlertLines; exports.buildChangeRateThresholdStockAlertLines = buildChangeRateThresholdStockAlertLines; exports.buildChangeRateAndVolumeSpikeStockAlertCandidates = buildChangeRateAndVolumeSpikeStockAlertCandidates; exports.buildChangeRateAndVolumeSpikeStockAlertLines = buildChangeRateAndVolumeSpikeStockAlertLines; exports.buildStockAlertNotificationIdentity = buildStockAlertNotificationIdentity; exports.sendManagedStockAlertWebPush = sendManagedStockAlertWebPush; exports.sendCurrentPriceStockAlertWebPush = sendCurrentPriceStockAlertWebPush; exports.updateStockAlertLayoutFeatureDescription = updateStockAlertLayoutFeatureDescription; var notification_service_js_1 = require("./notification-service.js"); var client_js_1 = require("../db/client.js"); exports.STOCK_ALERT_TABLE = 'stock_alerts'; exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = 'stock_alert_volume_snapshots'; exports.STOCK_ALERT_LAYOUT_NAME = 'stock알림'; exports.STOCK_ALERT_TYPE_OPTIONS = [ { value: 'all', label: '전체' }, { value: 'price', label: '현재가' }, { value: 'top3', label: '등락폭이 큰 상위3종목' }, ]; var STOCK_ALERT_LABEL_MAP = new Map(exports.STOCK_ALERT_TYPE_OPTIONS.map(function (option) { return [option.value, option.label]; })); var STOCK_ALERT_VALUE_SET = new Set(['price', 'top3']); var KRX_CORP_LIST_URL = 'https://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13'; var KRX_CORP_LIST_CACHE_TTL_MS = 1000 * 60 * 60 * 12; var STOCK_ALERT_NOTIFICATION_TITLE = '현재 주가'; var STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN = 'test.sm-home.cloud'; var STOCK_ALERT_NOTIFICATION_SCOPE = 'schedule-2-stock-alert'; var STOCK_ALERT_NOTIFICATION_TARGET_URL = "https://".concat(STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN, "/?topMenu=play&playMenu=layout"); var KOREA_TIMEZONE = 'Asia/Seoul'; var KOREA_PREOPEN_RESET_HOUR = 5; var KOREA_REGULAR_OPEN_HOUR = 9; var cachedKrxListedStocks = null; function normalizeTimestamp(value) { if (!value) { return new Date().toISOString(); } if (value instanceof Date) { return value.toISOString(); } var parsed = Date.parse(value); return Number.isNaN(parsed) ? new Date().toISOString() : new Date(parsed).toISOString(); } function normalizeAlertType(value) { var normalized = value.trim().toLowerCase(); if (!STOCK_ALERT_VALUE_SET.has(normalized)) { throw new Error('알림유형은 현재가 또는 등락폭이 큰 상위3종목만 저장할 수 있습니다.'); } return normalized; } function normalizeStockCode(value) { var digits = value.replace(/\D+/g, ''); return digits.length === 6 ? digits : ''; } function isFiniteNumber(value) { return typeof value === 'number' && Number.isFinite(value); } function parseLooseNumber(value) { if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value !== 'string') { return null; } var normalized = value.replace(/[^0-9.-]+/g, ''); if (!normalized) { return null; } var parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : null; } function applySign(value, sign) { if (value === null || sign === null || value === 0) { return value; } return Math.abs(value) * sign; } function resolveNaverDirectionSign(compareToPreviousPrice) { var _a, _b; var direction = ((_a = compareToPreviousPrice === null || compareToPreviousPrice === void 0 ? void 0 : compareToPreviousPrice.name) === null || _a === void 0 ? void 0 : _a.trim().toUpperCase()) || ((_b = compareToPreviousPrice === null || compareToPreviousPrice === void 0 ? void 0 : compareToPreviousPrice.code) === null || _b === void 0 ? void 0 : _b.trim()); if (direction === 'FALLING' || direction === '4' || direction === '5') { return -1; } if (direction === 'RISING' || direction === '2') { return 1; } return null; } function resolveSignedNaverChangeRate(rate, compareToPreviousClosePrice, compareToPreviousPrice) { var signedChangeAmount = parseLooseNumber(compareToPreviousClosePrice); if (signedChangeAmount !== null && signedChangeAmount !== 0) { return applySign(rate, signedChangeAmount < 0 ? -1 : 1); } return applySign(rate, resolveNaverDirectionSign(compareToPreviousPrice)); } function resolveCapturedTimestampMs(value) { if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string' && value.trim()) { var parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function extractKoreaHour(timestampMs) { var formatted = new Intl.DateTimeFormat('en-GB', { timeZone: KOREA_TIMEZONE, hour: '2-digit', hour12: false, }).format(new Date(timestampMs)); var hour = Number(formatted); return Number.isFinite(hour) ? hour : null; } function isKoreaMorningResetWindow(timestampMs) { if (!Number.isFinite(timestampMs)) { return false; } var koreaHour = extractKoreaHour(timestampMs); return koreaHour !== null && koreaHour >= KOREA_PREOPEN_RESET_HOUR && koreaHour < KOREA_REGULAR_OPEN_HOUR; } function getAlertTypeLabel(value) { var _a; return (_a = STOCK_ALERT_LABEL_MAP.get(value)) !== null && _a !== void 0 ? _a : value; } function average(values) { if (!values.length) { return null; } var sum = values.reduce(function (acc, value) { return acc + value; }, 0); return sum / values.length; } function resolveVolumeRate5dFromHistory(currentVolume, historicalVolumes) { var _a; var normalizedCurrentVolume = isFiniteNumber(currentVolume) && currentVolume >= 0 ? currentVolume : null; var normalizedVolumes = historicalVolumes.filter(function (value) { return isFiniteNumber(value) && value >= 0; }); if (normalizedVolumes.length < 2) { return null; } var latestVolume = (_a = normalizedCurrentVolume !== null && normalizedCurrentVolume !== void 0 ? normalizedCurrentVolume : normalizedVolumes[normalizedVolumes.length - 1]) !== null && _a !== void 0 ? _a : null; var previousFiveAverage = average(normalizedVolumes.slice(-6, -1)); if (latestVolume === null || previousFiveAverage === null || previousFiveAverage <= 0) { return null; } return (latestVolume / previousFiveAverage) * 100; } function normalizeNonNegativeVolume(value) { var parsed = parseLooseNumber(value); if (!isFiniteNumber(parsed) || parsed < 0) { return null; } return Math.round(parsed); } function calculateVolumeIncreasePercent(currentVolume, previousVolume) { if (!isFiniteNumber(currentVolume) || !isFiniteNumber(previousVolume) || previousVolume <= 0 || currentVolume < previousVolume) { return null; } return ((currentVolume - previousVolume) / previousVolume) * 100; } function normalizeStockAlertVolumeSnapshotRow(row) { var _a; return { stockCode: normalizeStockCode(row.stock_code), stockName: String((_a = row.stock_name) !== null && _a !== void 0 ? _a : '').trim() || normalizeStockCode(row.stock_code), previousVolume: normalizeNonNegativeVolume(row.previous_volume), currentVolume: normalizeNonNegativeVolume(row.current_volume), volumeIncreasePercent: parseLooseNumber(row.volume_increase_percent), currentPrice: parseLooseNumber(row.current_price), changeRate: parseLooseNumber(row.change_rate), quotedAt: row.quoted_at ? normalizeTimestamp(row.quoted_at) : null, createdAt: normalizeTimestamp(row.created_at), updatedAt: normalizeTimestamp(row.updated_at), }; } function buildStockAlertVolumeSnapshotRecord(item, currentVolume, previousSnapshot) { var _a, _b; var now = new Date().toISOString(); var normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume); var previousCurrentVolume = (_a = previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== null && _a !== void 0 ? _a : null; var shouldResetBaseline = normalizedCurrentVolume !== null && previousCurrentVolume !== null && normalizedCurrentVolume < previousCurrentVolume; var comparisonBaseline = shouldResetBaseline ? normalizedCurrentVolume : previousCurrentVolume; var volumeIncreasePercent = calculateVolumeIncreasePercent(normalizedCurrentVolume, comparisonBaseline); var nextPreviousVolume = shouldResetBaseline ? normalizedCurrentVolume : previousCurrentVolume !== null ? previousCurrentVolume : normalizedCurrentVolume; return { stock_code: item.stockCode, stock_name: item.stockName, previous_volume: nextPreviousVolume, current_volume: normalizedCurrentVolume, volume_increase_percent: volumeIncreasePercent, current_price: item.currentPrice, change_rate: item.changeRate, quoted_at: item.quotedAt, created_at: (_b = previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.createdAt) !== null && _b !== void 0 ? _b : now, updated_at: now, }; } function buildStockSymbols(stockCode) { var normalizedCode = normalizeStockCode(stockCode); if (!normalizedCode) { return []; } return ["".concat(normalizedCode, ".KS"), "".concat(normalizedCode, ".KQ")]; } function extractStockCodeFromSymbol(symbol) { var match = symbol.trim().match(/^(\d{6})\.(?:KS|KQ)$/i); return match ? match[1] : ''; } function resolveMarketLabel(quote) { var _a, _b, _c, _d, _e; var symbol = (_b = (_a = quote.symbol) === null || _a === void 0 ? void 0 : _a.trim().toUpperCase()) !== null && _b !== void 0 ? _b : ''; if (symbol.endsWith('.KS')) { return 'KOSPI'; } if (symbol.endsWith('.KQ')) { return 'KOSDAQ'; } var exchange = ((_c = quote.exchDisp) === null || _c === void 0 ? void 0 : _c.trim()) || ((_d = quote.exchange) === null || _d === void 0 ? void 0 : _d.trim()) || ((_e = quote.typeDisp) === null || _e === void 0 ? void 0 : _e.trim()); if (!exchange) { return '기타'; } if (/KOSDAQ/i.test(exchange)) { return 'KOSDAQ'; } if (/KOSPI|KOSE/i.test(exchange)) { return 'KOSPI'; } return exchange; } function ensureStockAlertTable() { return __awaiter(this, void 0, void 0, function () { var exists; return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.STOCK_ALERT_TABLE)]; case 1: exists = _a.sent(); if (exists) { return [2 /*return*/]; } return [4 /*yield*/, client_js_1.db.schema.createTable(exports.STOCK_ALERT_TABLE, function (table) { table.text('id').primary(); table.text('stock_code').notNullable(); table.text('stock_name').notNullable(); table.text('alert_type').notNullable(); table.timestamp('created_at', { useTz: true }).notNullable(); table.timestamp('updated_at', { useTz: true }).notNullable(); })]; case 2: _a.sent(); return [2 /*return*/]; } }); }); } function ensureStockAlertVolumeSnapshotTable() { return __awaiter(this, void 0, void 0, function () { var exists; return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE)]; case 1: exists = _a.sent(); if (exists) { return [2 /*return*/]; } return [4 /*yield*/, client_js_1.db.schema.createTable(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE, function (table) { table.text('stock_code').primary(); table.text('stock_name').notNullable(); table.bigInteger('previous_volume').nullable(); table.bigInteger('current_volume').nullable(); table.decimal('volume_increase_percent', 10, 2).nullable(); table.decimal('current_price', 14, 2).nullable(); table.decimal('change_rate', 10, 4).nullable(); table.timestamp('quoted_at', { useTz: true }).nullable(); table.timestamp('created_at', { useTz: true }).notNullable(); table.timestamp('updated_at', { useTz: true }).notNullable(); })]; case 2: _a.sent(); return [2 /*return*/]; } }); }); } function fetchJson(url, init) { return __awaiter(this, void 0, void 0, function () { var response; var _a; return __generator(this, function (_b) { switch (_b.label) { case 0: return [4 /*yield*/, fetch(url, __assign(__assign({}, init), { headers: __assign({ accept: 'application/json', 'user-agent': 'ai-code-app/stock-alert' }, ((_a = init === null || init === void 0 ? void 0 : init.headers) !== null && _a !== void 0 ? _a : {})) }))]; case 1: response = _b.sent(); if (!response.ok) { throw new Error("\uC678\uBD80 \uC2DC\uC138 \uC751\uB2F5\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. (".concat(response.status, ")")); } return [2 /*return*/, response.json()]; } }); }); } function fetchText(url, init) { return __awaiter(this, void 0, void 0, function () { var response; var _a; return __generator(this, function (_b) { switch (_b.label) { case 0: return [4 /*yield*/, fetch(url, __assign(__assign({}, init), { headers: __assign({ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'user-agent': 'ai-code-app/stock-alert' }, ((_a = init === null || init === void 0 ? void 0 : init.headers) !== null && _a !== void 0 ? _a : {})) }))]; case 1: response = _b.sent(); if (!response.ok) { throw new Error("\uC678\uBD80 \uC885\uBAA9 \uAC80\uC0C9 \uC751\uB2F5\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. (".concat(response.status, ")")); } return [2 /*return*/, response.arrayBuffer()]; } }); }); } function decodeEucKr(value) { return new TextDecoder('euc-kr').decode(value); } function decodeHtmlEntities(value) { return value .replace(/ /gi, ' ') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/'/g, "'") .replace(/"/gi, '"'); } function stripHtmlTags(value) { return decodeHtmlEntities(value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); } function normalizeSearchKeyword(value) { return value.trim().replace(/\s+/g, '').toLowerCase(); } function normalizeMarketLabel(value) { var trimmedValue = value.trim(); if (/코스닥/i.test(trimmedValue)) { return 'KOSDAQ'; } if (/유가증권|코스피|유가/i.test(trimmedValue)) { return 'KOSPI'; } return trimmedValue || '기타'; } function parseKrxListedStocks(html) { var _a; var rowMatches = (_a = html.match(//gi)) !== null && _a !== void 0 ? _a : []; var items = []; rowMatches.forEach(function (rowHtml) { var _a, _b, _c, _d; var cellMatches = (_a = rowHtml.match(//gi)) !== null && _a !== void 0 ? _a : []; if (cellMatches.length < 3) { return; } var stockName = stripHtmlTags((_b = cellMatches[0]) !== null && _b !== void 0 ? _b : ''); var market = normalizeMarketLabel(stripHtmlTags((_c = cellMatches[1]) !== null && _c !== void 0 ? _c : '')); var stockCode = normalizeStockCode(stripHtmlTags((_d = cellMatches[2]) !== null && _d !== void 0 ? _d : '')); if (!stockCode || !stockName) { return; } items.push({ stockCode: stockCode, stockName: stockName, market: market, }); }); return items; } function findKrxListedStockByCode(stockCode) { return __awaiter(this, void 0, void 0, function () { var normalizedCode, items; var _a; return __generator(this, function (_b) { switch (_b.label) { case 0: normalizedCode = normalizeStockCode(stockCode); if (!normalizedCode) { return [2 /*return*/, null]; } return [4 /*yield*/, fetchKrxListedStocks()]; case 1: items = _b.sent(); return [2 /*return*/, (_a = items.find(function (item) { return item.stockCode === normalizedCode; })) !== null && _a !== void 0 ? _a : null]; } }); }); } function fetchKrxListedStocks() { return __awaiter(this, void 0, void 0, function () { var buffer, decodedHtml, items; return __generator(this, function (_a) { switch (_a.label) { case 0: if (cachedKrxListedStocks && cachedKrxListedStocks.expiresAt > Date.now()) { return [2 /*return*/, cachedKrxListedStocks.items]; } return [4 /*yield*/, fetchText(KRX_CORP_LIST_URL)]; case 1: buffer = _a.sent(); decodedHtml = decodeEucKr(buffer); items = parseKrxListedStocks(decodedHtml); cachedKrxListedStocks = { expiresAt: Date.now() + KRX_CORP_LIST_CACHE_TTL_MS, items: items, }; return [2 /*return*/, items]; } }); }); } function searchKrxListedStocks(query_1) { return __awaiter(this, arguments, void 0, function (query, limit) { var normalizedKeyword, items, matchedItems; if (limit === void 0) { limit = 20; } return __generator(this, function (_a) { switch (_a.label) { case 0: normalizedKeyword = normalizeSearchKeyword(query); if (!normalizedKeyword) { return [2 /*return*/, []]; } return [4 /*yield*/, fetchKrxListedStocks()]; case 1: items = _a.sent(); matchedItems = items.filter(function (item) { var normalizedCode = item.stockCode.toLowerCase(); var normalizedName = normalizeSearchKeyword(item.stockName); return normalizedCode.includes(normalizedKeyword) || normalizedName.includes(normalizedKeyword); }); matchedItems.sort(function (left, right) { var trimmedQuery = query.trim(); var leftExactCode = left.stockCode === trimmedQuery ? 1 : 0; var rightExactCode = right.stockCode === trimmedQuery ? 1 : 0; if (leftExactCode !== rightExactCode) { return rightExactCode - leftExactCode; } var leftExactName = normalizeSearchKeyword(left.stockName) === normalizedKeyword ? 1 : 0; var rightExactName = normalizeSearchKeyword(right.stockName) === normalizedKeyword ? 1 : 0; if (leftExactName !== rightExactName) { return rightExactName - leftExactName; } var leftStartsWith = normalizeSearchKeyword(left.stockName).startsWith(normalizedKeyword) ? 1 : 0; var rightStartsWith = normalizeSearchKeyword(right.stockName).startsWith(normalizedKeyword) ? 1 : 0; if (leftStartsWith !== rightStartsWith) { return rightStartsWith - leftStartsWith; } var leftLengthGap = Math.abs(left.stockName.length - trimmedQuery.length); var rightLengthGap = Math.abs(right.stockName.length - trimmedQuery.length); if (leftLengthGap !== rightLengthGap) { return leftLengthGap - rightLengthGap; } return left.stockName.localeCompare(right.stockName, 'ko-KR'); }); return [2 /*return*/, matchedItems.slice(0, Math.max(1, Math.min(50, limit)))]; } }); }); } function resolveStockIdentity(input) { return __awaiter(this, void 0, void 0, function () { var codeFromInput, trimmedName, krxMatch, quotes, quote, krxMatches, exactMatch, searchUrl, payload, matchedQuote, resolvedCode; var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k; return __generator(this, function (_l) { switch (_l.label) { case 0: codeFromInput = normalizeStockCode((_a = input.stockCode) !== null && _a !== void 0 ? _a : ''); trimmedName = (_c = (_b = input.stockName) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : ''; if (!codeFromInput) return [3 /*break*/, 3]; return [4 /*yield*/, findKrxListedStockByCode(codeFromInput)]; case 1: krxMatch = _l.sent(); if (krxMatch) { return [2 /*return*/, { stockCode: codeFromInput, stockName: krxMatch.stockName, }]; } if (trimmedName) { return [2 /*return*/, { stockCode: codeFromInput, stockName: trimmedName, }]; } return [4 /*yield*/, fetchQuotesByCodes([codeFromInput])]; case 2: quotes = _l.sent(); quote = quotes.get(codeFromInput); if (quote) { return [2 /*return*/, { stockCode: codeFromInput, stockName: (_d = quote.stockName) !== null && _d !== void 0 ? _d : codeFromInput, }]; } return [2 /*return*/, { stockCode: codeFromInput, stockName: codeFromInput, }]; case 3: if (!trimmedName) { throw new Error('종목명을 입력해 주세요.'); } return [4 /*yield*/, searchKrxListedStocks(trimmedName, 10)]; case 4: krxMatches = _l.sent(); exactMatch = (_f = (_e = krxMatches.find(function (item) { return normalizeSearchKeyword(item.stockName) === normalizeSearchKeyword(trimmedName); })) !== null && _e !== void 0 ? _e : krxMatches[0]) !== null && _f !== void 0 ? _f : null; if (exactMatch) { return [2 /*return*/, { stockCode: exactMatch.stockCode, stockName: exactMatch.stockName, }]; } searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search'); searchUrl.searchParams.set('q', trimmedName); searchUrl.searchParams.set('quotesCount', '10'); searchUrl.searchParams.set('newsCount', '0'); searchUrl.searchParams.set('lang', 'ko-KR'); searchUrl.searchParams.set('region', 'KR'); return [4 /*yield*/, fetchJson(searchUrl)]; case 5: payload = _l.sent(); matchedQuote = (_h = (_g = payload.quotes) === null || _g === void 0 ? void 0 : _g.find(function (quote) { return typeof quote.symbol === 'string' && extractStockCodeFromSymbol(quote.symbol).length === 6; })) !== null && _h !== void 0 ? _h : null; if (!(matchedQuote === null || matchedQuote === void 0 ? void 0 : matchedQuote.symbol)) { throw new Error("\uC885\uBAA9\uBA85 \"".concat(trimmedName, "\"\uC5D0 \uD574\uB2F9\uD558\uB294 \uC885\uBAA9\uCF54\uB4DC\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.")); } resolvedCode = extractStockCodeFromSymbol(matchedQuote.symbol); if (!resolvedCode) { throw new Error("\uC885\uBAA9\uBA85 \"".concat(trimmedName, "\"\uC5D0 \uD574\uB2F9\uD558\uB294 \uC885\uBAA9\uCF54\uB4DC\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.")); } return [2 /*return*/, { stockCode: resolvedCode, stockName: ((_j = matchedQuote.shortname) === null || _j === void 0 ? void 0 : _j.trim()) || ((_k = matchedQuote.longname) === null || _k === void 0 ? void 0 : _k.trim()) || trimmedName, }]; } }); }); } function searchYahooStocks(query_1) { return __awaiter(this, arguments, void 0, function (query, quotesCount) { var trimmedQuery, searchUrl, payload, error_1; var _a; if (quotesCount === void 0) { quotesCount = 20; } return __generator(this, function (_b) { switch (_b.label) { case 0: trimmedQuery = query.trim(); if (!trimmedQuery) { return [2 /*return*/, []]; } searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search'); searchUrl.searchParams.set('q', trimmedQuery); searchUrl.searchParams.set('quotesCount', String(Math.max(1, Math.min(50, quotesCount)))); searchUrl.searchParams.set('newsCount', '0'); searchUrl.searchParams.set('lang', 'ko-KR'); searchUrl.searchParams.set('region', 'KR'); _b.label = 1; case 1: _b.trys.push([1, 3, , 4]); return [4 /*yield*/, fetchJson(searchUrl)]; case 2: payload = _b.sent(); return [2 /*return*/, (_a = payload.quotes) !== null && _a !== void 0 ? _a : []]; case 3: error_1 = _b.sent(); // Yahoo search rejects some non-code Korean queries with HTTP 400. if (/[^\x00-\x7F]/.test(trimmedQuery)) { return [2 /*return*/, []]; } throw error_1; case 4: return [2 /*return*/]; } }); }); } function searchStockAlertCandidates(query_1) { return __awaiter(this, arguments, void 0, function (query, limit) { var normalizedLimit, _a, krxItems, quotes, seenCodes, items, krxByCode; if (limit === void 0) { limit = 20; } return __generator(this, function (_b) { switch (_b.label) { case 0: normalizedLimit = Math.max(1, Math.min(50, limit)); return [4 /*yield*/, Promise.all([ searchKrxListedStocks(query, normalizedLimit), searchYahooStocks(query, normalizedLimit * 2), ])]; case 1: _a = _b.sent(), krxItems = _a[0], quotes = _a[1]; seenCodes = new Set(); items = __spreadArray([], krxItems, true); krxByCode = new Map(krxItems.map(function (item) { return [item.stockCode, item]; })); krxItems.forEach(function (item) { seenCodes.add(item.stockCode); }); quotes.forEach(function (quote) { var _a, _b, _c, _d; if (!quote.symbol) { return; } var stockCode = extractStockCodeFromSymbol(quote.symbol); if (!stockCode || seenCodes.has(stockCode)) { return; } var stockName = ((_a = quote.shortname) === null || _a === void 0 ? void 0 : _a.trim()) || ((_b = quote.longname) === null || _b === void 0 ? void 0 : _b.trim()) || stockCode; var krxMatch = krxByCode.get(stockCode); seenCodes.add(stockCode); items.push({ stockCode: stockCode, stockName: (_c = krxMatch === null || krxMatch === void 0 ? void 0 : krxMatch.stockName) !== null && _c !== void 0 ? _c : stockName, market: (_d = krxMatch === null || krxMatch === void 0 ? void 0 : krxMatch.market) !== null && _d !== void 0 ? _d : resolveMarketLabel(quote), }); }); return [2 /*return*/, items.slice(0, normalizedLimit)]; } }); }); } function ensureNoDuplicateStockCode(stockCode, currentId) { return __awaiter(this, void 0, void 0, function () { var normalizedCode, existing; return __generator(this, function (_a) { switch (_a.label) { case 0: normalizedCode = normalizeStockCode(stockCode); if (!normalizedCode) { throw new Error('종목코드를 확인할 수 없습니다.'); } return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) .select('id') .where({ stock_code: normalizedCode }) .modify(function (query) { if (currentId === null || currentId === void 0 ? void 0 : currentId.trim()) { query.whereNot('id', currentId.trim()); } }) .first()]; case 1: existing = (_a.sent()); if (existing === null || existing === void 0 ? void 0 : existing.id) { throw new Error('이미 추가된 종목입니다.'); } return [2 /*return*/]; } }); }); } function resolveLatestQuoteFromMeta(meta) { var _a, _b, _c, _d; var marketState = String((_a = meta.marketState) !== null && _a !== void 0 ? _a : '') .trim() .toUpperCase(); var shouldPreferPremarket = ['PRE', 'PREPRE'].includes(marketState); var shouldPreferPostmarket = ['POST', 'POSTPOST', 'CLOSED'].includes(marketState); var preferredCandidate = shouldPreferPremarket ? { price: meta.preMarketPrice, changeRate: meta.preMarketChangePercent, time: meta.preMarketTime, } : shouldPreferPostmarket ? { price: meta.postMarketPrice, changeRate: meta.postMarketChangePercent, time: meta.postMarketTime, } : { price: meta.regularMarketPrice, changeRate: meta.regularMarketChangePercent, time: meta.regularMarketTime, }; var quoteCandidates = [ { price: meta.regularMarketPrice, changeRate: meta.regularMarketChangePercent, time: meta.regularMarketTime, }, { price: meta.preMarketPrice, changeRate: meta.preMarketChangePercent, time: meta.preMarketTime, }, { price: meta.postMarketPrice, changeRate: meta.postMarketChangePercent, time: meta.postMarketTime, }, ]; var latestCandidate = (_b = quoteCandidates .flatMap(function (item) { return isFiniteNumber(item.price) && isFiniteNumber(item.time) ? [ { price: item.price, changeRate: item.changeRate, time: item.time, }, ] : []; }) .sort(function (left, right) { return right.time - left.time; })[0]) !== null && _b !== void 0 ? _b : null; var resolvedCandidate = isFiniteNumber(preferredCandidate.price) && isFiniteNumber(preferredCandidate.time) ? preferredCandidate : latestCandidate; var currentPrice = isFiniteNumber(resolvedCandidate === null || resolvedCandidate === void 0 ? void 0 : resolvedCandidate.price) ? resolvedCandidate.price : isFiniteNumber(meta.regularMarketPrice) ? meta.regularMarketPrice : null; var quotedAt = isFiniteNumber(resolvedCandidate === null || resolvedCandidate === void 0 ? void 0 : resolvedCandidate.time) ? new Date(resolvedCandidate.time * 1000).toISOString() : isFiniteNumber(meta.regularMarketTime) ? new Date(meta.regularMarketTime * 1000).toISOString() : null; var previousClose = typeof meta.chartPreviousClose === 'number' ? meta.chartPreviousClose : typeof meta.previousClose === 'number' ? meta.previousClose : null; var changeRate = isFiniteNumber(resolvedCandidate === null || resolvedCandidate === void 0 ? void 0 : resolvedCandidate.changeRate) ? resolvedCandidate.changeRate : currentPrice !== null && previousClose !== null && previousClose !== 0 ? ((currentPrice - previousClose) / previousClose) * 100 : null; return { currentPrice: currentPrice, changeRate: changeRate, volumeRate5d: null, currentVolume: null, quotedAt: quotedAt, stockName: ((_c = meta.shortName) === null || _c === void 0 ? void 0 : _c.trim()) || ((_d = meta.longName) === null || _d === void 0 ? void 0 : _d.trim()) || null, }; } function resolveLatestQuoteFromNaverRealtime(data, capturedAt) { var _a, _b, _c, _d, _e; var overMarketInfo = data.nxtOverMarketPriceInfo; var overPrice = parseLooseNumber(overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.overPrice); var overChangeRate = resolveSignedNaverChangeRate(parseLooseNumber(overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.fluctuationsRatio), overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.compareToPreviousClosePrice, overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.compareToPreviousPrice); var overQuotedAt = ((_a = overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.localTradedAt) === null || _a === void 0 ? void 0 : _a.trim()) || null; var hasExtendedSessionQuote = overPrice !== null && overQuotedAt; var baseQuotedAt = typeof capturedAt === 'number' && Number.isFinite(capturedAt) ? new Date(capturedAt).toISOString() : typeof capturedAt === 'string' && capturedAt.trim() ? new Date(capturedAt).toISOString() : null; var capturedTimestampMs = resolveCapturedTimestampMs(capturedAt); var basePrice = isFiniteNumber(data.nv) ? data.nv : null; var baseChangeRate = applySign(isFiniteNumber(data.cr) ? data.cr : null, resolveNaverDirectionSign(((_b = data.rf) === null || _b === void 0 ? void 0 : _b.trim()) ? { code: data.rf } : undefined)); var previousClosePrice = isFiniteNumber(data.pcv) ? data.pcv : null; var currentVolume = (_d = (_c = normalizeNonNegativeVolume(overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.accumulatedTradingVolume)) !== null && _c !== void 0 ? _c : normalizeNonNegativeVolume(data.accumulatedTradingVolume)) !== null && _d !== void 0 ? _d : normalizeNonNegativeVolume(data.aq); var shouldResetToPreviousClose = !hasExtendedSessionQuote && isKoreaMorningResetWindow(capturedTimestampMs) && previousClosePrice !== null; return { currentPrice: hasExtendedSessionQuote ? overPrice : shouldResetToPreviousClose ? previousClosePrice : basePrice, changeRate: hasExtendedSessionQuote ? overChangeRate : shouldResetToPreviousClose ? 0 : baseChangeRate, volumeRate5d: null, currentVolume: currentVolume, quotedAt: hasExtendedSessionQuote ? overQuotedAt : baseQuotedAt, stockName: ((_e = data.nm) === null || _e === void 0 ? void 0 : _e.trim()) || null, }; } function choosePreferredQuote(primary, fallback) { var _a, _b, _c, _d, _e, _f, _g, _h, _j; if ((primary === null || primary === void 0 ? void 0 : primary.currentPrice) === null && (fallback === null || fallback === void 0 ? void 0 : fallback.currentPrice) === null) { return (_a = primary !== null && primary !== void 0 ? primary : fallback) !== null && _a !== void 0 ? _a : null; } if (!primary) { return fallback; } if (!fallback) { return primary; } var primaryQuotedAt = primary.quotedAt ? Date.parse(primary.quotedAt) : Number.NaN; var fallbackQuotedAt = fallback.quotedAt ? Date.parse(fallback.quotedAt) : Number.NaN; if (Number.isFinite(primaryQuotedAt) && Number.isFinite(fallbackQuotedAt) && fallbackQuotedAt > primaryQuotedAt) { return __assign(__assign({}, fallback), { stockName: (_b = fallback.stockName) !== null && _b !== void 0 ? _b : primary.stockName, currentVolume: (_c = fallback.currentVolume) !== null && _c !== void 0 ? _c : primary.currentVolume }); } return __assign(__assign({}, primary), { stockName: (_d = primary.stockName) !== null && _d !== void 0 ? _d : fallback.stockName, currentPrice: (_e = primary.currentPrice) !== null && _e !== void 0 ? _e : fallback.currentPrice, changeRate: (_f = primary.changeRate) !== null && _f !== void 0 ? _f : fallback.changeRate, volumeRate5d: (_g = primary.volumeRate5d) !== null && _g !== void 0 ? _g : fallback.volumeRate5d, currentVolume: (_h = primary.currentVolume) !== null && _h !== void 0 ? _h : fallback.currentVolume, quotedAt: (_j = primary.quotedAt) !== null && _j !== void 0 ? _j : fallback.quotedAt }); } function fetchNaverRealtimeQuoteByCode(stockCode) { return __awaiter(this, void 0, void 0, function () { var quoteUrl, payload, data; var _a, _b, _c, _d, _e; return __generator(this, function (_f) { switch (_f.label) { case 0: quoteUrl = new URL('https://polling.finance.naver.com/api/realtime'); quoteUrl.searchParams.set('query', "SERVICE_ITEM:".concat(stockCode)); return [4 /*yield*/, fetchJson(quoteUrl, { headers: { accept: '*/*', }, })]; case 1: payload = _f.sent(); data = (_d = (_c = (_b = (_a = payload.result) === null || _a === void 0 ? void 0 : _a.areas) === null || _b === void 0 ? void 0 : _b.find(function (area) { return area.name === 'SERVICE_ITEM'; })) === null || _c === void 0 ? void 0 : _c.datas) === null || _d === void 0 ? void 0 : _d[0]; if (!data) { return [2 /*return*/, null]; } return [2 /*return*/, resolveLatestQuoteFromNaverRealtime(data, (_e = payload.result) === null || _e === void 0 ? void 0 : _e.time)]; } }); }); } function fetchQuoteBySymbol(symbol) { return __awaiter(this, void 0, void 0, function () { var quoteUrl, payload, result, meta, quote, dailyVolumes; var _a, _b, _c, _d, _e, _f, _g, _h; return __generator(this, function (_j) { switch (_j.label) { case 0: quoteUrl = new URL("https://query1.finance.yahoo.com/v8/finance/chart/".concat(symbol)); quoteUrl.searchParams.set('range', '3mo'); quoteUrl.searchParams.set('interval', '1d'); quoteUrl.searchParams.set('includePrePost', 'true'); quoteUrl.searchParams.set('lang', 'ko-KR'); quoteUrl.searchParams.set('region', 'KR'); return [4 /*yield*/, fetchJson(quoteUrl)]; case 1: payload = _j.sent(); result = (_b = (_a = payload.chart) === null || _a === void 0 ? void 0 : _a.result) === null || _b === void 0 ? void 0 : _b[0]; meta = result === null || result === void 0 ? void 0 : result.meta; if (!meta) { return [2 /*return*/, null]; } quote = resolveLatestQuoteFromMeta(meta); dailyVolumes = (_f = (_e = (_d = (_c = result === null || result === void 0 ? void 0 : result.indicators) === null || _c === void 0 ? void 0 : _c.quote) === null || _d === void 0 ? void 0 : _d[0]) === null || _e === void 0 ? void 0 : _e.volume) !== null && _f !== void 0 ? _f : []; return [2 /*return*/, __assign(__assign({}, quote), { currentVolume: (_g = dailyVolumes[dailyVolumes.length - 1]) !== null && _g !== void 0 ? _g : null, volumeRate5d: resolveVolumeRate5dFromHistory((_h = dailyVolumes[dailyVolumes.length - 1]) !== null && _h !== void 0 ? _h : null, dailyVolumes) })]; } }); }); } function fetchQuotesByCodes(stockCodes) { return __awaiter(this, void 0, void 0, function () { var normalizedCodes, quoteMap; var _this = this; return __generator(this, function (_a) { switch (_a.label) { case 0: normalizedCodes = Array.from(new Set(stockCodes .map(function (value) { return normalizeStockCode(value); }) .filter(Boolean))); quoteMap = new Map(); if (!normalizedCodes.length) { return [2 /*return*/, quoteMap]; } return [4 /*yield*/, Promise.all(normalizedCodes.map(function (stockCode) { return __awaiter(_this, void 0, void 0, function () { var preferredQuote, _a, symbols, _i, symbols_1, symbol, yahooQuote, _b; return __generator(this, function (_c) { switch (_c.label) { case 0: preferredQuote = null; _c.label = 1; case 1: _c.trys.push([1, 3, , 4]); return [4 /*yield*/, fetchNaverRealtimeQuoteByCode(stockCode)]; case 2: preferredQuote = _c.sent(); return [3 /*break*/, 4]; case 3: _a = _c.sent(); return [3 /*break*/, 4]; case 4: symbols = buildStockSymbols(stockCode); _i = 0, symbols_1 = symbols; _c.label = 5; case 5: if (!(_i < symbols_1.length)) return [3 /*break*/, 10]; symbol = symbols_1[_i]; _c.label = 6; case 6: _c.trys.push([6, 8, , 9]); return [4 /*yield*/, fetchQuoteBySymbol(symbol)]; case 7: yahooQuote = _c.sent(); preferredQuote = choosePreferredQuote(preferredQuote, yahooQuote); if (preferredQuote && (preferredQuote.currentPrice !== null || preferredQuote.stockName) && preferredQuote.volumeRate5d !== null) { quoteMap.set(stockCode, preferredQuote); return [2 /*return*/]; } return [3 /*break*/, 9]; case 8: _b = _c.sent(); return [3 /*break*/, 9]; case 9: _i++; return [3 /*break*/, 5]; case 10: if (preferredQuote && (preferredQuote.currentPrice !== null || preferredQuote.stockName || preferredQuote.volumeRate5d !== null)) { quoteMap.set(stockCode, preferredQuote); } return [2 /*return*/]; } }); }); }))]; case 1: _a.sent(); return [2 /*return*/, quoteMap]; } }); }); } function listStockAlerts() { return __awaiter(this, arguments, void 0, function (filterType) { var rows, matchedCodes, quotes, krxItems, krxByCode, groupedItems; if (filterType === void 0) { filterType = 'all'; } return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, ensureStockAlertTable()]; case 1: _a.sent(); rows = []; if (!(filterType === 'all')) return [3 /*break*/, 3]; return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).select('*').orderBy('updated_at', 'desc')]; case 2: rows = (_a.sent()); return [3 /*break*/, 6]; case 3: return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) .select('stock_code') .where({ alert_type: normalizeAlertType(filterType) }) .groupBy('stock_code')]; case 4: matchedCodes = (_a.sent()).map(function (row) { return row.stock_code; }); if (!matchedCodes.length) { return [2 /*return*/, []]; } return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) .select('*') .whereIn('stock_code', matchedCodes) .orderBy('updated_at', 'desc')]; case 5: rows = (_a.sent()); _a.label = 6; case 6: return [4 /*yield*/, fetchQuotesByCodes(rows.map(function (row) { return row.stock_code; }))]; case 7: quotes = _a.sent(); return [4 /*yield*/, fetchKrxListedStocks()]; case 8: krxItems = _a.sent(); krxByCode = new Map(krxItems.map(function (item) { return [item.stockCode, item]; })); groupedItems = new Map(); rows.forEach(function (row) { var _a, _b, _c, _d, _e; var alertType = normalizeAlertType(row.alert_type); var quote = quotes.get(row.stock_code); var krxMatch = krxByCode.get(row.stock_code); var existing = groupedItems.get(row.stock_code); if (existing) { if (!existing.alertTypes.includes(alertType)) { existing.alertTypes.push(alertType); existing.alertTypeLabels.push(getAlertTypeLabel(alertType)); } var updatedAtTime = Date.parse(normalizeTimestamp(row.updated_at)); var existingUpdatedAtTime = Date.parse(existing.updatedAt); if (Number.isFinite(updatedAtTime) && (!Number.isFinite(existingUpdatedAtTime) || updatedAtTime > existingUpdatedAtTime)) { existing.updatedAt = normalizeTimestamp(row.updated_at); } return; } groupedItems.set(row.stock_code, { id: row.stock_code, stockCode: row.stock_code, stockName: row.stock_name || (krxMatch === null || krxMatch === void 0 ? void 0 : krxMatch.stockName) || (quote === null || quote === void 0 ? void 0 : quote.stockName) || row.stock_code, alertTypes: [alertType], alertTypeLabels: [getAlertTypeLabel(alertType)], currentPrice: (_a = quote === null || quote === void 0 ? void 0 : quote.currentPrice) !== null && _a !== void 0 ? _a : null, changeRate: (_b = quote === null || quote === void 0 ? void 0 : quote.changeRate) !== null && _b !== void 0 ? _b : null, volumeRate5d: (_c = quote === null || quote === void 0 ? void 0 : quote.volumeRate5d) !== null && _c !== void 0 ? _c : null, currentVolume: (_d = quote === null || quote === void 0 ? void 0 : quote.currentVolume) !== null && _d !== void 0 ? _d : null, quotedAt: (_e = quote === null || quote === void 0 ? void 0 : quote.quotedAt) !== null && _e !== void 0 ? _e : null, createdAt: normalizeTimestamp(row.created_at), updatedAt: normalizeTimestamp(row.updated_at), }); }); return [2 /*return*/, Array.from(groupedItems.values()).sort(function (left, right) { return Date.parse(right.updatedAt) - Date.parse(left.updatedAt); })]; } }); }); } function createStockAlert(input) { return __awaiter(this, void 0, void 0, function () { var identity, alertTypes, existingRows, mergedAlertTypes, now, created; return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, ensureStockAlertTable()]; case 1: _a.sent(); return [4 /*yield*/, resolveStockIdentity(input)]; case 2: identity = _a.sent(); alertTypes = Array.from(new Set(input.alertTypes.map(function (value) { return normalizeAlertType(value); }))); if (!alertTypes.length) { throw new Error('알림유형을 하나 이상 선택해 주세요.'); } return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) .select('*') .where({ stock_code: identity.stockCode })]; case 3: existingRows = (_a.sent()); if (existingRows.length) { mergedAlertTypes = Array.from(new Set(__spreadArray(__spreadArray([], existingRows.map(function (row) { return normalizeAlertType(row.alert_type); }), true), alertTypes, true))); return [2 /*return*/, updateStockAlert(identity.stockCode, { stockCode: identity.stockCode, stockName: identity.stockName, alertTypes: mergedAlertTypes, })]; } now = new Date().toISOString(); return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).insert(alertTypes.map(function (alertType) { return ({ id: "stock-alert-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)), stock_code: identity.stockCode, stock_name: identity.stockName, alert_type: alertType, created_at: now, updated_at: now, }); }))]; case 4: _a.sent(); return [4 /*yield*/, listStockAlerts('all').then(function (rows) { return rows.filter(function (row) { return row.stockCode === identity.stockCode; }); })]; case 5: created = (_a.sent())[0]; if (!created) { throw new Error('저장된 종목 알림을 다시 불러오지 못했습니다.'); } return [2 /*return*/, created]; } }); }); } function updateStockAlert(id, input) { return __awaiter(this, void 0, void 0, function () { var currentRows, existing, identity, updatedAt, alertTypes, updated; var _this = this; var _a, _b; return __generator(this, function (_c) { switch (_c.label) { case 0: return [4 /*yield*/, ensureStockAlertTable()]; case 1: _c.sent(); return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) .select('*') .where(function (query) { query.where({ stock_code: normalizeStockCode(id) || '__never__' }).orWhere({ id: id }); })]; case 2: currentRows = (_c.sent()); if (!currentRows.length) { throw new Error('수정할 종목 알림을 찾을 수 없습니다.'); } existing = currentRows[0]; return [4 /*yield*/, resolveStockIdentity({ stockCode: (_a = input.stockCode) !== null && _a !== void 0 ? _a : existing === null || existing === void 0 ? void 0 : existing.stock_code, stockName: (_b = input.stockName) !== null && _b !== void 0 ? _b : existing === null || existing === void 0 ? void 0 : existing.stock_name, })]; case 3: identity = _c.sent(); updatedAt = new Date().toISOString(); alertTypes = Array.from(new Set(input.alertTypes.map(function (value) { return normalizeAlertType(value); }))); if (!alertTypes.length) { throw new Error('알림유형을 하나 이상 선택해 주세요.'); } if (!(identity.stockCode !== existing.stock_code)) return [3 /*break*/, 5]; return [4 /*yield*/, ensureNoDuplicateStockCode(identity.stockCode)]; case 4: _c.sent(); _c.label = 5; case 5: return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, trx(exports.STOCK_ALERT_TABLE).where({ stock_code: existing.stock_code }).delete()]; case 1: _a.sent(); return [4 /*yield*/, trx(exports.STOCK_ALERT_TABLE).insert(alertTypes.map(function (alertType) { var _a, _b; return ({ id: "stock-alert-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)), stock_code: identity.stockCode, stock_name: identity.stockName, alert_type: alertType, created_at: (_b = (_a = currentRows.find(function (row) { return row.alert_type === alertType; })) === null || _a === void 0 ? void 0 : _a.created_at) !== null && _b !== void 0 ? _b : updatedAt, updated_at: updatedAt, }); }))]; case 2: _a.sent(); return [2 /*return*/]; } }); }); })]; case 6: _c.sent(); return [4 /*yield*/, listStockAlerts('all').then(function (rows) { return rows.filter(function (row) { return row.stockCode === identity.stockCode; }); })]; case 7: updated = (_c.sent())[0]; if (!updated) { throw new Error('수정된 종목 알림을 다시 불러오지 못했습니다.'); } return [2 /*return*/, updated]; } }); }); } function deleteStockAlert(id) { return __awaiter(this, void 0, void 0, function () { var normalizedCode, count, _a; return __generator(this, function (_b) { switch (_b.label) { case 0: return [4 /*yield*/, ensureStockAlertTable()]; case 1: _b.sent(); normalizedCode = normalizeStockCode(id); if (!normalizedCode) return [3 /*break*/, 3]; return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).where({ stock_code: normalizedCode }).delete()]; case 2: _a = _b.sent(); return [3 /*break*/, 5]; case 3: return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).where({ id: id }).delete()]; case 4: _a = _b.sent(); _b.label = 5; case 5: count = _a; if (!count) { throw new Error('삭제할 종목 알림을 찾을 수 없습니다.'); } return [2 /*return*/]; } }); }); } function saveStockAlerts(items) { return __awaiter(this, void 0, void 0, function () { var seenCodes, _i, items_1, item, normalizedCode, savedItems, _a, items_2, item, trimmedId, savedItem, _b; var _c, _d; return __generator(this, function (_e) { switch (_e.label) { case 0: return [4 /*yield*/, ensureStockAlertTable()]; case 1: _e.sent(); seenCodes = new Set(); for (_i = 0, items_1 = items; _i < items_1.length; _i++) { item = items_1[_i]; normalizedCode = normalizeStockCode((_c = item.stockCode) !== null && _c !== void 0 ? _c : ''); if (!normalizedCode) { continue; } if (seenCodes.has(normalizedCode)) { throw new Error('동일한 종목코드를 중복 저장할 수 없습니다.'); } if (!item.alertTypes.length) { throw new Error('알림유형을 하나 이상 선택해 주세요.'); } seenCodes.add(normalizedCode); } savedItems = []; _a = 0, items_2 = items; _e.label = 2; case 2: if (!(_a < items_2.length)) return [3 /*break*/, 8]; item = items_2[_a]; trimmedId = (_d = item.id) === null || _d === void 0 ? void 0 : _d.trim(); if (!trimmedId) return [3 /*break*/, 4]; return [4 /*yield*/, updateStockAlert(trimmedId, item)]; case 3: _b = _e.sent(); return [3 /*break*/, 6]; case 4: return [4 /*yield*/, createStockAlert(item)]; case 5: _b = _e.sent(); _e.label = 6; case 6: savedItem = _b; savedItems.push(savedItem); _e.label = 7; case 7: _a++; return [3 /*break*/, 2]; case 8: return [2 /*return*/, savedItems]; } }); }); } function formatStockAlertPrice(value) { if (!isFiniteNumber(value)) { return '-'; } return "".concat(Math.round(value).toLocaleString('ko-KR'), "\u20A9"); } function formatStockAlertChangeRate(value) { if (!isFiniteNumber(value)) { return '(변동률 확인불가)'; } if (value > 0) { return "(+".concat(value.toFixed(2), "% \u25B2)"); } if (value < 0) { return "(".concat(value.toFixed(2), "% \u25BC)"); } return '(0.00% -)'; } function canBuildCurrentPriceStockAlertLine(item) { return item.alertTypes.includes('price') && isFiniteNumber(item.currentPrice) && isFiniteNumber(item.changeRate); } function canBuildChangeThresholdStockAlertLine(item) { return isFiniteNumber(item.currentPrice) && isFiniteNumber(item.changeRate); } function buildCurrentPriceStockAlertLines(items) { return items .filter(canBuildCurrentPriceStockAlertLine) .map(function (item) { return "".concat(item.stockName, " ").concat(formatStockAlertPrice(item.currentPrice), " ").concat(formatStockAlertChangeRate(item.changeRate)); }); } function buildChangeRateThresholdStockAlertLines(items, thresholdPercent) { return items .filter(function (item) { var _a; return canBuildChangeThresholdStockAlertLine(item) && Math.abs((_a = item.changeRate) !== null && _a !== void 0 ? _a : 0) >= thresholdPercent; }) .sort(function (left, right) { var _a, _b; var changeRateGap = Math.abs(((_a = right.changeRate) !== null && _a !== void 0 ? _a : 0)) - Math.abs(((_b = left.changeRate) !== null && _b !== void 0 ? _b : 0)); if (changeRateGap !== 0) { return changeRateGap; } return left.stockName.localeCompare(right.stockName, 'ko-KR'); }) .map(function (item) { return "".concat(item.stockName, " ").concat(formatStockAlertPrice(item.currentPrice), " ").concat(formatStockAlertChangeRate(item.changeRate)); }); } function listStockAlertVolumeSnapshots() { return __awaiter(this, void 0, void 0, function () { var rows; return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, ensureStockAlertVolumeSnapshotTable()]; case 1: _a.sent(); return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE) .select('*') .orderBy('updated_at', 'desc')]; case 2: rows = (_a.sent()); return [2 /*return*/, new Map(rows .map(function (row) { return normalizeStockAlertVolumeSnapshotRow(row); }) .filter(function (row) { return row.stockCode; }) .map(function (row) { return [row.stockCode, row]; }))]; } }); }); } function upsertStockAlertVolumeSnapshots(items, previousSnapshots) { return __awaiter(this, void 0, void 0, function () { var records; return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, ensureStockAlertVolumeSnapshotTable()]; case 1: _a.sent(); if (!items.length) { return [2 /*return*/]; } records = items.map(function (item) { var _a; return buildStockAlertVolumeSnapshotRecord(item, item.currentVolume, (_a = previousSnapshots.get(item.stockCode)) !== null && _a !== void 0 ? _a : null); }); return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE) .insert(records) .onConflict('stock_code') .merge({ stock_name: client_js_1.db.ref('excluded.stock_name'), previous_volume: client_js_1.db.ref('excluded.previous_volume'), current_volume: client_js_1.db.ref('excluded.current_volume'), volume_increase_percent: client_js_1.db.ref('excluded.volume_increase_percent'), current_price: client_js_1.db.ref('excluded.current_price'), change_rate: client_js_1.db.ref('excluded.change_rate'), quoted_at: client_js_1.db.ref('excluded.quoted_at'), updated_at: client_js_1.db.ref('excluded.updated_at'), })]; case 2: _a.sent(); return [2 /*return*/]; } }); }); } function buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, options) { return items .flatMap(function (item) { var _a, _b; if (!canBuildChangeThresholdStockAlertLine(item)) { return []; } var previousSnapshot = previousSnapshots.get(item.stockCode); var previousVolume = (_a = previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== null && _a !== void 0 ? _a : null; var currentVolume = normalizeNonNegativeVolume(item.currentVolume); var volumeIncreasePercent = calculateVolumeIncreasePercent(currentVolume, previousVolume); if (volumeIncreasePercent === null || Math.abs((_b = item.changeRate) !== null && _b !== void 0 ? _b : 0) < options.thresholdPercent || volumeIncreasePercent < options.minVolumeIncreasePercent) { return []; } return [ { stockCode: item.stockCode, stockName: item.stockName, currentPrice: item.currentPrice, changeRate: item.changeRate, currentVolume: currentVolume, previousVolume: previousVolume, volumeIncreasePercent: volumeIncreasePercent, quotedAt: item.quotedAt, }, ]; }) .sort(function (left, right) { var _a, _b, _c, _d; var changeRateGap = Math.abs(((_a = right.changeRate) !== null && _a !== void 0 ? _a : 0)) - Math.abs(((_b = left.changeRate) !== null && _b !== void 0 ? _b : 0)); if (changeRateGap !== 0) { return changeRateGap; } var volumeGap = ((_c = right.volumeIncreasePercent) !== null && _c !== void 0 ? _c : 0) - ((_d = left.volumeIncreasePercent) !== null && _d !== void 0 ? _d : 0); if (volumeGap !== 0) { return volumeGap; } return left.stockName.localeCompare(right.stockName, 'ko-KR'); }); } function buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, options) { return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, options).map(function (item) { return "".concat(item.stockName, " ").concat(formatStockAlertPrice(item.currentPrice), " ").concat(formatStockAlertChangeRate(item.changeRate)); }); } function createSkippedNotificationResult(reason) { var skippedWebResult = { ok: true, skipped: true, reason: reason, sentCount: 0, failedCount: 0, invalidEndpoints: [], }; var skippedIosResult = { ok: true, skipped: true, reason: reason, sentCount: 0, failedCount: 0, invalidTokens: [], }; return { ios: skippedIosResult, web: skippedWebResult, }; } function buildStockAlertNotificationIdentity(options) { var modeKey = options.mode === 'price' ? 'current-price' : options.mode === 'change-threshold' ? 'change-threshold' : 'change-threshold-volume-spike'; var legacyNotificationKey = "".concat(options.serviceKey, ":current-price"); var legacyModeNotificationKey = "".concat(options.serviceKey, ":").concat(modeKey); return { threadId: "schedule-stock-alert:".concat(options.scheduleId), notificationKey: "schedule-stock-alert:".concat(options.scheduleId), notificationScope: "schedule-stock-alert:".concat(options.scheduleId), notificationAliases: __spreadArray([], new Set([ options.serviceKey, legacyNotificationKey, legacyModeNotificationKey, options.scheduleId === 2 ? STOCK_ALERT_NOTIFICATION_SCOPE : '', options.scheduleId === 2 ? "".concat(STOCK_ALERT_NOTIFICATION_SCOPE, ":current-price") : '', ].filter(Boolean)), true), }; } function sendManagedStockAlertWebPush(options) { return __awaiter(this, void 0, void 0, function () { var thresholdPercent, minVolumeIncreasePercent, items, previousSnapshots, _a, lines, hasRegisteredTargets, hasComparableVolumeBaseline, skippedReason, skippedResult, body, notificationIdentity, result; var _b, _c; return __generator(this, function (_d) { switch (_d.label) { case 0: thresholdPercent = Math.max(0, Number((_b = options.thresholdPercent) !== null && _b !== void 0 ? _b : 5)); minVolumeIncreasePercent = Math.max(0, Number((_c = options.minVolumeIncreasePercent) !== null && _c !== void 0 ? _c : 300)); return [4 /*yield*/, listStockAlerts(options.mode === 'price' ? 'price' : 'all')]; case 1: items = _d.sent(); if (!(options.mode === 'change-threshold-volume-spike')) return [3 /*break*/, 3]; return [4 /*yield*/, listStockAlertVolumeSnapshots()]; case 2: _a = _d.sent(); return [3 /*break*/, 4]; case 3: _a = new Map(); _d.label = 4; case 4: previousSnapshots = _a; lines = options.mode === 'price' ? buildCurrentPriceStockAlertLines(items) : options.mode === 'change-threshold' ? buildChangeRateThresholdStockAlertLines(items, thresholdPercent) : buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, { thresholdPercent: thresholdPercent, minVolumeIncreasePercent: minVolumeIncreasePercent, }); hasRegisteredTargets = options.mode === 'price' ? items.some(function (item) { return item.alertTypes.includes('price'); }) : items.length > 0; hasComparableVolumeBaseline = options.mode !== 'change-threshold-volume-spike' ? false : items.some(function (item) { var previousSnapshot = previousSnapshots.get(item.stockCode); return (previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== null && (previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== undefined; }); skippedReason = options.mode === 'price' ? hasRegisteredTargets ? '현재가 시세를 확인할 수 있는 종목이 없습니다.' : '현재가로 등록된 종목이 없습니다.' : options.mode === 'change-threshold' ? hasRegisteredTargets ? "".concat(thresholdPercent, "% \uC774\uC0C1 \uBCC0\uB3D9 \uC885\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.") : '등록된 종목이 없습니다.' : !hasRegisteredTargets ? '등록된 종목이 없습니다.' : !hasComparableVolumeBaseline ? '\uC774\uC804 \uAC70\uB798\uB7C9 \uB610\uB294 5\uC601\uC5C5\uC77C \uD3C9\uADE0 \uAC70\uB798\uB7C9 \uBE44\uAD50 \uAE30\uC900\uC774 \uC5C6\uC5B4 \uC2A4\uB0C5\uC0F7\uB9CC \uAC31\uC2E0\uD588\uC2B5\uB2C8\uB2E4.' : "\uB4F1\uB77D\uB960 ".concat(thresholdPercent, "% \uC774\uC0C1\uC774\uBA74\uC11C \uC9C1\uC804 \uAC70\uB798\uB7C9 \uC99D\uD3ED\uC218 \uB300\uBE44 \uC774\uBC88 \uAC70\uB798\uB7C9 \uC99D\uD3ED\uC774 ").concat(minVolumeIncreasePercent, "% \uC774\uC0C1\uC778 \uC885\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4."); skippedResult = createSkippedNotificationResult(skippedReason); if (!(options.mode === 'change-threshold-volume-spike')) return [3 /*break*/, 6]; return [4 /*yield*/, upsertStockAlertVolumeSnapshots(items, previousSnapshots)]; case 5: _d.sent(); _d.label = 6; case 6: if (!lines.length) { return [2 /*return*/, { ok: true, skipped: true, reason: skippedReason, title: options.title, body: '', itemCount: 0, lines: [], ios: skippedResult.ios, web: skippedResult.web, }]; } body = lines.join('\n'); notificationIdentity = buildStockAlertNotificationIdentity(options); return [4 /*yield*/, (0, notification_service_js_1.sendNotifications)({ title: options.title, body: body, threadId: notificationIdentity.threadId, targetAppDomains: [STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN], data: { category: 'stock-alert', eventType: options.mode === 'price' ? 'stock-alert-current-price' : options.mode === 'change-threshold' ? 'stock-alert-change-threshold' : 'stock-alert-change-threshold-volume-spike', notificationKey: notificationIdentity.notificationKey, notificationScope: notificationIdentity.notificationScope, notificationAliases: JSON.stringify(notificationIdentity.notificationAliases), replaceExistingScope: 'true', source: options.serviceKey, targetUrl: STOCK_ALERT_NOTIFICATION_TARGET_URL, }, }, { disableIos: true, })]; case 7: result = _d.sent(); return [2 /*return*/, __assign(__assign({}, result), { title: options.title, body: body, itemCount: lines.length, lines: lines })]; } }); }); } function sendCurrentPriceStockAlertWebPush() { return __awaiter(this, void 0, void 0, function () { return __generator(this, function (_a) { return [2 /*return*/, sendManagedStockAlertWebPush({ scheduleId: 2, serviceKey: STOCK_ALERT_NOTIFICATION_SCOPE, title: STOCK_ALERT_NOTIFICATION_TITLE, mode: 'price', })]; }); }); } function updateStockAlertLayoutFeatureDescription() { return __awaiter(this, void 0, void 0, function () { var layoutRecord, tree, changed, nextInteractions; return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, ensureStockAlertTable()]; case 1: _a.sent(); return [4 /*yield*/, (0, client_js_1.db)('play_layouts').select('id', 'tree').where({ name: exports.STOCK_ALERT_LAYOUT_NAME }).first()]; case 2: layoutRecord = _a.sent(); if (!layoutRecord || typeof layoutRecord !== 'object') { return [2 /*return*/, false]; } tree = layoutRecord.tree; if (!tree || !Array.isArray(tree.interactions)) { return [2 /*return*/, false]; } changed = false; nextInteractions = tree.interactions.map(function (interaction) { var _a; var title = (_a = interaction.title) === null || _a === void 0 ? void 0 : _a.trim(); if (title === '그리드 기본정의') { var nextDescription = [ '## 그리드 필드를 아래로 정의하세요.', ' - 종목명, 등락률, 현재가, 기준일시, 알림유형', '## 숨긴필드', ' - 종목코드', '## DB관리 데이터', ' - 종목코드, 알림유형', '## 외부 API 및 가공 데이터', ' - DB에 저장된 종목코드에 대한 현재가 등락률 기준일시를 표현', ' - 현재가 데이터는 정규장이 아닌 가격도 모두 가져오도록 해주세요.', '## 서비스 구현', ' - 해당 그리드 데이터를 저장할 DB 및 서비스 API를 구현허고 연결하세요. CRUD', ' - 알림유형은 한종목에 멀티로 저장되어야 합니다.', '## 입력', '알림유형의 경우 멀티선택 가능하게 해주세요.', ].join('\n'); var nextNotes = 'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다. 거래량은 네이버 실시간 누적거래량을 현재값으로 받고, Yahoo Finance 일봉 최근 5영업일 평균 대비 비율(%)로 계산해 제공합니다.'; if (interaction.description !== nextDescription || interaction.implementationNotes !== nextNotes) { changed = true; return __assign(__assign({}, interaction), { description: nextDescription, implementationNotes: nextNotes }); } } if (title === '얼림유형 검색') { var nextNotes = 'Primary Pane Select Input 값은 all, price, top3 코드로 유지하고 Secondary Pane 그리드 조회 파라미터 alertType 으로 바로 전달합니다.'; if (interaction.implementationNotes !== nextNotes) { changed = true; return __assign(__assign({}, interaction), { implementationNotes: nextNotes }); } } if (title === '행추가 기능') { var nextNotes = 'GET /api/stock-alerts/search?query=... 로 종목명/종목코드를 조회하고, 선택한 종목코드·종목명·시장구분을 그리드 신규 행으로 매핑합니다. 동일 종목코드는 중복 추가를 막고, 선택 후 해당 행으로 포커스를 이동합니다.'; if (interaction.implementationNotes !== nextNotes) { changed = true; return __assign(__assign({}, interaction), { implementationNotes: nextNotes }); } } return interaction; }); if (!changed) { return [2 /*return*/, false]; } return [4 /*yield*/, (0, client_js_1.db)('play_layouts') .where({ id: layoutRecord.id }) .update({ tree: __assign(__assign({}, tree), { interactions: nextInteractions }), })]; case 3: _a.sent(); return [2 /*return*/, true]; } }); }); }