neuroalpha 0.5.29 → 0.5.31

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.29",
3
+ "version": "0.5.31",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
@@ -297,8 +297,26 @@ export function brokerAccountsSummary(uid = 1) {
297
297
  }
298
298
 
299
299
  const candlePrevStmt = db.prepare('SELECT ts, open, close FROM candles WHERE symbol = ? AND interval = 10 AND ts <= ? ORDER BY ts DESC LIMIT 3');
300
+ const recentBarValStmt = db.prepare('SELECT value FROM candles WHERE symbol = ? AND interval = 10 AND value > 0 ORDER BY ts DESC LIMIT 48');
300
301
  const executionAuditCache = new Map(); // accountId -> { sig, val }
301
302
 
303
+ // Медианный/средний 10-минутный биржевой оборот акции (в рублях) за последние ~8 торговых часов —
304
+ // ограничивает размер заявки во 2-м эшелоне, чтобы не ставить в тонкий стакан крупную «плиту», заметную роботам-маркетмейкерам
305
+ function recentBarTurnoverRub(symbol) {
306
+ try {
307
+ const rows = recentBarValStmt.all(symbol);
308
+ if (!rows.length) return 0;
309
+ const vals = rows.map((r) => Number(r.value) || 0).filter((v) => v > 0).sort((a, b) => a - b);
310
+ if (!vals.length) return 0;
311
+ const mid = Math.floor(vals.length / 2);
312
+ const medianVal = vals.length % 2 ? vals[mid] : (vals[mid - 1] + vals[mid]) / 2;
313
+ const avgVal = vals.reduce((s, v) => s + v, 0) / vals.length;
314
+ return Math.round((medianVal * 0.6 + avgVal * 0.4));
315
+ } catch {
316
+ return 0;
317
+ }
318
+ }
319
+
302
320
  // Телеметрия качества исполнения и сверка реальных сделок счёта с ориентиром стресс-теста (с кешированием по срезу истории)
