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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.5.27",
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>`;
@@ -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 r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
87
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
88
- const j = await r.json();
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 accountBusyLocks = new Set();
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
- export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1580
- if (accountBusyLocks.has(id)) return brokersState();
1581
- accountBusyLocks.add(id);
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
- accountBusyLocks.delete(id);
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 (accountBusyLocks.has(id) && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 300));
2481
- if (accountBusyLocks.has(id)) throw new Error('Счёт сейчас занят пересчётом заявок — повторите через минуту');
2482
- accountBusyLocks.add(id);
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) accountBusyLocks.delete(id);
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 lastFullWallTs = acc.lastFullStepWallTs || 0;
2793
- const lastAnyWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
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 - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
2847
+ (latest - lastSchedBarTs >= minStepMs) &&
2801
2848
  !(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
2802
2849
  ),
2803
2850
  );
2804
2851
  if (dueFullStep) {
2805
- acc.lastScheduledBarTs = latest || acc.lastScheduledBarTs || nowTs;
2806
- acc.lastFullStepWallTs = nowTs;
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
- if (accountBusyLocks.has(accId)) return; // идёт исполнение пакета — сверимся в следующий раз
2924
- accountBusyLocks.add(accId);
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
- accountBusyLocks.delete(accId);
2974
+ releaseAccountLock(accId, lockToken);
2929
2975
  }
2930
2976
  }
2931
2977
 
@@ -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 && Date.now() - hit.at < 4 * 60000) return hit.ds;
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) && Date.now() - v.at < 4 * 60000 && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000) {
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
  }
@@ -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
- const [tbPrices, rShares, rIdx] = await Promise.all([
168
- fetchTbankRealtimePrices().catch(() => null),
169
- 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),
170
- 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),
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