neuroalpha 0.5.1 → 0.5.2

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.1",
3
+ "version": "0.5.2",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -2304,6 +2304,7 @@ function renderBrokers() {
2304
2304
  <span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
2305
2305
  <span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
2306
2306
  <span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.broker === 'tbank' ? ` · стоп-заявки у брокера: <b>${a.brokerStops ? `<span class="pos">вкл.</span> (${Object.keys(a.brokerStopBySymbol || {}).length} активн.)` : 'выкл.'}</b> · кэш в LQDT: <b>${a.cashSweep ? '<span class="pos">вкл.</span>' : 'выкл.'}</b>` : ''}${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
2307
+ ${a.executionAudit ? `<span class="k">Реальные издержки (сверка):</span><span class="v" title="Сверка фактических сделок счёта (после отключения автоследования) с ориентиром стресс-теста">комиссия <b>${fmtNum(a.executionAudit.commPct, 3)}%</b>${a.executionAudit.slipAllPct !== null ? ` · проскальзывание к сигналу <b>${a.executionAudit.slipAllPct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipAllPct, 3)}%</b>${a.executionAudit.slipTier1Pct !== null ? ` (1-й эш. ${a.executionAudit.slipTier1Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier1Pct, 3)}%${a.executionAudit.slipTier2Pct !== null ? `, 2–3-й эш. ${a.executionAudit.slipTier2Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier2Pct, 3)}%` : ''})` : ''}` : ''} · итого <b class="${a.executionAudit.totalCostPerSidePct <= a.executionAudit.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(a.executionAudit.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности стресс-теста ≤ ${fmtNum(a.executionAudit.breakEvenTargetPct, 3)}%, закрыто ${a.executionAudit.closedCount} поз.${a.executionAudit.profitFactorGross !== null ? `, PF до ком. ${fmtNum(a.executionAudit.profitFactorGross, 2)}` : ''})</span>` : ''}
2307
2308
  </div>