303
321
  function computeExecutionAudit(a) {
304
322
  const ops = Array.isArray(a.lastHistory?.operations) ? a.lastHistory.operations : [];
@@ -1634,6 +1652,77 @@ function sessionEdgeWarning(now = Date.now()) {
1634
1652
  // ---------- формирование и исполнение пакета лимитных заявок ----------
1635
1653
  const ACCOUNT_LOCK_TTL_MS = 75000;
1636
1654
  const accountBusyLocks = new Map(); // id -> { since, token }
1655
+ const entryImpulseWatch = new Map(); // `${accId}:${symbol}:${side}` -> { firstTs, lastTs, lastPx, extremePx }
1656
+
1657
+ // Фильтр затухания минутного импульса перед входом (Impulse Exhaustion):
1658
+ // не даёт продавать в шорт посередине вертикально растущей минутной свечи (или покупать в полёт падающего ножа),
1659
+ // дожидаясь остановки разгона и первого микро-отката от пика/дна (с шагом проверки 20 секунд)
1660
+ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
1661
+ if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
1662
+ for (const [k, v] of entryImpulseWatch) {
1663
+ if (nowTs - v.lastTs > 15 * 60000) entryImpulseWatch.delete(k);
1664
+ }
1665
+ const ready = [];
1666
+ const deferred = [];
1667
+ for (const r of cands) {
1668
+ const sym = r.symbol;
1669
+ const isShort = side === 'short';
1670
+ const hasCounterImpulse = isShort
1671
+ ? ((r.lastMovePct || 0) > 0.15 || (r.selfPct || 0) < -0.10)
1672
+ : ((r.lastMovePct || 0) < -0.15 || (r.selfPct || 0) > 0.10);
1673
+ if (!hasCounterImpulse) {
1674
+ ready.push(r);
1675
+ continue;
1676
+ }
1677
+ const bars1m = await fetchTbankCandles(sym, nowTs - 8 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null);
1678
+ if (!bars1m?.length) {
1679
+ ready.push(r);
1680
+ continue;
1681
+ }
1682
+ const last1m = bars1m[bars1m.length - 1];
1683
+ const prev1m = bars1m.length >= 2 ? bars1m[bars1m.length - 2] : null;
1684
+ const curPx = Number(last1m?.close || r.price) || 0;
1685
+ const sp = specs[sym] || { minStep: 0.01 };
1686
+ const step = sp.minStep > 0 ? sp.minStep : 0.01;
1687
+ const wKey = `${acc.id}:${sym}:${side}`;
1688
+ const prevW = entryImpulseWatch.get(wKey) || null;
1689
+
1690
+ if (isShort) {
1691
+ const recentHigh = Math.max(prevW?.extremePx || 0, curPx, last1m?.high || 0, prev1m?.high || 0);
1692
+ const pullbackPct = recentHigh > 0 ? ((recentHigh - curPx) / recentHigh) * 100 : 0;
1693
+ const pullbackTicks = recentHigh > 0 ? (recentHigh - curPx) / step : 0;
1694
+ const barTurnedDown = Boolean(last1m && prev1m && last1m.close <= last1m.open && last1m.high <= prev1m.high + step * 0.5);
1695
+ const peakedAndPulledBack = pullbackPct >= 0.12 && pullbackTicks >= 2;
1696
+ const tickedDownFromPrev = Boolean(prevW && curPx <= prevW.lastPx - step && pullbackTicks >= 1.5);
1697
+ const waitTimeout = Boolean(prevW && nowTs - prevW.firstTs >= 180000);
1698
+ if (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev || waitTimeout) {
1699
+ entryImpulseWatch.delete(wKey);
1700
+ r.price = curPx;
1701
+ ready.push(r);
1702
+ } else {
1703
+ entryImpulseWatch.set(wKey, { firstTs: prevW?.firstTs || nowTs, lastTs: nowTs, lastPx: curPx, extremePx: recentHigh });
1704
+ deferred.push({ symbol: sym, side, curPx, extremePx: recentHigh });
1705
+ }
1706
+ } else {
1707
+ const recentLow = Math.min(prevW?.extremePx || Infinity, curPx > 0 ? curPx : Infinity, last1m?.low > 0 ? last1m.low : Infinity, prev1m?.low > 0 ? prev1m.low : Infinity);
1708
+ const bouncePct = Number.isFinite(recentLow) && recentLow > 0 ? ((curPx - recentLow) / recentLow) * 100 : 0;
1709
+ const bounceTicks = Number.isFinite(recentLow) && recentLow > 0 ? (curPx - recentLow) / step : 0;
1710
+ const barTurnedUp = Boolean(last1m && prev1m && last1m.close >= last1m.open && last1m.low >= prev1m.low - step * 0.5);
1711
+ const bottomedAndBounced = bouncePct >= 0.12 && bounceTicks >= 2;
1712
+ const tickedUpFromPrev = Boolean(prevW && curPx >= prevW.lastPx + step && bounceTicks >= 1.5);
1713
+ const waitTimeout = Boolean(prevW && nowTs - prevW.firstTs >= 180000);
1714
+ if (bottomedAndBounced || barTurnedUp || tickedUpFromPrev || waitTimeout) {
1715
+ entryImpulseWatch.delete(wKey);
1716
+ r.price = curPx;
1717
+ ready.push(r);
1718
+ } else {
1719
+ entryImpulseWatch.set(wKey, { firstTs: prevW?.firstTs || nowTs, lastTs: nowTs, lastPx: curPx, extremePx: recentLow });
1720
+ deferred.push({ symbol: sym, side, curPx, extremePx: recentLow });
1721
+ }
1722
+ }
1723
+ }
1724
+ return { ready, deferred };
1725
+ }
1637
1726
 
1638
1727
  function isAccountBusy(id) {
1639
1728
  const cur = accountBusyLocks.get(id);
@@ -2034,8 +2123,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2034
2123
  if (bQ > 0 && !closing.has(r.symbol)) return false;
2035
2124
  return !(aQ < 0 && !closing.has(r.symbol));
2036
2125
  });
