neuroalpha 0.5.27 → 0.5.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +13 -1
- package/server/brokers/index.js +71 -25
- package/server/model/dataset.js +15 -3
- package/server/model/forecast.js +43 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.28",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -2575,8 +2575,10 @@ function renderBrokers() {
|
|
|
2575
2575
|
const baseMap = a.baselineQty || {};
|
|
2576
2576
|
const curSubTab = brokerTabById.get(a.id) || 'assets';
|
|
2577
2577
|
|
|
2578
|
+
const isSweepLqdt = (p) => Boolean(a.cashSweep && a.broker === 'tbank' && p.symbol === 'LQDT');
|
|
2579
|
+
|
|
2578
2580
|
const algoQtyOf = (p) => {
|
|
2579
|
-
if ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol))) return 0;
|
|
2581
|
+
if (!isSweepLqdt(p) && ((p.instrumentType && p.instrumentType !== 'share') || (fcRowMap && !fcRowMap.has(p.symbol)))) return 0;
|
|
2580
2582
|
if (lMode === 'takeover') return p.qty;
|
|
2581
2583
|
const bq = Number(baseMap[p.symbol] || 0);
|
|
2582
2584
|
if (p.qty > 0 && bq > 0) return Math.max(0, p.qty - bq);
|
|
@@ -2590,6 +2592,16 @@ function renderBrokers() {
|
|
|
2590
2592
|
if (isNonTradablePos(p)) {
|
|
2591
2593
|
return '<span class="tag" title="Бумага снята с торгов, проходит редомициляцию или заблокирована (рыночная оценка 0 ₽). Стратегия её не затрагивает">не торгуется (0 ₽)</span>';
|
|
2592
2594
|
}
|
|
2595
|
+
if (isSweepLqdt(p)) {
|
|
2596
|
+
const aq = algoQtyOf(p);
|
|
2597
|
+
const bq = Number(baseMap[p.symbol] || 0);
|
|
2598
|
+
if (aq > 0 && bq > 0 && lMode !== 'takeover') {
|
|
2599
|
+
return `<span class="tag ok" title="Рублёвый резерв стратегии в фонде ликвидности LQDT: автоматически продаётся под новые сигналы">старый: ${fmtNum(bq, 0)} шт. · резерв стратегии: ${fmtNum(aq, 0)} шт.</span>`;
|
|
2600
|
+
}
|
|
2601
|
+
if (aq > 0) {
|
|
2602
|
+
return '<span class="tag ok" title="Рублёвый резерв стратегии в фонде ликвидности LQDT: автоматически высвобождается под новые сигналы покупки и шорта">резерв стратегии (кэш в LQDT)</span>';
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2593
2605
|
if (p.instrumentType && p.instrumentType !== 'share') {
|
|
2594
2606
|
const [kl] = INST_KIND_LABEL[p.instrumentType] || ['Актив', ''];
|
|
2595
2607
|
return `<span class="tag" title="Драгметаллы, валюта, облигации и БПИФы не затрагиваются стратегией акций">${esc(kl.toLowerCase())} (защищён)</span>`;
|
package/server/brokers/index.js
CHANGED
|
@@ -79,13 +79,29 @@ export const BROKER_META = [
|
|
|
79
79
|
|
|
80
80
|
// ---------- справочник лотов и шага цены Мосбиржи (TQBR) ----------
|
|
81
81
|
const SPECS_URL = 'https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=securities,marketdata';
|
|
82
|
+
|
|
83
|
+
function withWallTimeout(promise, ms, label = 'Превышено время ожидания') {
|
|
84
|
+
let timer = null;
|
|
85
|
+
return Promise.race([
|
|
86
|
+
promise,
|
|
87
|
+
new Promise((_, reject) => {
|
|
88
|
+
timer = setTimeout(() => reject(new Error(label)), ms);
|
|
89
|
+
timer.unref?.();
|
|
90
|
+
}),
|
|
91
|
+
]).finally(() => {
|
|
92
|
+
if (timer) clearTimeout(timer);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
export async function ensureBoardSpecs() {
|
|
83
97
|
const cached = kvGet('moexSpecs');
|
|
84
98
|
if (cached?.specs && cached?.v === 2 && Date.now() - cached.at < DAY) return cached.specs;
|
|
85
99
|
try {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
const j = await withWallTimeout((async () => {
|
|
101
|
+
const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
|
|
102
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
103
|
+
return r.json();
|
|
104
|
+
})(), 26000, 'Таймаут загрузки справочника TQBR');
|
|
89
105
|
const sc = j.securities.columns, mc = j.marketdata.columns;
|
|
90
106
|
const is = (k) => sc.indexOf(k), im = (k) => mc.indexOf(k);
|
|
91
107
|
const prices = new Map();
|
|
@@ -1574,11 +1590,36 @@ function sessionEdgeWarning(now = Date.now()) {
|
|
|
1574
1590
|
}
|
|
1575
1591
|
|
|
1576
1592
|
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
1577
|
-
const
|
|
1593
|
+
const ACCOUNT_LOCK_TTL_MS = 75000;
|
|
1594
|
+
const accountBusyLocks = new Map(); // id -> { since, token }
|
|
1595
|
+
|
|
1596
|
+
function isAccountBusy(id) {
|
|
1597
|
+
const cur = accountBusyLocks.get(id);
|
|
1598
|
+
if (!cur) return false;
|
|
1599
|
+
if (Date.now() - cur.since >= ACCOUNT_LOCK_TTL_MS) {
|
|
1600
|
+
accountBusyLocks.delete(id);
|
|
1601
|
+
return false;
|
|
1602
|
+
}
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function acquireAccountLock(id) {
|
|
1607
|
+
if (isAccountBusy(id)) return null;
|
|
1608
|
+
const token = Symbol(id);
|
|
1609
|
+
accountBusyLocks.set(id, { since: Date.now(), token });
|
|
1610
|
+
return token;
|
|
1611
|
+
}
|
|
1578
1612
|
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1613
|
+
function releaseAccountLock(id, token) {
|
|
1614
|
+
const cur = accountBusyLocks.get(id);
|
|
1615
|
+
if (cur && (!token || cur.token === token)) {
|
|
1616
|
+
accountBusyLocks.delete(id);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBarTs = 0 } = {}) {
|
|
1621
|
+
const lockToken = acquireAccountLock(id);
|
|
1622
|
+
if (!lockToken) return brokersState();
|
|
1582
1623
|
try {
|
|
1583
1624
|
let list = loadRawAccounts();
|
|
1584
1625
|
let acc = list.find((x) => x.id === id);
|
|
@@ -1602,11 +1643,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1602
1643
|
acc = list.find((x) => x.id === id) || acc;
|
|
1603
1644
|
}
|
|
1604
1645
|
const params = normalizeParams(getSettings());
|
|
1605
|
-
const [fc, rawSpecs, shortableMap] = await Promise.all([
|
|
1646
|
+
const [fc, rawSpecs, shortableMap] = await withWallTimeout(Promise.all([
|
|
1606
1647
|
forecastNow(params),
|
|
1607
1648
|
ensureBoardSpecs(),
|
|
1608
1649
|
ensureTbankShortableMap(acc),
|
|
1609
|
-
]);
|
|
1650
|
+
]), 55000, 'Превышено время ожидания расчёта прогноза');
|
|
1610
1651
|
const specs = { ...rawSpecs };
|
|
1611
1652
|
if (acc.broker === 'tbank') {
|
|
1612
1653
|
const tbSpecs = kvGet('tbankShortableMap')?.specMap || {};
|
|
@@ -2130,6 +2171,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
2130
2171
|
const batchNowTs = Date.now();
|
|
2131
2172
|
if (!onlyEntries) {
|
|
2132
2173
|
acc.lastFullStepWallTs = batchNowTs;
|
|
2174
|
+
if (scheduledBarTs > 0) acc.lastScheduledBarTs = scheduledBarTs;
|
|
2133
2175
|
}
|
|
2134
2176
|
acc.pendingBatch = {
|
|
2135
2177
|
id: `batch_${batchNowTs}`,
|
|
@@ -2158,7 +2200,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
2158
2200
|
hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
|
|
2159
2201
|
return st;
|
|
2160
2202
|
} finally {
|
|
2161
|
-
|
|
2203
|
+
releaseAccountLock(id, lockToken);
|
|
2162
2204
|
}
|
|
2163
2205
|
}
|
|
2164
2206
|
|
|
@@ -2474,12 +2516,13 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2474
2516
|
}
|
|
2475
2517
|
|
|
2476
2518
|
export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
2519
|
+
let lockToken = null;
|
|
2477
2520
|
if (!_lockHeld) {
|
|
2478
2521
|
// Счёт может быть занят пересчётом заявок (после перезапуска — обучением прогноза): ждём до 15 с, а не молча пропускаем
|
|
2479
2522
|
const waitUntil = Date.now() + 15000;
|
|
2480
|
-
while (
|
|
2481
|
-
|
|
2482
|
-
|
|
2523
|
+
while (isAccountBusy(id) && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 300));
|
|
2524
|
+
lockToken = acquireAccountLock(id);
|
|
2525
|
+
if (!lockToken) throw new Error('Счёт сейчас занят пересчётом заявок — повторите через минуту');
|
|
2483
2526
|
}
|
|
2484
2527
|
try {
|
|
2485
2528
|
const list = loadRawAccounts();
|
|
@@ -2563,7 +2606,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2563
2606
|
hub.emit({ type: 'brokers:update', state: st });
|
|
2564
2607
|
return st;
|
|
2565
2608
|
} finally {
|
|
2566
|
-
if (!_lockHeld)
|
|
2609
|
+
if (!_lockHeld && lockToken) releaseAccountLock(id, lockToken);
|
|
2567
2610
|
}
|
|
2568
2611
|
}
|
|
2569
2612
|
|
|
@@ -2789,24 +2832,27 @@ async function tickBrokerSchedule() {
|
|
|
2789
2832
|
// Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
|
|
2790
2833
|
const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
2791
2834
|
const minStepMs = stepBars * BAR_MS;
|
|
2792
|
-
const
|
|
2793
|
-
const
|
|
2835
|
+
const rawLastFullWallTs = acc.lastFullStepWallTs || 0;
|
|
2836
|
+
const batchCreatedTs = acc.pendingBatch?.createdAt || 0;
|
|
2837
|
+
const lastAnyWallTs = acc.pendingBatch?.executedAt || batchCreatedTs || 0;
|
|
2838
|
+
// Если отметка шага была записана без фактического обновления pendingBatch, не считаем такой шаг состоявшимся
|
|
2839
|
+
const lastFullWallTs = batchCreatedTs >= rawLastFullWallTs - 60000 ? rawLastFullWallTs : batchCreatedTs;
|
|
2840
|
+
const lastSchedBarTs = batchCreatedTs >= rawLastFullWallTs - 60000 ? (acc.lastScheduledBarTs || 0) : 0;
|
|
2794
2841
|
const hasOpenAlgoPos = (acc.lastSync?.positions || []).some((p) => p.qty && p.symbol !== 'LQDT' && p.symbol !== 'TMON@' && p.symbol !== 'TMON' && p.symbol !== 'SBMM' && algoQtyFor(acc, p.symbol, p.qty) !== 0);
|
|
2795
2842
|
const nowTs = Date.now();
|
|
2796
2843
|
const nightExitDue = autoAdapt && hasOpenAlgoPos && shouldExitBeforeNight(nowTs, 0, 3, BAR_INTERVAL) && (!lastFullWallTs || !shouldExitBeforeNight(lastFullWallTs, 0, 3, BAR_INTERVAL) || (nowTs - lastFullWallTs >= 15 * 60000));
|
|
2797
2844
|
const dueFullStep = Boolean(
|
|
2798
2845
|
nightExitDue || (
|
|
2799
2846
|
latest &&
|
|
2800
|
-
(latest -
|
|
2847
|
+
(latest - lastSchedBarTs >= minStepMs) &&
|
|
2801
2848
|
!(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
|
|
2802
2849
|
),
|
|
2803
2850
|
);
|
|
2804
2851
|
if (dueFullStep) {
|
|
2805
|
-
|
|
2806
|
-
acc.
|
|
2807
|
-
saveRawAccounts(list);
|
|
2852
|
+
if (isAccountBusy(acc.id)) continue;
|
|
2853
|
+
const stepBarTs = latest || acc.lastScheduledBarTs || nowTs;
|
|
2808
2854
|
if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
|
|
2809
|
-
await prepareBrokerOrders(acc.id).catch(() => {});
|
|
2855
|
+
await prepareBrokerOrders(acc.id, { scheduledBarTs: stepBarTs }).catch(() => {});
|
|
2810
2856
|
continue;
|
|
2811
2857
|
}
|
|
2812
2858
|
// Мгновенный перехват новых сигналов «купить» / «шорт» при наличии свободных рублей или резерва в LQDT (каждые 20 секунд, не дожидаясь конца 30-минутного шага!):
|
|
@@ -2920,12 +2966,12 @@ async function reconcileBrokerStops(accId) {
|
|
|
2920
2966
|
return;
|
|
2921
2967
|
}
|
|
2922
2968
|
if (!acc.lastSync?.ok) return;
|
|
2923
|
-
|
|
2924
|
-
|
|
2969
|
+
const lockToken = acquireAccountLock(accId);
|
|
2970
|
+
if (!lockToken) return; // идёт исполнение пакета — сверимся в следующий раз
|
|
2925
2971
|
try {
|
|
2926
|
-
await reconcileBrokerStopsLocked(list, acc);
|
|
2972
|
+
await withWallTimeout(reconcileBrokerStopsLocked(list, acc), 35000, 'Превышено время сверки стоп-заявок');
|
|
2927
2973
|
} finally {
|
|
2928
|
-
|
|
2974
|
+
releaseAccountLock(accId, lockToken);
|
|
2929
2975
|
}
|
|
2930
2976
|
}
|
|
2931
2977
|
|
package/server/model/dataset.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
import { ensureCandles, ensureDividends, exDate, mskToTs, tsToDate } from '../data/moex.js';
|
|
2
|
+
import { ensureCandles, ensureDividends, exDate, mskToTs, tsToDate, barMs } from '../data/moex.js';
|
|
3
3
|
import { ensureVocab, ensureIndexHistory } from './vocab.js';
|
|
4
4
|
import { kvGet, kvSet } from '../db.js';
|
|
5
5
|
|
|
@@ -221,13 +221,25 @@ export async function loadAlignedOpen(ds, symbol, { fromTs, tillTs, signal } = {
|
|
|
221
221
|
// цены с переносом последнего значения, обороты, доходности, рынок (IMOEX).
|
|
222
222
|
// Цены акций скорректированы на дивиденды (полная доходность; useDividends=false — без этого), макро — только при useMacro.
|
|
223
223
|
export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, useMacro = false, useDividends = true, localOnly = false, onProgress = () => {}, signal }) {
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
const mskDate = new Date(now + MSK);
|
|
226
|
+
const mskDow = mskDate.getUTCDay();
|
|
227
|
+
const mskMins = mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes();
|
|
228
|
+
const isTradingNow = !localOnly && mskDow >= 1 && mskDow <= 5 && mskMins >= 7 * 60 && mskMins <= 23 * 60 + 50 && tillTs >= now - 2 * 60000;
|
|
229
|
+
const bMs = barMs(interval);
|
|
230
|
+
const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - bMs) / bMs) * bMs;
|
|
231
|
+
const isCacheFreshForSlot = (entry) => {
|
|
232
|
+
if (!isTradingNow || now - entry.at < 50000) return true;
|
|
233
|
+
const lastBar = entry.ds?.timeline?.[entry.ds.T - 1] || 0;
|
|
234
|
+
return lastBar >= latestClosedSlotTs;
|
|
235
|
+
};
|
|
224
236
|
const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
|
|
225
237
|
const hit = datasetCache.get(key);
|
|
226
|
-
if (hit &&
|
|
238
|
+
if (hit && now - hit.at < 4 * 60000 && isCacheFreshForSlot(hit)) return hit.ds;
|
|
227
239
|
// Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
|
|
228
240
|
const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
|
|
229
241
|
for (const v of datasetCache.values()) {
|
|
230
|
-
if (v.modeKey === modeKey && (!v.localOnly || localOnly) &&
|
|
242
|
+
if (v.modeKey === modeKey && (!v.localOnly || localOnly) && now - v.at < 4 * 60000 && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000 && isCacheFreshForSlot(v)) {
|
|
231
243
|
return v.ds;
|
|
232
244
|
}
|
|
233
245
|
}
|
package/server/model/forecast.js
CHANGED
|
@@ -163,42 +163,53 @@ export const isModelTrainedToday = (p, now = Date.now()) => {
|
|
|
163
163
|
// в первую очередь берёт секундные цены реального времени из T-Invest API (GetLastPrices, 0 секунд задержки!),
|
|
164
164
|
// а недостающие тикеры и индекс IMOEX дополняет из MOEX ISS
|
|
165
165
|
async function fetchLiveMoexSnapshot() {
|
|
166
|
+
let timer = null;
|
|
166
167
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
return await Promise.race([
|
|
169
|
+
(async () => {
|
|
170
|
+
const [tbPrices, rShares, rIdx] = await Promise.all([
|
|
171
|
+
fetchTbankRealtimePrices().catch(() => null),
|
|
172
|
+
fetch('https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,LAST,MARKETPRICE,LCURRENTPRICE,VALTODAY', { signal: AbortSignal.timeout(6000) }).catch(() => null),
|
|
173
|
+
fetch('https://iss.moex.com/iss/engines/stock/markets/index/boards/SNDX/securities/IMOEX.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,CURRENTVALUE,LASTVALUE', { signal: AbortSignal.timeout(6000) }).catch(() => null),
|
|
174
|
+
]);
|
|
175
|
+
const prices = new Map();
|
|
176
|
+
if (rShares?.ok) {
|
|
177
|
+
const j = await rShares.json().catch(() => null);
|
|
178
|
+
const cols = j?.marketdata?.columns || [];
|
|
179
|
+
const iSec = cols.indexOf('SECID');
|
|
180
|
+
const iLast = cols.indexOf('LAST');
|
|
181
|
+
const iMkt = cols.indexOf('MARKETPRICE');
|
|
182
|
+
const iCur = cols.indexOf('LCURRENTPRICE');
|
|
183
|
+
for (const row of j?.marketdata?.data || []) {
|
|
184
|
+
const sym = row[iSec];
|
|
185
|
+
const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
|
|
186
|
+
if (sym && px > 0) prices.set(sym, px);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (tbPrices?.size) {
|
|
190
|
+
for (const [sym, px] of tbPrices) {
|
|
191
|
+
if (px > 0) prices.set(sym, px);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!prices.size) return null;
|
|
195
|
+
let imoexPrice = 0;
|
|
196
|
+
if (rIdx?.ok) {
|
|
197
|
+
const ji = await rIdx.json().catch(() => null);
|
|
198
|
+
const ic = ji?.marketdata?.columns || [];
|
|
199
|
+
const ir = ji?.marketdata?.data?.[0];
|
|
200
|
+
if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
|
|
201
|
+
}
|
|
202
|
+
return { prices, imoexPrice, ts: Date.now(), realtime: Boolean(tbPrices?.size) };
|
|
203
|
+
})(),
|
|
204
|
+
new Promise((resolve) => {
|
|
205
|
+
timer = setTimeout(() => resolve(null), 7000);
|
|
206
|
+
timer.unref?.();
|
|
207
|
+
}),
|
|
171
208
|
]);
|
|
172
|
-
const prices = new Map();
|
|
173
|
-
if (rShares?.ok) {
|
|
174
|
-
const j = await rShares.json().catch(() => null);
|
|
175
|
-
const cols = j?.marketdata?.columns || [];
|
|
176
|
-
const iSec = cols.indexOf('SECID');
|
|
177
|
-
const iLast = cols.indexOf('LAST');
|
|
178
|
-
const iMkt = cols.indexOf('MARKETPRICE');
|
|
179
|
-
const iCur = cols.indexOf('LCURRENTPRICE');
|
|
180
|
-
for (const row of j?.marketdata?.data || []) {
|
|
181
|
-
const sym = row[iSec];
|
|
182
|
-
const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
|
|
183
|
-
if (sym && px > 0) prices.set(sym, px);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
if (tbPrices?.size) {
|
|
187
|
-
for (const [sym, px] of tbPrices) {
|
|
188
|
-
if (px > 0) prices.set(sym, px);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
if (!prices.size) return null;
|
|
192
|
-
let imoexPrice = 0;
|
|
193
|
-
if (rIdx?.ok) {
|
|
194
|
-
const ji = await rIdx.json().catch(() => null);
|
|
195
|
-
const ic = ji?.marketdata?.columns || [];
|
|
196
|
-
const ir = ji?.marketdata?.data?.[0];
|
|
197
|
-
if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
|
|
198
|
-
}
|
|
199
|
-
return { prices, imoexPrice, ts: Date.now(), realtime: Boolean(tbPrices?.size) };
|
|
200
209
|
} catch {
|
|
201
210
|
return null;
|
|
211
|
+
} finally {
|
|
212
|
+
if (timer) clearTimeout(timer);
|
|
202
213
|
}
|
|
203
214
|
}
|
|
204
215
|
|