2308
2309
  ${ls?.ok ? `<div class="kpis">
2309
2310
  <div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
@@ -3227,6 +3228,8 @@ const helpCache = new Map();
3227
3228
  // ---------- журнал исполнения по счёту ----------
3228
3229
  const EVENT_LABELS = {
3229
3230
  order_sent: ['заявка отправлена', ''],
3231
+ order_expired: ['заявка снята (>12 мин)', 'muted'],
3232
+ spread_skip: ['пропуск входа (широкий спред)', 'mid'],
3230
3233
  order_error: ['ошибка заявки', 'neg'],
3231
3234
  protective_stop: ['защитный стоп', 'neg'],
3232
3235
  protective_stop_error: ['ошибка защитного стопа', 'neg'],
@@ -3245,12 +3248,25 @@ async function openBrokerEvents(id) {
3245
3248
  $('#events-modal').hidden = false;
3246
3249
  try {
3247
3250
  const { events } = await api(`/api/brokers/${encodeURIComponent(id)}/events?limit=300`);
3248
- $('#events-body').innerHTML = events.length
3251
+ const sentCnt = events.filter((e) => e.kind === 'order_sent').length;
3252
+ const expCnt = events.filter((e) => e.kind === 'order_expired').length;
3253
+ const skipCnt = events.filter((e) => e.kind === 'spread_skip').length;
3254
+ const errCnt = events.filter((e) => e.kind === 'order_error').length;
3255
+ const fillPct = sentCnt > 0 ? Math.max(0, Math.round(((sentCnt - expCnt) / sentCnt) * 100)) : null;
3256
+ const ea = acc?.executionAudit;
3257
+ const summaryHtml = (ea || sentCnt > 0)
3258
+ ? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head" style="flex-wrap:wrap;gap:10px">
3259
+ <b>Сверка исполнения со стресс-тестом</b>
3260
+ ${ea ? `<span class="small">Сделок: <b>${ea.tradesCount}</b> (закрыто <b>${ea.closedCount}</b> поз.) · Комиссия: <b>${fmtNum(ea.commPct, 3)}%</b> · Отклонение от сигнала: <b>${ea.slipAllPct !== null ? `${ea.slipAllPct > 0 ? '+' : ''}${fmtNum(ea.slipAllPct, 3)}%` : '—'}</b> · Итого издержки: <b class="${ea.totalCostPerSidePct <= ea.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(ea.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности ≤ ${fmtNum(ea.breakEvenTargetPct, 3)}%)</span>` : ''}
3261
+ ${sentCnt > 0 ? `<span class="small muted">По журналу: отправлено ${sentCnt}, исполнено ~${fillPct}% (снято >12 мин: ${expCnt}, пропущено по спреду: ${skipCnt}, ошибок: ${errCnt})</span>` : ''}
3262
+ </div></div>`
3263
+ : '';
3264
+ $('#events-body').innerHTML = summaryHtml + (events.length
3249
3265
  ? `<div style="overflow-x:auto"><table><thead><tr><th>Время (МСК)</th><th>Событие</th><th>Подробности</th></tr></thead><tbody>${events.map((ev) => {
3250
3266
  const [label, cls] = EVENT_LABELS[ev.kind] || [ev.kind, ''];
3251
3267
  return `<tr><td class="small" style="white-space:nowrap">${esc(new Date(ev.ts + MSK).toISOString().slice(0, 19).replace('T', ' '))}</td><td class="small ${cls}">${esc(label)}</td><td class="small">${esc(ev.text)}</td></tr>`;
3252
3268
  }).join('')}</tbody></table></div>`
3253
- : '<p class="muted">Событий пока нет. Здесь появятся отправленные заявки, ошибки брокера, срабатывания стопов и ручные действия.</p>';
3269
+ : '<p class="muted">Событий пока нет. Здесь появятся отправленные заявки, ошибки брокера, срабатывания стопов и ручные действия.</p>');
3254
3270
  } catch (e) { $('#events-body').textContent = e.message; }
3255
3271
  }
3256
3272
  $('#events-close')?.addEventListener('click', () => { $('#events-modal').hidden = true; });
@@ -279,6 +279,84 @@ export function brokerAccountsSummary(uid = 1) {
279
279
  }).filter(Boolean);
280
280
  }
281
281
 
282
+ const candlePrevStmt = db.prepare('SELECT ts, open, close FROM candles WHERE symbol = ? AND interval = 10 AND ts <= ? ORDER BY ts DESC LIMIT 3');
283
+
284
+ // Телеметрия качества исполнения и сверка реальных сделок счёта с ориентиром стресс-теста
285
+ function computeExecutionAudit(a) {
286
+ const ops = Array.isArray(a.lastHistory?.operations) ? a.lastHistory.operations : [];
287
+ if (!ops.length) return null;
288
+ const sorted = [...ops].sort((x, y) => x.date - y.date);
289
+ // отсекаем старую историю автоследования (до последнего удержания комиссии автоследования)
290
+ const lastFollowTs = Math.max(0, ...sorted.filter((o) => o.kind === 'fee' && /автослед/i.test(o.title || '')).map((o) => o.date));
291
+ const algoOps = sorted.filter((o) => o.date > lastFollowTs);
292
+ const trades = algoOps.filter((o) => (o.kind === 'buy' || o.kind === 'sell') && o.qty > 0 && o.price > 0 && o.symbol && o.symbol !== 'LQDT');
293
+ if (!trades.length) return null;
294
+
295
+ let turnoverRub = 0;
296
+ let slipW1 = 0, rubW1 = 0, slipW2 = 0, rubW2 = 0;
297
+ for (const t of trades) {
298
+ const rub = t.qty * t.price;
299
+ turnoverRub += rub;
300
+ const rows = candlePrevStmt.all(t.symbol, t.date);
301
+ const prev = rows.find((r) => r.ts + BAR_MS <= t.date);
302
+ if (prev && prev.close > 0) {
303
+ const sign = t.kind === 'buy' ? 1 : -1;
304
+ const slipPct = sign * (t.price / prev.close - 1) * 100;
305
+ if (Math.abs(slipPct) < 5) {
306
+ if (TIER1_LIQUID_SYMBOLS.has(t.symbol)) { slipW1 += slipPct * rub; rubW1 += rub; }
307
+ else { slipW2 += slipPct * rub; rubW2 += rub; }
308
+ }
309
+ }
310
+ }
311
+ const feesRub = algoOps.filter((o) => o.kind === 'fee').reduce((s, o) => s + Math.abs(o.paymentRub || 0), 0);
312
+ const commPct = turnoverRub > 0 ? round((feesRub / turnoverRub) * 100, 3) : 0.04;
313
+ const slipAllPct = (rubW1 + rubW2) > 0 ? round((slipW1 + slipW2) / (rubW1 + rubW2), 3) : null;
314
+ const slipTier1Pct = rubW1 > 0 ? round(slipW1 / rubW1, 3) : null;
315
+ const slipTier2Pct = rubW2 > 0 ? round(slipW2 / rubW2, 3) : null;
316
+ const totalCostPerSidePct = slipAllPct !== null ? round(commPct + Math.max(0, slipAllPct), 3) : commPct;
317
+
318
+ // FIFO закрытых позиций (без старого портфеля baselineQty)
319
+ const book = new Map();
320
+ let winRub = 0, lossRub = 0, closedCount = 0;
321
+ for (const t of trades) {
322
+ const q = t.kind === 'buy' ? t.qty : -t.qty;
323
+ const lots = book.get(t.symbol) || [];
324
+ if (!lots.length && t.kind === 'sell' && (a.baselineQty?.[t.symbol] || 0) > 0) continue;
325
+ let rem = q;
326
+ while (rem !== 0 && lots.length && Math.sign(lots[0].qty) !== Math.sign(rem)) {
327
+ const take = Math.min(Math.abs(rem), Math.abs(lots[0].qty));
328
+ const pnl = lots[0].qty > 0 ? take * (t.price - lots[0].price) : take * (lots[0].price - t.price);
329
+ closedCount++;
330
+ if (pnl >= 0) winRub += pnl; else lossRub += -pnl;
331
+ lots[0].qty -= Math.sign(lots[0].qty) * take;
332
+ rem -= Math.sign(rem) * take;
333
+ if (lots[0].qty === 0) lots.shift();
334
+ }
335
+ if (rem !== 0) lots.push({ qty: rem, price: t.price });
336
+ book.set(t.symbol, lots);
337
+ }
338
+ const grossRub = round(winRub - lossRub, 0);
339
+ const netRub = round(grossRub - feesRub, 0);
340
+ const profitFactorGross = lossRub > 0 ? round(winRub / lossRub, 2) : (winRub > 0 ? 3 : null);
341
+
342
+ return {
343
+ fromTs: algoOps[0]?.date || null,
344
+ tradesCount: trades.length,
345
+ closedCount,
346
+ turnoverRub: round(turnoverRub, 0),
347
+ commPct,
348
+ slipAllPct,
349
+ slipTier1Pct,
350
+ slipTier2Pct,
351
+ totalCostPerSidePct,
352
+ breakEvenTargetPct: 0.255,
353
+ grossRub,
354
+ feesRub: round(feesRub, 0),
355
+ netRub,
356
+ profitFactorGross,
357
+ };
358
+ }
359
+
282
360
  function publicAccount(a) {
283
361
  let baseRunLabel = null;
284
362
  if (a.baseRunId) {
@@ -297,10 +375,11 @@ function publicAccount(a) {
297
375
  }),
298
376
  };
299
377
  }
378
+ const executionAudit = computeExecutionAudit(a);
300
379
  // Токен брокера никогда не покидает сервер: в браузер, события WebSocket и ответы ИИ уходит только маска
301
380
  const { token: rawToken, ...rest } = a;
302
381
  const tok = String(rawToken || '').trim();
303
- return { ...rest, lastSync, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
382
+ return { ...rest, lastSync, executionAudit, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
304
383
  }
305
384
 
306
385
  export function brokersState() {
@@ -807,6 +886,7 @@ async function syncTbank(acc) {
807
886
  method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 8000,
808
887
  }).catch(() => null)
809
888
  )));
889
+ logBrokerEvent(acc, 'order_expired', `авто-снятие ${toCancelIds.length} неисполненных/дублирующих заявок (>12 мин в стакане)`);
810
890
  rawOrders = keptOrders;
811
891
  }
812
892
  }
@@ -1954,6 +2034,7 @@ async function sendOrderToBroker(acc, ord) {
1954
2034
  const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
1955
2035
  const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
1956
2036
  if (isEntry && spreadPct > maxSpreadPct) {
2037
+ logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
1957
2038
  throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
1958
2039
  }
1959
2040
  if (isEntry && ord.symbol !== 'LQDT') {
@@ -2038,7 +2119,7 @@ async function sendOrderToBroker(acc, ord) {
2038
2119
  throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
2039
2120
  }
2040
2121
  const j = await r.json();
2041
- logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ · ${body.orderId}`);
2122
+ logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ (сигнал ${ord.refPrice || finalLimitPx} ₽) · ${body.orderId}`);
2042
2123
  return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder, limitPrice: finalLimitPx };
2043
2124
  }
2044
2125
  if (acc.broker === 'finam') {
@@ -479,12 +479,12 @@ function predictAttention(m, f, u) {
479
479
  for (let q = 0; q < 3; q++) zz += m.coef[HEADS.length + 3 + mi * 3 + q] * zs[q * f.W + u];
480
480
  s += ML[mi].b[i] * zz;
481
481
  }
482
- // Ансамбль с базовой моделью своей инерции (при наличии весов inertiaCoef):
483
- // сглаживает ошибки внимания к соседям на тонких бумагах и усиливает самые чистые сигналы возврата/импульса
482
+ // Равный ансамбль 50/50 с базовой моделью своей инерции (при наличии весов inertiaCoef):
483
+ // без подгонки весов объединяет переток по карте соседей и собственный импульс/возврат акции
484
484
  if (m.inertiaCoef) {
485
485
  let sIn = 0;
486
486
  for (let q = 0; q < 3; q++) sIn += m.inertiaCoef[q] * f.X[0][q][k];
487
- s = 0.45 * s + 0.55 * sIn;
487
+ s = 0.50 * s + 0.50 * sIn;
488
488
  }
489
489
  out[i] = s;
490
490
  }
@@ -504,7 +504,7 @@ export const modelNames = () => Object.keys(MODELS);
504
504
  export function explainAttention(m, f, u, j, top = 3) {
505
505
  const N = f.N;
506
506
  const contrib = new Float64Array(N);
507
- const wAtt = m.inertiaCoef ? 0.45 : 1, wIn = m.inertiaCoef ? 0.55 : 0;
507
+ const wAtt = m.inertiaCoef ? 0.50 : 1, wIn = m.inertiaCoef ? 0.50 : 0;
508
508
  HEADS.forEach((h, hi) => {
509
509
  const A = m.As[BETAS.findIndex((b) => b.v === h.beta)];
510
510
  const X = f.X[h.kind === 'price' ? 0 : 1][h.scale];