neuroalpha 0.3.15 → 0.3.16

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.
@@ -61,6 +61,10 @@ export async function fetchCandles(symbol, interval, from, till, { signal, onBat
61
61
  return rows;
62
62
  }
63
63
 
64
+ // Память времени последней проверки правой границы (свежих свечей) по каждому тикеру,
65
+ // чтобы во время серии экспериментов не опрашивать MOEX ISS по всем 100 акциям на каждом запуске
66
+ const lastTailCheckAt = new Map(); // `${symbol}:${interval}` -> timestamp
67
+
64
68
  // Догружает недостающие свечи в SQLite и возвращает их из кэша.
65
69
  // Метка kv candProbe:symbol:interval — насколько раннюю историю уже спрашивали у биржи:
66
70
  // у инструментов, вышедших на биржу позже начала периода, ранней истории нет, и без метки
@@ -71,24 +75,63 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
71
75
  const now = Date.now();
72
76
  const pKey = `candProbe:${symbol}:${interval}`;
73
77
  const probe = kvGet(pKey);
74
- const covered = probe && probe.fromTs <= fromTs + 5 * DAY; // раньше этой даты точно ничего нет
75
- let fetchFrom;
76
- if (!range.n) fetchFrom = fromTs;
77
- else {
78
- if (!covered && range.min > fromTs + 5 * DAY) {
79
- // один запрос: если биржа отдаёт первую свечу не раньше нашей — истории раньше не существует
80
- const first = (await fetchCandles(symbol, interval, tsToDate(fromTs), null, { signal, pages: 1 }))[0];
81
- if (!first || first.ts <= range.min + DAY) kvSet(pKey, { fromTs });
82
- else fetchFrom = fromTs; // ранний кусок и правда есть — качаем всё
78
+ // Защита от ложных меток candProbe v1 (где из-за старого условия first.ts <= range.min записывалось fromTs без скачивания):
79
+ // валидной считаем только метку v2 (или если range.min реально доходит до fromTs)
80
+ const covered = probe && probe.v === 2 && probe.fromTs <= fromTs + 5 * DAY;
81
+
82
+ if (!range.n) {
83
+ // В базе ещё нет свечей этой бумаги — качаем весь запрошенный диапазон один раз
84
+ await fetchCandles(symbol, interval, tsToDate(fromTs), tillTs < now ? tsToDate(tillTs) : null, {
85
+ signal,
86
+ onBatch: (page) => saveCandles(symbol, interval, page),
87
+ });
88
+ kvSet(pKey, { v: 2, fromTs });
89
+ lastTailCheckAt.set(`${symbol}:${interval}`, now);
90
+ return loadCandles(symbol, interval, fromTs, tillTs);
91
+ }
92
+
93
+ // 1) ЛЕВЫЙ КРАЙ:
94
+ // Если в локальной базе SQLite уже накоплена история (range.n >= 500 свечей), НИКОГДА не лезем на биржу докачивать левый край
95
+ // при смене «Окна обучения» или «Периода исследования» — работаем мгновенно по уже сохранённым в SQLite историческим свечам!
96
+ if (!covered && range.n < 500 && range.min > fromTs + 5 * DAY) {
97
+ const first = (await fetchCandles(symbol, interval, tsToDate(fromTs), null, { signal, pages: 1 }))[0];
98
+ if (!first || first.ts >= range.min - DAY) {
99
+ kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
100
+ } else {
101
+ const leftTill = tsToDate(range.min + DAY);
102
+ await fetchCandles(symbol, interval, tsToDate(fromTs), leftTill, {
103
+ signal,
104
+ onBatch: (page) => saveCandles(symbol, interval, page),
105
+ });
106
+ kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
83
107
  }
84
- // свежие свечи догружаем в любом случае
85
- if (fetchFrom === undefined && range.max < Math.min(tillTs, now) - barMs(interval)) fetchFrom = range.max;
108
+ } else if (!probe || probe.v !== 2 || probe.fromTs > fromTs) {
109
+ kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, range.min) });
86
110
  }
