neuroalpha 0.5.1 → 0.5.3

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.3",
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,90 @@ 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
+ const executionAuditCache = new Map(); // accountId -> { sig, val }
284
+
285
+ // Телеметрия качества исполнения и сверка реальных сделок счёта с ориентиром стресс-теста (с кешированием по срезу истории)
286
+ function computeExecutionAudit(a) {
287
+ const ops = Array.isArray(a.lastHistory?.operations) ? a.lastHistory.operations : [];
288
+ if (!ops.length) return null;
289
+ const sig = `${a.lastHistory?.at || 0}:${ops.length}:${ops[0]?.id || ''}:${ops[ops.length - 1]?.id || ''}`;
290
+ const hit = executionAuditCache.get(a.id);
291
+ if (hit && hit.sig === sig) return hit.val;
292
+ const sorted = [...ops].sort((x, y) => x.date - y.date);
293
+ // отсекаем старую историю автоследования (до последнего удержания комиссии автоследования)
294
+ const lastFollowTs = Math.max(0, ...sorted.filter((o) => o.kind === 'fee' && /автослед/i.test(o.title || '')).map((o) => o.date));
295
+ const algoOps = sorted.filter((o) => o.date > lastFollowTs);
296
+ const trades = algoOps.filter((o) => (o.kind === 'buy' || o.kind === 'sell') && o.qty > 0 && o.price > 0 && o.symbol && o.symbol !== 'LQDT');
297
+ if (!trades.length) return null;
298
+
299
+ let turnoverRub = 0;
300
+ let slipW1 = 0, rubW1 = 0, slipW2 = 0, rubW2 = 0;
301
+ for (const t of trades) {
302
+ const rub = t.qty * t.price;
303
+ turnoverRub += rub;
304
+ const rows = candlePrevStmt.all(t.symbol, t.date);
305
+ const prev = rows.find((r) => r.ts + BAR_MS <= t.date);
306
+ if (prev && prev.close > 0) {
307
+ const sign = t.kind === 'buy' ? 1 : -1;
308
+ const slipPct = sign * (t.price / prev.close - 1) * 100;
309
+ if (Math.abs(slipPct) < 5) {
310
+ if (TIER1_LIQUID_SYMBOLS.has(t.symbol)) { slipW1 += slipPct * rub; rubW1 += rub; }
311
+ else { slipW2 += slipPct * rub; rubW2 += rub; }
312
+ }
313
+ }
314
+ }
315
+ const feesRub = algoOps.filter((o) => o.kind === 'fee').reduce((s, o) => s + Math.abs(o.paymentRub || 0), 0);
316
+ const commPct = turnoverRub > 0 ? round((feesRub / turnoverRub) * 100, 3) : 0.04;
317
+ const slipAllPct = (rubW1 + rubW2) > 0 ? round((slipW1 + slipW2) / (rubW1 + rubW2), 3) : null;
318
+ const slipTier1Pct = rubW1 > 0 ? round(slipW1 / rubW1, 3) : null;
319
+ const slipTier2Pct = rubW2 > 0 ? round(slipW2 / rubW2, 3) : null;
320
+ const totalCostPerSidePct = slipAllPct !== null ? round(commPct + Math.max(0, slipAllPct), 3) : commPct;
321
+
322
+ // FIFO закрытых позиций (без старого портфеля baselineQty)
323
+ const book = new Map();
324
+ let winRub = 0, lossRub = 0, closedCount = 0;
325
+ for (const t of trades) {
326
+ const q = t.kind === 'buy' ? t.qty : -t.qty;
327
+ const lots = book.get(t.symbol) || [];
328
+ if (!lots.length && t.kind === 'sell' && (a.baselineQty?.[t.symbol] || 0) > 0) continue;
329
+ let rem = q;
330
+ while (rem !== 0 && lots.length && Math.sign(lots[0].qty) !== Math.sign(rem)) {
331
+ const take = Math.min(Math.abs(rem), Math.abs(lots[0].qty));
332
+ const pnl = lots[0].qty > 0 ? take * (t.price - lots[0].price) : take * (lots[0].price - t.price);
333
+ closedCount++;
334
+ if (pnl >= 0) winRub += pnl; else lossRub += -pnl;
335
+ lots[0].qty -= Math.sign(lots[0].qty) * take;
336
+ rem -= Math.sign(rem) * take;
337
+ if (lots[0].qty === 0) lots.shift();
338
+ }
339
+ if (rem !== 0) lots.push({ qty: rem, price: t.price });
340
+ book.set(t.symbol, lots);
341
+ }
342
+ const grossRub = round(winRub - lossRub, 0);
343
+ const netRub = round(grossRub - feesRub, 0);
344
+ const profitFactorGross = lossRub > 0 ? round(winRub / lossRub, 2) : (winRub > 0 ? 3 : null);
345
+
346
+ const val = {
347
+ fromTs: algoOps[0]?.date || null,
348
+ tradesCount: trades.length,
349
+ closedCount,
350
+ turnoverRub: round(turnoverRub, 0),
351
+ commPct,
352
+ slipAllPct,
353
+ slipTier1Pct,
354
+ slipTier2Pct,
355
+ totalCostPerSidePct,
356
+ breakEvenTargetPct: 0.229,
357
+ grossRub,
358
+ feesRub: round(feesRub, 0),
359
+ netRub,
360
+ profitFactorGross,
361
+ };
362
+ executionAuditCache.set(a.id, { sig, val });
363
+ return val;
364
+ }
365
+
282
366
  function publicAccount(a) {
283
367
  let baseRunLabel = null;
284
368
  if (a.baseRunId) {
@@ -297,10 +381,11 @@ function publicAccount(a) {
297
381
  }),
298
382
  };