2037
- const newBuys = filterBrokerDynamicTopK(rawNewBuys, minBuyEdgePct, Math.max(0, K - activeLongs));
2038
- const newShorts = filterBrokerDynamicTopK(rawNewShorts, minShortEdgePct, Math.max(0, K - activeShorts));
2126
+ const candidateBuys = filterBrokerDynamicTopK(rawNewBuys, minBuyEdgePct, Math.max(0, K - activeLongs));
2127
+ const candidateShorts = filterBrokerDynamicTopK(rawNewShorts, minShortEdgePct, Math.max(0, K - activeShorts));
2128
+ const [buyExhaustion, shortExhaustion] = await Promise.all([
2129
+ filterByImpulseExhaustion(acc, candidateBuys, 'buy', specs, nowTs),
2130
+ filterByImpulseExhaustion(acc, candidateShorts, 'short', specs, nowTs),
2131
+ ]);
2132
+ const newBuys = buyExhaustion.ready;
2133
+ const newShorts = shortExhaustion.ready;
2134
+ const deferredByImpulse = [...buyExhaustion.deferred, ...shortExhaustion.deferred];
2039
2135
  const totalNew = newBuys.length + newShorts.length;
2040
2136
  const activeTotal = Math.max(2, activeLongs + activeShorts + totalNew);
2041
2137
  const dynSlotRub = (isOwnerPlan && autoAdapt)
@@ -2101,8 +2197,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2101
2197
  const lqdtLots = Math.floor(lqdtAlgoQty / lqdtSp.lotSize);