87
- if (fetchFrom !== undefined) {
88
- // сохраняем постранично: при обрыве акция продолжит загрузку с того места
89
- await fetchCandles(symbol, interval, tsToDate(fetchFrom), tillTs < now ? tsToDate(tillTs) : null, { signal, onBatch: (page) => saveCandles(symbol, interval, page) });
90
- kvSet(pKey, { fromTs: Math.min(fromTs, probe?.fromTs ?? fromTs) });
111
+
112
+ // 2) ПРАВЫЙ КРАЙ (свежие свечи текущего дня):
113
+ // Бесплатный контур MOEX ISS отдаёт 10-минутные свечи с задержкой ~20–30 минут.
114
+ // Поэтому если в SQLite уже есть свечи сегодняшней/вчерашней сессии (моложе 45 минут в торговые часы или моложе 16 часов вне сессии),
115
+ // или если мы уже проверяли хвост в последние 30 минут (метка хранится и в памяти, и в probe.tailCheckedAt в SQLite, переживая перезапуск сервера),
116
+ // то вообще не делаем сетевых запросов к Мосбирже!
117
+ const tailKey = `${symbol}:${interval}`;
118
+ const lastCheck = Math.max(lastTailCheckAt.get(tailKey) || 0, probe?.tailCheckedAt || 0);
119
+ const mskDate = new Date(now + MSK);
120
+ const mskDow = mskDate.getUTCDay();
121
+ const mskHour = mskDate.getUTCHours();
122
+ const isTradingHours = mskDow >= 1 && mskDow <= 5 && mskHour >= 9 && mskHour < 24;
123
+ const checkCooldownMs = isTradingHours ? 30 * 60000 : 4 * 3600000;
124
+ const dbIsAlreadyFresh = (now - range.max < 45 * 60000) || (!isTradingHours && now - range.max < 18 * 3600000);
125
+
126
+ if (!dbIsAlreadyFresh && range.max < Math.min(tillTs, now) - barMs(interval) && (now - lastCheck >= checkCooldownMs)) {
127
+ await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now ? tsToDate(tillTs) : null, {
128
+ signal,
129
+ onBatch: (page) => saveCandles(symbol, interval, page),
130
+ });
131
+ lastTailCheckAt.set(tailKey, now);
132
+ kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
91
133
  }
134
+
92
135
  return loadCandles(symbol, interval, fromTs, tillTs);
93
136
  }
94
137
 
@@ -147,20 +190,28 @@ async function fetchDividendsSmartlab(symbol, signal) {
147
190
  return list;
148
191
  }
149
192
 