299
383
  }
384
+ const executionAudit = computeExecutionAudit(a);
300
385
  // Токен брокера никогда не покидает сервер: в браузер, события WebSocket и ответы ИИ уходит только маска
301
386
  const { token: rawToken, ...rest } = a;
302
387
  const tok = String(rawToken || '').trim();
303
- return { ...rest, lastSync, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
388
+ return { ...rest, lastSync, executionAudit, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
304
389
  }
305
390
 
306
391
  export function brokersState() {
@@ -787,7 +872,7 @@ async function syncTbank(acc) {
787
872
  // 2) если лимитная заявка висит неисполненной дольше 12 минут (прошла свеча, цена ушла) — автоматически снимаем её и разблокируем рубли!
788
873
  if (acc.mode !== 'readonly' && rawOrders.length > 0) {
789
874
  const seenInstDir = new Set();
790
- const toCancelIds = [];
875
+ const toCancelOrders = [];
791
876
  const keptOrders = [];
792
877
  const sortedByDate = [...rawOrders].sort((a, b) => Date.parse(b.orderDate || 0) - Date.parse(a.orderDate || 0));
793
878
  const nowMs = Date.now();
@@ -795,18 +880,21 @@ async function syncTbank(acc) {
795
880
  const k = `${o.instrumentUid || o.figi || o.ticker}:${o.direction}`;
796
881
  const ageMs = o.orderDate ? (nowMs - Date.parse(o.orderDate)) : 0;
797
882
  if (seenInstDir.has(k) || ageMs > 12 * 60000) {
798
- if (o.orderId) toCancelIds.push(o.orderId);
883
+ if (o.orderId) toCancelOrders.push({ o, reason: ageMs > 12 * 60000 ? '>12 мин' : 'дубль' });
799
884
  } else {
800
885
  seenInstDir.add(k);
801
886
  keptOrders.push(o);
802
887
  }
803
888
  }
804
- if (toCancelIds.length > 0) {
805
- await Promise.all(toCancelIds.map((orderId) => (
889
+ if (toCancelOrders.length > 0) {
890
+ await Promise.all(toCancelOrders.map(({ o }) => (
806
891
  brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
807
- method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 8000,
892
+ method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(o.orderId) }), timeoutMs: 8000,
808
893
  }).catch(() => null)
809
894
  )));
895
+ for (const { o, reason } of toCancelOrders) {
896
+ logBrokerEvent(acc, 'order_expired', `${o.ticker || o.figi || 'заявка'}: снята (${reason}) · ${o.orderId}`);
897
+ }
810
898
  rawOrders = keptOrders;
811
899
  }
812
900
  }
@@ -1954,6 +2042,7 @@ async function sendOrderToBroker(acc, ord) {
1954
2042
  const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
1955
2043
  const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
1956
2044
  if (isEntry && spreadPct > maxSpreadPct) {
2045
+ logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
1957
2046
  throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
1958
2047
  }
1959
2048
  if (isEntry && ord.symbol !== 'LQDT') {
@@ -2038,7 +2127,7 @@ async function sendOrderToBroker(acc, ord) {
2038
2127
  throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
2039
2128
  }
2040
2129
  const j = await r.json();
2041
- logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ · ${body.orderId}`);
2130
+ logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ (сигнал ${ord.refPrice || finalLimitPx} ₽) · ${body.orderId}`);
2042
2131
  return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder, limitPrice: finalLimitPx };
2043
2132
  }
2044
2133
  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];