neuroalpha 0.5.27 → 0.5.29

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.
@@ -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
 
@@ -367,10 +378,16 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
367
378
  const p30m = tk.close[idx30m] || 0;
368
379
  const p1d = tk.close[idx1d] || 0;
369
380
  const p3d = tk.close[idx3d] || 0;
381
+ let maxPx1d = livePx;
382
+ for (let t = idx1d; t <= idx; t++) {
383
+ const c = tk.close[t];
384
+ if (c > maxPx1d) maxPx1d = c;
385
+ }
370
386
  const excess30m = (p30m > 0 && livePx > 0 ? (livePx / p30m - 1) : 0) - m30m;
371
387
  const excess1d = (p1d > 0 && livePx > 0 ? (livePx / p1d - 1) : 0) - m1d;
388
+ const peakExcess1d = (p1d > 0 && maxPx1d > 0 ? (maxPx1d / p1d - 1) : 0) - m1d;
372
389
  const excess3d = (p3d > 0 && livePx > 0 ? (livePx / p3d - 1) : 0) - m3d;
373
- const isOverheated = excess1d >= 0.045 || excess3d >= 0.09;
390
+ const isOverheated = excess1d >= 0.045 || peakExcess1d >= 0.045 || excess3d >= 0.09;
374
391
  const ex = (explain || isOverheated) ? explainAttention(fitted.attention, f, u, i) : null;
375
392
  let selfRaw = ex ? ex.self * f.sigma[i] * Math.sqrt(H) : 0;
376
393
  if (isOverheated && selfRaw > 0) {
@@ -192,9 +192,17 @@ export async function notifyUser(userId, text, buttons = null) {
192
192
 
193
193
  export async function notifyBrokerBatch(userId, acc, batch) {
194
194
  if (!batch?.orders?.length) return;
195
- const lines = batch.orders.map((o) => `• ${o.action === 'buy' ? 'Купить' : 'Продать'} ${o.symbol}: ${o.lots} л. (${o.shares} шт.) по ${o.limitPrice} ₽ ≈ ${round(o.amountRub, 0)} ₽`);
196
- const text = `НейроАльфа · Счёт «${acc.name}»\nСформирован пакет лимитных заявок (свеча ${batch.bar || '—'}):\n${lines.join('\n')}`;
197
- const buttons = acc.mode === 'confirm' ? [
195
+ const actionLabel = (a) => (a === 'buy' ? 'Купить' : a === 'cover' ? 'Закрыть шорт (купить)' : a === 'short' ? 'Шорт (продать)' : 'Продать');
196
+ const lines = batch.orders.map((o) => {
197
+ const why = o.reason ? `\n ↳ ${o.reason}` : '';
198
+ return `• ${actionLabel(o.action)} ${o.symbol}: ${o.lots} л. (${o.shares} шт.) по ${o.limitPrice} ₽ ≈ ${round(o.amountRub, 0)} ₽${why}`;
199
+ });
200
+ const isExecuted = batch.status === 'executed' || batch.status === 'partial';
201
+ const header = isExecuted
202
+ ? `Выставлены лимитные заявки (свеча ${batch.bar || '—'}):`
203
+ : `Сформирован пакет лимитных заявок (свеча ${batch.bar || '—'}):`;
204
+ const text = `НейроАльфа · Счёт «${acc.name}»\n${header}\n${lines.join('\n')}`;
205
+ const buttons = acc.mode === 'confirm' && !isExecuted ? [
198
206
  { label: `✓ Исполнить (${batch.orders.length})`, data: `exec:${userId}:${acc.id}`, action: 'exec', accountId: acc.id, batchId: batch.id || '' },
199
207
  { label: '✕ Отклонить', data: `rej:${userId}:${acc.id}`, action: 'rej', accountId: acc.id, batchId: batch.id || '' },
200
208
  ] : null;