150
- // Дивиденды акции с кэшем в kv на сутки; при ошибке сети — старый кэш
193
+ // Дивиденды акции с кэшем в kv на 7 суток (включая пустой список для бездивидендных бумаг); при ошибке сети — старый кэш
151
194
  export async function ensureDividends(symbol, { signal, localOnly = false } = {}) {
152
195
  const key = `dividends:${symbol}`;
153
196
  const cached = kvGet(key);
154
197
  if (localOnly) return cached?.list || [];
155
- if (cached && Date.now() - cached.at < DAY) return cached.list;
198
+ if (cached && Array.isArray(cached.list) && Date.now() - cached.at < 7 * DAY) return cached.list;
156
199
  let list = null;
157
- try { list = await fetchDividendsIss(symbol, signal); } catch { /* нет подписки/сети */ }
200
+ let networkOk = false;
201
+ try {
202
+ list = await fetchDividendsIss(symbol, signal);
203
+ networkOk = true;
204
+ } catch { /* нет подписки/сети */ }
158
205
  if (!list?.length) {
159
- try { list = await fetchDividendsSmartlab(symbol, signal); } catch { /* пропускаем */ }
206
+ try {
207
+ const sl = await fetchDividendsSmartlab(symbol, signal);
208
+ if (sl) list = sl;
209
+ networkOk = true;
210
+ } catch { /* пропускаем */ }
160
211
  }
161
- if (list) {
162
- kvSet(key, { at: Date.now(), list });
163
- return list;
212
+ const finalList = Array.isArray(list) ? list : (cached?.list || []);
213
+ if (networkOk || Array.isArray(list)) {
214
+ kvSet(key, { at: Date.now(), list: finalList });
164
215
  }
165
- return cached?.list || [];
216
+ return finalList;
166
217
  }
package/server/db.js CHANGED
@@ -196,11 +196,12 @@ for (const [tbl, col, def] of [
196
196
  }
197
197
  }
198
198
 
199
- // Гарантируем наличие локального пользователя по умолчанию (id = 1)
199
+ // Гарантируем наличие локального пользователя по умолчанию (id = 1, режим «Хозяин»)
200
200
  if (!db.prepare('SELECT id FROM users WHERE id = 1').get()) {
201
201
  db.prepare(`INSERT INTO users (id, email, name, pass_hash, role, plan, plan_until, notify_code, created_at)
202
- VALUES (1, 'owner@neuroalpha.local', 'Пользователь', '', 'admin', 'pro', 0, 'NA-OWNER1', ?)`).run(Date.now());
202
+ VALUES (1, 'owner@neuroalpha.local', 'Хозяин', '', 'admin', 'owner', 0, 'NA-OWNER1', ?)`).run(Date.now());
203
203
  } else {
204
+ db.prepare(`UPDATE users SET plan = 'owner' WHERE id = 1 AND role = 'admin' AND plan IN ('free', 'pro')`).run();
204
205
  db.prepare("UPDATE users SET name = 'Пользователь' WHERE id = 1 AND name = 'Владелец платформы'").run();
205
206
  }
206
207
  db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_api_key ON users(api_key) WHERE api_key IS NOT NULL');
@@ -203,6 +203,14 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
203
203
  const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends);
204
204
  const hit = datasetCache.get(key);
205
205
  if (hit && Date.now() - hit.at < 30 * 60000) return hit.ds;
206
+ // Если в памяти уже есть более широкий датасет (например, при изменении Окна обучения modelTrainDays),
207
+ // который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его мгновенно без пересборки!
208
+ const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
209
+ for (const v of datasetCache.values()) {
210
+ if (v.modeKey === modeKey && Date.now() - v.at < 30 * 60000 && v.fromTs <= fromTs + DAY && Math.abs(v.tillTs - tillTs) < 3600000) {
211
+ return v.ds;
212
+ }
213
+ }
206
214
  const vocab = await ensureVocab();
207
215
  const symbols = vocab.map((v) => v.symbol);
208
216
  const raw = {};
@@ -356,7 +364,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
356
364
  }
357
365
  }
358
366
  const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(T / days) : 54 };
359
- datasetCache.set(key, { ds, at: Date.now() });
360
- if (datasetCache.size > 2) datasetCache.delete(datasetCache.keys().next().value);
367
+ datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs });
368
+ if (datasetCache.size > 4) datasetCache.delete(datasetCache.keys().next().value);
361
369
  return ds;
362
370
  }
@@ -6,6 +6,8 @@ import { buildFold, MODELS, HEADS, KINDS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP
6
6
  import { loadProfiles } from './profiles.js';
7
7
  import { isMarketDown } from './research.js';
8
8
  import { newsForSymbols } from '../news/index.js';
9
+ import { kvGet, kvSet } from '../db.js';
10
+ import { hub } from '../hub.js';
9
11
 
10
12
  const trainedModelCache = new Map(); // fitKey -> { dayMsk, trainedAt, fTrain, fitted, snap }
11
13
  let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
@@ -176,22 +178,23 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
176
178
  }
177
179
  }
178
180
 
179
- // 2) пересчитываем признаки масштаба 0 (последний час) в точке u
180
- const sc0 = SCALES[0];
181
- const invP = 1 / Math.sqrt(sc0.len), invF = invP / 1.5;
182
- const Xp0 = f.X[0][0], Xf0 = f.X[1][0];
183
- for (let i = 0; i < N; i++) {
184
- let sp = 0, sf = 0;
185
- for (let k = 0; k < sc0.len; k++) {
186
- const uk = u - k;
187
- if (uk < 0) break;
188
- const zk = f.z[uk * N + i];
189
- sp += zk;
190
- sf += zk * Math.min(4, ds.tokens[i].valRatio[f.lo + uk]);
181
+ // 2) пересчитываем все масштабы SCALES в точке u (как в buildInferenceFoldAt)
182
+ SCALES.forEach((sc, si) => {
183
+ const Xp = f.X[0][si], Xf = f.X[1][si];
184
+ const invP = 1 / Math.sqrt(sc.len), invF = invP / 1.5;
185
+ for (let i = 0; i < N; i++) {
186
+ let sp = 0, sf = 0;
187
+ for (let k = 0; k < sc.len; k++) {
188
+ const uk = u - sc.from - k;
189
+ if (uk < 0) break;
190
+ const zk = f.z[uk * N + i];
191
+ sp += zk;
192
+ sf += zk * Math.min(4, ds.tokens[i].valRatio[f.lo + uk]);
193
+ }
194
+ Xp[u * N + i] = sp * invP;
195
+ Xf[u * N + i] = sf * invF;
191
196
  }
192
- Xp0[u * N + i] = sp * invP;
193
- Xf0[u * N + i] = sf * invF;
194
- }
197
+ });
195
198
 
196
199
  // 3) пересчитываем головы внимания F[hi][u*N + j] в точке u
197
200
  const att = fitted.attention;
@@ -240,7 +243,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
240
243
  const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
241
244
  const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
242
245
  const u = idx - f.lo;
243
- const list = [...new Set(explain ? Object.keys(MODELS) : ['attention', chosen])];
246
+ // Обучаем только выбранную модель (и attention для объяснения соседей и весов голов),
247
+ // не тратя 15 секунд на холостое обучение матрицы leadlag, которая в прогнозе и живой торговле не используется!
248
+ const list = [...new Set(['attention', chosen])];
244
249
  const fitted = {};
245
250
  for (const n of list) {
246
251
  fitted[n] = await MODELS[n].fit(f);
@@ -283,6 +288,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
283
288
  const dir = p.modelDirection || 'long';
284
289
  const needL = autoAdapt && bear ? threshold * 1.25 : threshold;
285
290
  const needS = autoAdapt && dir === 'longshort' ? (bear ? threshold * 0.90 : threshold * 1.25) : threshold;
291
+ const shortableMap = kvGet('tbankShortableMap')?.map || null;
286
292
  let sInv = 0, nInv = 0;
287
293
  for (let i = 0; i < ds.tokens.length; i++) if (f.sigma[i] > 0 && ds.tokens[i].liq[idx] >= LIQ_MIN) { sInv += 1 / f.sigma[i]; nInv++; }
288
294
  const avgInv = nInv ? sInv / nInv : 0;
@@ -290,6 +296,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
290
296
  const exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
291
297
  const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
292
298
  const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
299
+ const rawShortHit = dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -needS;
300
+ const shortBlockedByBroker = Boolean(rawShortHit && shortableMap && shortableMap[tk.symbol] === false);
293
301
  const row = {
294
302
  i, symbol: tk.symbol, name: tk.name,
295
303
  price: round(livePx, 4),
@@ -300,7 +308,10 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
300
308
  weight: round(weight, 3),
301
309
  signal: tk.liq[idx] >= LIQ_MIN && exp > needL,
302
310
  };
303
- if (dir !== 'long') row.shortSignal = tk.liq[idx] >= LIQ_MIN && exp < -needS;
311
+ if (dir !== 'long') {
312
+ row.shortSignal = rawShortHit && !shortBlockedByBroker;
313
+ if (shortBlockedByBroker) row.shortBlockedByBroker = true;
314
+ }
304
315
  if (explain) {
305
316
  const ex = explainAttention(fitted.attention, f, u, i);
306
317
  row.influences = ex.neighbors.map((x) => ({ symbol: ds.tokens[x.i].symbol, contribPct: round(x.contrib * f.sigma[i] * Math.sqrt(H) * 100, 3), closeness: round(f.cos[i][x.i], 2) }));
@@ -308,7 +319,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
308
319
  }
309
320
  return row;
310
321
  });
311
- return { f, u, fitted, chosen, threshold, marketDown: bear, rows };
322
+ return { f, u, fitted, chosen, threshold, needL, needS, marketDown: bear, rows };
312
323
  }
313
324
 
314
325
  // Новости — только в прогнозе «сейчас»
@@ -387,15 +398,36 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
387
398
  at: now,
388
399
  liveTs,
389
400
  bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
401
+ buyThresholdPct: round((r.needL ?? r.threshold) * 100, 3),
402
+ shortThresholdPct: round((r.needS ?? r.threshold) * 100, 3),
403
+ marketDown: r.marketDown,
390
404
  rows: sortedRows,
391
405
  };
392
406
  baseState.lastTickResult = result;
393
407
  baseState.lastTickAt = now;
408
+ kvSet('lastForecastSnap', { key, result });
409
+ hub.emit({ type: 'trading:pulse', kind: 'radar', ts: now, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
394
410
  return withNews(result, p);
395
411
  }
396
412
 
413
+ // 2б) Сразу после перезапуска сервера (пока baseState ещё не прогрет в RAM):
414
+ // если в SQLite есть свежий снимок прогноза с теми же настройками (моложе 4 часов),
415
+ // мгновенно отдаём его пользователю за 0 мс, а пересчёт свежих котировок запускаем параллельно в фоне!
416
+ if (!force && !baseState) {
417
+ const saved = kvGet('lastForecastSnap');
418
+ if (saved?.result && saved.key === key && now - (saved.result.at || 0) < 4 * 3600000) {
419
+ if (!inflight.has(key)) {
420
+ setImmediate(() => {
421
+ forecastNow(p, { force: true }).catch(() => {});
422
+ });
423
+ }
424
+ return withNews(saved.result, p);
425
+ }
426
+ }
427
+
397
428
  if (!force && inflight.has(key)) return withNews(await inflight.get(key), p);
398
429
  const task = (async () => {
430
+ hub.emit({ type: 'trading:pulse', kind: 'train', ts: now, text: 'Обновление котировок и расчёт прогноза…' });
399
431
  const trainedToday = !force && isModelTrainedToday(p, now);
400
432
  const windowDays = trainedToday ? 7 : Math.ceil(p.modelTrainDays * 1.5 + 25);
401
433
  const [ds, liveSnap] = await Promise.all([
@@ -416,7 +448,10 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
416
448
  liveTs,
417
449
  barTs,
418
450
  bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
419
- horizon: effHorizon, direction: p.modelDirection, model: r.chosen, thresholdPct: round(r.threshold * 100, 3), marketDown: r.marketDown,
451
+ horizon: effHorizon, direction: p.modelDirection, model: r.chosen, thresholdPct: round(r.threshold * 100, 3),
452
+ buyThresholdPct: round((r.needL ?? r.threshold) * 100, 3),
453
+ shortThresholdPct: round((r.needS ?? r.threshold) * 100, 3),
454
+ marketDown: r.marketDown,
420
455
  rows: sortedRows,
421
456
  map: { symbols: ds.tokens.map((t) => t.symbol), names: ds.tokens.map((t) => t.name), pts: snap.pts.map(([x, y]) => [round(x, 3), round(y, 3)]), cl: snap.clusters },
422
457
  heads: HEADS.map((h, i) => ({ label: h.label, coef: round(r.fitted.attention.coef[i], 4) })),
@@ -437,6 +472,8 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
437
472
  lastTickResult: result,
438
473
  lastTickAt: now,
439
474
  };
475
+ kvSet('lastForecastSnap', { key, result });
476
+ hub.emit({ type: 'trading:pulse', kind: 'radar', ts: now, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
440
477
  return result;
441
478
  })().finally(() => inflight.delete(key));
442
479
  inflight.set(key, task);
@@ -328,24 +328,39 @@ class LiveEngine {
328
328
  saveTrade(s.runId, t);
329
329
  hub.emit({ type: 'paper:trade', runId: s.runId, trade: t });
330
330
  }
331
- const K = s.params.modelTopK;
331
+ const rawTopK = Number(s.params.modelTopK ?? 2);
332
+ const K = rawTopK > 0 ? rawTopK : 4;
332
333
  const dir = this.#direction(s);
333
334
  const slots = dir === 'long' ? K : 2 * K;
334
335
  const autoAdapt = s.params.modelAutoAdapt !== false;
335
- const maxSlotWeight = dir === 'long' ? Math.min(0.48, Math.max(1 / K, 1.4 / K)) : Math.min(0.48, 1.9 / (2 * K));
336
+ const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
336
337
  const invVol = s.params.modelAllocMode === 'invvol';
337
338
  const effMinEdge = autoAdapt ? 2.5 : s.params.modelMinEdge;
338
339
  const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct);
339
340
  const buyOrders = s.pending.filter((x) => x.action === 'buy' || x.action === 'short');
341
+ if (autoAdapt && buyOrders.length > 1) {
342
+ buyOrders.sort((a, b) => Math.abs(b.expectedPct || 0) - Math.abs(a.expectedPct || 0));
343
+ }
340
344
  const usedStart = [...s.held.values()].reduce((sum, h) => sum + h.alloc, 0);
341
345
  let avail = Math.max(0, s.equity * 0.99 - usedStart);
346
+ const activeTotal = Math.max(2, s.held.size + buyOrders.length);
347
+ const dynSlotWeight = autoAdapt
348
+ ? Math.min(dir === 'long' ? 0.58 : 0.75, Math.max(maxSlotWeight, (dir === 'long' ? 1.8 : 3.0) / Math.min(slots, activeTotal + 1)))
349
+ : maxSlotWeight;
350
+ let remWeightSum = buyOrders.reduce((sum, o) => sum + Math.max(0.01, Math.abs(o.expectedPct || 0)), 0);
342
351
  for (let idx = 0; idx < buyOrders.length; idx++) {
343
352
  const o = buyOrders[idx];
344
353
  const side = o.action === 'short' ? 'short' : 'long';
345
354
  const px = price(o.symbol);
346
- if (s.held.has(o.symbol) || !(px > 0) || [...s.held.values()].filter((h) => h.side === side).length >= K) continue;
347
- const conv = autoAdapt && needPct > 0 && o.expectedPct ? Math.min(1.25, Math.max(0.85, 0.80 + 0.25 * (Math.abs(o.expectedPct) / needPct))) : 1;
348
- const baseAlloc = autoAdapt ? Math.min(s.equity * maxSlotWeight * conv, avail / Math.max(1, buyOrders.length - idx)) : Math.min(s.equity / slots, avail / Math.max(1, buyOrders.length - idx));
355
+ const wCur = Math.max(0.01, Math.abs(o.expectedPct || 0));
356
+ if (s.held.has(o.symbol) || !(px > 0) || [...s.held.values()].filter((h) => h.side === side).length >= K) {
357
+ remWeightSum = Math.max(0, remWeightSum - wCur);
358
+ continue;
359
+ }
360
+ const conv = autoAdapt && needPct > 0 && o.expectedPct ? Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (Math.abs(o.expectedPct) / needPct))) : 1;
361
+ const propShare = autoAdapt && remWeightSum > 0 ? avail * (wCur / remWeightSum) : avail / Math.max(1, buyOrders.length - idx);
362
+ remWeightSum = Math.max(0, remWeightSum - wCur);
363
+ const baseAlloc = autoAdapt ? Math.min(s.equity * dynSlotWeight * conv, propShare, avail) : Math.min(s.equity / slots, avail / Math.max(1, buyOrders.length - idx));
349
364
  let alloc = baseAlloc;
350
365
  if (invVol && o.weight > 0) {
351
366
  alloc = Math.min(baseAlloc * o.weight, avail / Math.max(1, buyOrders.length - idx));
@@ -386,7 +401,9 @@ class LiveEngine {
386
401
  const r = await predictAt(ds, p, idx);
387
402
  const row = new Map(r.rows.map((x) => [x.symbol, x]));
388
403
  const dir = this.#direction(s);
389
- const K = p.modelTopK;
404
+ const rawTopK = Number(p.modelTopK ?? 2);
405
+ const autoK = rawTopK === 0 || autoAdapt;
406
+ const K = rawTopK > 0 ? rawTopK : 4;
390
407
  const closing = new Set();
391
408
  const closePos = (sym, why) => {
392
409
  const x = row.get(sym);
@@ -428,6 +445,13 @@ class LiveEngine {
428
445
  if (quarantined.has(x.symbol)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
429
446
  const b = nb?.get(x.symbol)?.[side === 'buy' ? 'bad' : 'good'];
430
447
  if (b) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: `Пропущена: ${side === 'buy' ? 'плохая' : 'хорошая'} новость — ${b.gist}` }); continue; }
448
+ if (autoK && out.length >= 1) {
449
+ const bestE = Math.abs(out[0].expected);
450
+ const curE = Math.abs(x.expected);
451
+ const relMin = out.length === 1 ? 0.62 : 0.78;
452
+ const absMin = out.length === 1 ? r.threshold * 1.15 : r.threshold * 1.45;
453
+ if (!(curE >= bestE * relMin && curE >= absMin)) break;
454
+ }
431
455
  out.push(x);
432
456
  }
433
457
  return out;
@@ -131,12 +131,66 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
131
131
  const diag = Array.from({ length: N }, (_, i) => Math.sqrt(Math.max(C[i][i], 1e-9)));
132
132
  for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) C[i][j] /= diag[i] * diag[j];
133
133
  const eig = eigenSym(C, { maxSweeps: 30 });
134
- const d = Math.min(embedDim, N - 1);
135
- const emb = Array.from({ length: N }, (_, i) => Float64Array.from({ length: d }, (_, c) => eig.vectors[c][i] * Math.sqrt(Math.max(eig.values[c], 0))));
136
- const embN = emb.map((e) => { const n = Math.hypot(...e) || 1; return e.map((v) => v / n); });
134
+ const rawEmbDim = Math.round(Number(embedDim ?? 0));
135
+ let reqDim;
136
+ if (rawEmbDim <= 0) {
137
+ // Авто-калибровка размерности эмбеддинга по спектру случайных матриц (закон Марченко–Пастура с поправкой на главные моды):
138
+ // оцениваем дисперсию фонового шума sigmaBulk по средней части спектра (исключая главные рыночные моды сверху
139
+ // и вырожденные парные моды обычка/преф снизу) и отбираем структурные собственные числа выше границы lambdaPlus
140
+ const cutLo = Math.floor(eig.values.length * 0.32);
141
+ const cutHi = Math.max(cutLo + 1, Math.floor(eig.values.length * 0.92));
142
+ const bulkSlice = eig.values.slice(cutLo, cutHi);
143
+ const sigmaBulk = bulkSlice.reduce((a, b) => a + b, 0) / Math.max(1, bulkSlice.length);
144
+ const effSamples = Math.min(u1 - u0, 90 * 93) * 0.90;
145
+ const qRatio = N / Math.max(N + 1, effSamples);
146
+ const lambdaPlus = sigmaBulk * ((1 + Math.sqrt(qRatio)) ** 2);
147
+ let sigCount = 0;
148
+ for (let c = 0; c < eig.values.length; c++) {
149
+ if (eig.values[c] >= lambdaPlus) sigCount++;
150
+ }
151
+ reqDim = Math.max(16, Math.min(36, sigCount));
152
+ } else {
153
+ reqDim = Math.max(2, Math.min(1000, rawEmbDim));
154
+ }
155
+ const baseDim = Math.min(reqDim, N - 1);
156
+ const buildExtendedEmb = (ev) => Array.from({ length: N }, (_, i) => {
157
+ const vec = new Float64Array(reqDim);
158
+ for (let c = 0; c < baseDim; c++) {
159
+ vec[c] = ev.vectors[c][i] * Math.sqrt(Math.max(ev.values[c], 0));
160
+ }
161
+ if (reqDim > baseDim) {
162
+ let idx = baseDim;
163
+ const topM = Math.min(baseDim, 45);
164
+ for (let a = 0; a < topM && idx < reqDim; a++) {
165
+ for (let b = a; b < topM && idx < reqDim; b++) {
166
+ const scale = a === b ? 0.5 : 0.70710678;
167
+ vec[idx++] = vec[a] * vec[b] * scale;
168
+ }
169
+ }
170
+ for (let a = 0; a < topM && idx < reqDim; a++) {
171
+ for (let b = a + 1; b < topM && idx < reqDim; b++) {
172
+ for (let g = b + 1; g < topM && idx < reqDim; g++) {
173
+ vec[idx++] = vec[a] * vec[b] * vec[g] * 0.5;
174
+ }
175
+ }
176
+ }
177
+ }
178
+ return vec;
179
+ });
180
+ const normVec = (e) => {
181
+ let s = 0;
182
+ for (let c = 0; c < e.length; c++) s += e[c] * e[c];
183
+ const n = Math.sqrt(s) || 1;
184
+ const out = new Float64Array(e.length);
185
+ for (let c = 0; c < e.length; c++) out[c] = e[c] / n;
186
+ return out;
187
+ };
188
+ const d = reqDim;
189
+ const emb = buildExtendedEmb(eig);
190
+ const embN = emb.map(normVec);
137
191
  const cos = matrix(N, N);
138
192
  for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) { let s = 0; for (let c = 0; c < d; c++) s += embN[i][c] * embN[j][c]; cos[i][j] = s; }
139
- let eigVals = eig.values.slice(0, d);
193
+ let eigVals = eig.values.slice(0, baseDim);
140
194
 
141
195
  // ---------- смешивание с «текстовой» близостью (устойчивые признаки бизнеса из профилей ИИ) ----------
142
196
  // итог: cos = (1−w)·близость по поведению + w·близость по центрированным профилям;
@@ -164,13 +218,12 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
164
218
  }
165
219
  // эмбеддинги и «головы внимания» дальше используют смешанную матрицу
166
220
  const em = eigenSym(cos, { maxSweeps: 30 });
221
+ const extEm = buildExtendedEmb(em);
167
222
  for (let i = 0; i < N; i++) {
168
- const e = Float64Array.from({ length: d }, (_, c) => em.vectors[c][i] * Math.sqrt(Math.max(em.values[c], 0)));
169
- const n = Math.hypot(...e) || 1;
170
- emb[i] = e;
171
- embN[i] = e.map((v) => v / n);
223
+ emb[i] = extEm[i];
224
+ embN[i] = normVec(extEm[i]);
172
225
  }
173
- eigVals = em.values.slice(0, d);
226
+ eigVals = em.values.slice(0, baseDim);
174
227
  }
175
228
  }
176
229
 
@@ -214,7 +267,7 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
214
267
  if (list.length) macro = { list };
215
268
  }
216
269
 
217
- return { N, lo, hi, W, trainStart, testStart, testEnd, H, Hfast, Hslow, multiHorizon, beta, sigma, z, has, liq, X, yz, yraw, yok, emb, embN, cos, eigenvalues: eigVals, macro };
270
+ return { N, lo, hi, W, trainStart, testStart, testEnd, H, Hfast, Hslow, multiHorizon, beta, sigma, z, vr, has, liq, X, yz, yraw, yok, emb, embN, cos, eigenvalues: eigVals, macro };
218
271
  }
219
272
 
220
273
  // Ликвидная акция: сделки хотя бы в 15 из последних 20 свечей
@@ -330,31 +383,60 @@ export function attentionMatrices(f) {
330
383
  export function headFeatures(f, As) {
331
384
  const { N, W } = f;
332
385
  const F = HEADS.map(() => new Float32Array(W * N));
386
+ // По линейности умножения на матрицу внимания A (постоянную внутри фолда):
387
+ // сначала один раз на свечу u умножаем матрицы A[0] и A[1] на базовые ряды zp = z и zf = z * vr,
388
+ // а затем за O(W * N) берём скользящие суммы по 3 масштабам SCALES (в 3 раза быстрее при 100% совпадении результата)
389
+ const zp = f.z;
390
+ const zf = new Float64Array(W * N);
391
+ if (f.vr) {
392
+ for (let k = 0; k < W * N; k++) zf[k] = zp[k] * f.vr[k];
393
+ }
394
+ const A0 = As[0], A1 = As[1];
395
+ const Ap0 = new Float64Array(W * N), Af0 = new Float64Array(W * N);
396
+ const Ap1 = new Float64Array(W * N), Af1 = new Float64Array(W * N);
397
+ for (let u = 0; u < W; u++) {
398
+ const off = u * N;
399
+ for (let j = 0; j < N; j++) {
400
+ let sp0 = 0, sf0 = 0, sp1 = 0, sf1 = 0;
401
+ const row = j * N;
402
+ for (let i = 0; i < N; i++) {
403
+ const w0 = A0[row + i], w1 = A1[row + i];
404
+ const k = off + i;
405
+ const vp = zp[k], vf = zf[k];
406
+ sp0 += w0 * vp; sf0 += w0 * vf;
407
+ sp1 += w1 * vp; sf1 += w1 * vf;
408
+ }
409
+ const kj = off + j;
410
+ Ap0[kj] = sp0; Af0[kj] = sf0;
411
+ Ap1[kj] = sp1; Af1[kj] = sf1;
412
+ }
413
+ }
333
414
  for (let bIdx = 0; bIdx < BETAS.length; bIdx++) {
334
415
  const betaVal = BETAS[bIdx].v;
335
- const A = As[bIdx];
336
- const hIdxs = [];
337
- for (let hi = 0; hi < HEADS.length; hi++) if (HEADS[hi].beta === betaVal) hIdxs.push(hi);
338
- const Xs = hIdxs.map((hi) => f.X[HEADS[hi].kind === 'price' ? 0 : 1][HEADS[hi].scale]);
339
- const Fs = hIdxs.map((hi) => F[hi]);
340
- const [X0, X1, X2, X3, X4, X5] = Xs;
341
- const [F0, F1, F2, F3, F4, F5] = Fs;
342
- for (let u = FEATURE_WARMUP; u < W; u++) {
343
- const off = u * N;
344
- for (let j = 0; j < N; j++) {
345
- let s0 = 0, s1 = 0, s2 = 0, s3 = 0, s4 = 0, s5 = 0;
346
- const row = j * N;
347
- for (let i = 0; i < N; i++) {
348
- const w = A[row + i];
349
- const k = off + i;
350
- s0 += w * X0[k]; s1 += w * X1[k]; s2 += w * X2[k];
351
- s3 += w * X3[k]; s4 += w * X4[k]; s5 += w * X5[k];
416
+ const Ap = bIdx === 0 ? Ap0 : Ap1;
417
+ const Af = bIdx === 0 ? Af0 : Af1;
418
+ SCALES.forEach((sc, si) => {
419
+ const hP = HEADS.findIndex((h) => h.beta === betaVal && h.kind === 'price' && h.scale === si);
420
+ const hF = HEADS.findIndex((h) => h.beta === betaVal && h.kind === 'flow' && h.scale === si);
421
+ const Fp = F[hP], Ff = F[hF];
422
+ const invP = 1 / Math.sqrt(sc.len), invF = invP / 1.5;
423
+ const sp = new Float64Array(N), sf = new Float64Array(N);
424
+ for (let u = 0; u < W; u++) {
425
+ const a = u - sc.from, b = u - sc.from - sc.len;
426
+ if (a >= 0) {
427
+ const offA = a * N;
428
+ for (let j = 0; j < N; j++) { sp[j] += Ap[offA + j]; sf[j] += Af[offA + j]; }
429
+ }
430
+ if (b >= 0) {
431
+ const offB = b * N;
432
+ for (let j = 0; j < N; j++) { sp[j] -= Ap[offB + j]; sf[j] -= Af[offB + j]; }
433
+ }
434
+ if (u >= FEATURE_WARMUP) {
435
+ const offU = u * N;
436
+ for (let j = 0; j < N; j++) { Fp[offU + j] = sp[j] * invP; Ff[offU + j] = sf[j] * invF; }
352
437
  }
353
- const kj = off + j;
354
- F0[kj] = s0; F1[kj] = s1; F2[kj] = s2;
355
- F3[kj] = s3; F4[kj] = s4; F5[kj] = s5;
356
438
  }
357
- }
439
+ });
358
440
  }
359
441
  return F;
360
442
  }