2102
2198
  const neededForNewRub = combinedNew.reduce((s, { r, side }) => {
2103
2199
  const isT1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2104
- const cap = budget * (side === 'short' ? (isT1 ? 0.35 : 0.18) : (isT1 ? 0.45 : 0.22));
2105
- return s + Math.min(cap, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
2200
+ const rawCap = budget * (side === 'short' ? (isT1 ? 0.35 : 0.18) : (isT1 ? 0.45 : 0.22));
2201
+ const barTurnRub = !isT1 ? recentBarTurnoverRub(r.symbol) : 0;
2202
+ const liqCap = (!isT1 && barTurnRub > 0) ? Math.max(45000, Math.min(rawCap, barTurnRub * 0.12)) : rawCap;
2203
+ return s + Math.min(liqCap, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
2106
2204
  }, 0);
2107
2205
  const needFromLqdtRub = Math.min(Math.max(0, budget * 0.99 - usedRub), neededForNewRub) - availRub;
2108
2206
  if (lqdtLots >= 1 && needFromLqdtRub > 1500) {
@@ -2140,9 +2238,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2140
2238
  remWeightSum = Math.max(0, remWeightSum - wCur);
2141
2239
  remNew--;
2142
2240
  const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2143
- const sideCapRub = side === 'short'
2241
+ const rawSideCapRub = side === 'short'
2144
2242
  ? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
2145
2243
  : (isTier1 ? maxLongTier1Rub : maxLongTier2Rub);
2244
+ // Лимит от реального 10-минутного оборота стакана (Liquidity Cap): во 2-м эшелоне не входим объёмом больше 12% от 10-минутного оборота биржи,
2245
+ // чтобы заявка не становилась «стенкой» для стаканных роботов и исполнялась целиком без проскальзывания
2246
+ const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
2247
+ const sideCapRub = (!isTier1 && barTurnoverRub > 0)
2248
+ ? Math.max(45000, Math.min(rawSideCapRub, barTurnoverRub * 0.12))
2249
+ : rawSideCapRub;
2146
2250
  const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
2147
2251
  const sum = Math.min(rawSum, propShare, availRub);
2148
2252
  const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
@@ -2211,6 +2315,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2211
2315
  } else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
2212
2316
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
2213
2317
  diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
2318
+ } else if (!newBuys.length && !newShorts.length && deferredByImpulse.length > 0) {
2319
+ diagNote = `Сигнал ${deferredByImpulse.map((d) => `${d.symbol} (${d.side === 'short' ? 'шорт' : 'покупка'}, текущая ${d.curPx} ₽)`).join(', ')} взят на прицел: ждём затухания встречного минутного импульса для входа по лучшей цене (проверка каждые 20 сек).`;
2214
2320
  } else if (!newBuys.length && !newShorts.length) {
2215
2321
  const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
2216
2322
  const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует 60-минутная пауза после недавнего выхода из позиции, чтобы не платить повторную комиссию.` : '';
@@ -2316,9 +2422,10 @@ async function sendOrderToBroker(acc, ord) {
2316
2422
  // • для входа (buy/short) и для LQDT ставим лимитку по лучшей цене своей стороны или середине спреда (не переплачиваем за пересечение спреда!);
2317
2423
  // • если спред в стакане в момент входа разъехался шире половины ожидаемого хода сигнала (или >0,35%) — отклоняем вход в тонкий стакан!
2318
2424
  let finalLimitPx = Number(ord.limitPrice) || 0;
2425
+ let lotsToOrder = Number(ord.lots) || 0;
2319
2426
  const sp = kvGet('tbankShortableMap')?.specMap?.[ord.symbol] || { minStep: 0.01, decimals: 2 };
2320
2427
  const rBook = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetOrderBook`, {
2321
- method: 'POST', headers, body: JSON.stringify({ instrumentId, depth: 1 }), timeoutMs: 3500,
2428
+ method: 'POST', headers, body: JSON.stringify({ instrumentId, depth: 10 }), timeoutMs: 3500,
2322
2429
  }).catch(() => null);
2323
2430
  if (rBook?.ok) {
2324
2431
  const jBook = await rBook.json().catch(() => null);
@@ -2333,27 +2440,52 @@ async function sendOrderToBroker(acc, ord) {
2333
2440
  logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
2334
2441
  throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
2335
2442
  }
2443
+ // Контроль видимой глубины стакана при входе во 2-й эшелон: не выставляем заявку крупнее 65% суммарного объёма 5 лучших уровней стакана,
2444
+ // чтобы не светить крупную «плиту» перед стаканными роботами
2445
+ if (isEntry && ord.symbol !== 'LQDT' && !TIER1_LIQUID_SYMBOLS.has(ord.symbol) && lotsToOrder > 1) {
2446
+ const topBookSide = (isBuy ? jBook?.asks : jBook?.bids) || [];
2447
+ const top5Lots = topBookSide.slice(0, 5).reduce((s, lv) => s + (Number(lv.quantity) || 0), 0);
2448
+ const minViableLots = Math.max(1, Math.ceil(30000 / Math.max(1, midPx * (ord.lotSize || 1))));
2449
+ if (top5Lots >= minViableLots) {
2450
+ const depthCapLots = Math.max(minViableLots, Math.floor(top5Lots * 0.65));
2451
+ if (lotsToOrder > depthCapLots) lotsToOrder = depthCapLots;
2452
+ }
2453
+ }
2336
2454
  if (ord.symbol === 'LQDT') {
2337
2455
  finalLimitPx = roundToStep(isBuy ? bestAsk : bestBid, sp.minStep || 0.001, sp.decimals ?? 3);
2338
2456
  } else if (!isEntry) {
2339
- // Закрывающие заявки (sell / cover): при нормальном спреде (<= 0,8%) ставим цену в лучшую встречную заявку стакана (bestBid / bestAsk),
2340
- // чтобы выход исполнялся мгновенно (за < 1 сек) и гарантированно освобождал кэш перед открытием новых позиций!
2341
- if (spreadPct <= 0.80) {
2457
+ // Закрывающие заявки (sell / cover):
2458
+ // • при экстренном стоп-лоссе (isStopLoss) закрываемся гарантированно в пределах защитного лимита;
2459
+ // • при штатном выходе (Take-Profit или остывание прогноза) НЕ пробиваем несколько уровней тонкого стакана против себя:
2460
+ // при узком спреде (<= 0,10% или <= 2 шагов) ставим ровно в лучшую встречную цену (bestAsk / bestBid),
2461
+ // а при широком спреде встаём на середине спреда (midPx), не переплачивая стаканным роботам!
2462
+ const tightExitSpread = spreadPct <= 0.10 || (bestAsk - bestBid) <= 2.1 * step;
2463
+ if (ord.isStopLoss && spreadPct <= 0.80) {
2342
2464
  const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2343
2465
  if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
2466
+ } else if (tightExitSpread) {
2467
+ const cleanExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2468
+ if (cleanExitPx > 0) finalLimitPx = cleanExitPx;
2469
+ } else if (spreadPct <= 0.80) {
2470
+ const midExitPx = roundToStep(midPx, step, sp.decimals);
2471
+ if (midExitPx > 0) finalLimitPx = isBuy ? Math.min(bestAsk, midExitPx) : Math.max(bestBid, midExitPx);
2344
2472
  }
2345
2473
  } else {
2346
2474
  // Входящие заявки (buy / short):
2347
- // • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx, чтобы не упускать движение;
2475
+ // • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx,
2476
+ // КРОМЕ случая, когда в первых 5 уровнях стакана встречный объём давит в >= 2,2 раза сильнее нашей стороны (тогда встаём пассивно на своей стороне спреда, чтобы остаток импульса налил нам по лучшей цене!);
2348
2477
  // • при более широком спреде (2–3 эшелон) встаём на середине спреда (или на 1 шаг внутри спреда), не переплачивая маркетмейкеру!
2349
- const tightSpread = spreadPct <= 0.06 || (bestAsk - bestBid) <= 2.1 * step;
2478
+ const bidTop5 = (jBook?.bids || []).slice(0, 5).reduce((s, lv) => s + (Number(lv.quantity) || 0), 0);
2479
+ const askTop5 = (jBook?.asks || []).slice(0, 5).reduce((s, lv) => s + (Number(lv.quantity) || 0), 0);
2480
+ const opposingBookPressure = isBuy ? (askTop5 > bidTop5 * 2.2) : (bidTop5 > askTop5 * 2.2);
2481
+ const tightSpread = (spreadPct <= 0.06 || (bestAsk - bestBid) <= 2.1 * step) && !opposingBookPressure;
2350
2482
  if (tightSpread) {
2351
2483
  const activePx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2352
2484
  if (activePx > 0) finalLimitPx = isBuy ? Math.min(finalLimitPx, activePx) : Math.max(finalLimitPx, activePx);
2353
2485
  } else {
2354
2486
  const passivePx = isBuy
2355
- ? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + step), step, sp.decimals))
2356
- : Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - step), step, sp.decimals));
2487
+ ? Math.min(finalLimitPx, roundToStep(opposingBookPressure ? bestBid : Math.min(midPx, bestBid + step), step, sp.decimals))
2488
+ : Math.max(finalLimitPx, roundToStep(opposingBookPressure ? bestAsk : Math.max(midPx, bestAsk - step), step, sp.decimals));
2357
2489
  if (passivePx > 0) finalLimitPx = passivePx;
2358
2490
  }
2359
2491
  }
@@ -2361,7 +2493,6 @@ async function sendOrderToBroker(acc, ord) {
2361
2493
  }
2362
2494
  const units = Math.trunc(finalLimitPx);
2363
2495
  const nano = Math.round((finalLimitPx - units) * 1e9);
2364
- let lotsToOrder = Number(ord.lots) || 0;
2365
2496
 
2366
2497
  // Перед выставлением новой лимитной заявки автоматически снимаем старые неисполненные заявки по этой же бумаге и направлению,
2367
2498
  // чтобы в стакане никогда не копились дублирующиеся заявки и не блокировали рублёвый лимит счёта!