1628 lines
86 KiB
JavaScript
1628 lines
86 KiB
JavaScript
"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(/<tr[\s\S]*?<\/tr>/gi)) !== null && _a !== void 0 ? _a : [];
|
|
var items = [];
|
|
rowMatches.forEach(function (rowHtml) {
|
|
var _a, _b, _c, _d;
|
|
var cellMatches = (_a = rowHtml.match(/<td[\s\S]*?<\/td>/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];
|
|
}
|
|
});
|
|
});
|
|
}
|