neuroalpha 0.5.29 → 0.5.30

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.30",
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 : [];
@@ -2101,8 +2119,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2101
2119
  const lqdtLots = Math.floor(lqdtAlgoQty / lqdtSp.lotSize);
2102
2120
  const neededForNewRub = combinedNew.reduce((s, { r, side }) => {
2103
2121
  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));
2122
+ const rawCap = budget * (side === 'short' ? (isT1 ? 0.35 : 0.18) : (isT1 ? 0.45 : 0.22));
2123
+ const barTurnRub = !isT1 ? recentBarTurnoverRub(r.symbol) : 0;
2124
+ const liqCap = (!isT1 && barTurnRub > 0) ? Math.max(45000, Math.min(rawCap, barTurnRub * 0.12)) : rawCap;
2125
+ return s + Math.min(liqCap, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
2106
2126
  }, 0);
2107
2127
  const needFromLqdtRub = Math.min(Math.max(0, budget * 0.99 - usedRub), neededForNewRub) - availRub;
2108
2128
  if (lqdtLots >= 1 && needFromLqdtRub > 1500) {
@@ -2140,9 +2160,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2140
2160
  remWeightSum = Math.max(0, remWeightSum - wCur);
2141
2161
  remNew--;
2142
2162
  const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2143
- const sideCapRub = side === 'short'
2163
+ const rawSideCapRub = side === 'short'
2144
2164
  ? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
2145
2165
  : (isTier1 ? maxLongTier1Rub : maxLongTier2Rub);
2166
+ // Лимит от реального 10-минутного оборота стакана (Liquidity Cap): во 2-м эшелоне не входим объёмом больше 12% от 10-минутного оборота биржи,
2167
+ // чтобы заявка не становилась «стенкой» для стаканных роботов и исполнялась целиком без проскальзывания
2168
+ const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
2169
+ const sideCapRub = (!isTier1 && barTurnoverRub > 0)
2170
+ ? Math.max(45000, Math.min(rawSideCapRub, barTurnoverRub * 0.12))
2171
+ : rawSideCapRub;
2146
2172
  const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
2147
2173
  const sum = Math.min(rawSum, propShare, availRub);
2148
2174
  const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
@@ -2316,9 +2342,10 @@ async function sendOrderToBroker(acc, ord) {
2316
2342
  // • для входа (buy/short) и для LQDT ставим лимитку по лучшей цене своей стороны или середине спреда (не переплачиваем за пересечение спреда!);
2317
2343
  // • если спред в стакане в момент входа разъехался шире половины ожидаемого хода сигнала (или >0,35%) — отклоняем вход в тонкий стакан!
2318
2344
  let finalLimitPx = Number(ord.limitPrice) || 0;
2345
+ let lotsToOrder = Number(ord.lots) || 0;
2319
2346
  const sp = kvGet('tbankShortableMap')?.specMap?.[ord.symbol] || { minStep: 0.01, decimals: 2 };
2320
2347
  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,
2348
+ method: 'POST', headers, body: JSON.stringify({ instrumentId, depth: 10 }), timeoutMs: 3500,
2322
2349
  }).catch(() => null);
2323
2350
  if (rBook?.ok) {
2324
2351
  const jBook = await rBook.json().catch(() => null);
@@ -2333,14 +2360,35 @@ async function sendOrderToBroker(acc, ord) {
2333
2360
  logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
2334
2361
  throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
2335
2362
  }
2363
+ // Контроль видимой глубины стакана при входе во 2-й эшелон: не выставляем заявку крупнее 65% суммарного объёма 5 лучших уровней стакана,
2364
+ // чтобы не светить крупную «плиту» перед стаканными роботами
2365
+ if (isEntry && ord.symbol !== 'LQDT' && !TIER1_LIQUID_SYMBOLS.has(ord.symbol) && lotsToOrder > 1) {
2366
+ const topBookSide = (isBuy ? jBook?.asks : jBook?.bids) || [];
2367
+ const top5Lots = topBookSide.slice(0, 5).reduce((s, lv) => s + (Number(lv.quantity) || 0), 0);
2368
+ const minViableLots = Math.max(1, Math.ceil(30000 / Math.max(1, midPx * (ord.lotSize || 1))));
2369
+ if (top5Lots >= minViableLots) {
2370
+ const depthCapLots = Math.max(minViableLots, Math.floor(top5Lots * 0.65));
2371
+ if (lotsToOrder > depthCapLots) lotsToOrder = depthCapLots;
2372
+ }
2373
+ }
2336
2374
  if (ord.symbol === 'LQDT') {
2337
2375
  finalLimitPx = roundToStep(isBuy ? bestAsk : bestBid, sp.minStep || 0.001, sp.decimals ?? 3);
2338
2376
  } else if (!isEntry) {
2339
- // Закрывающие заявки (sell / cover): при нормальном спреде (<= 0,8%) ставим цену в лучшую встречную заявку стакана (bestBid / bestAsk),
2340
- // чтобы выход исполнялся мгновенно (за < 1 сек) и гарантированно освобождал кэш перед открытием новых позиций!
2341
- if (spreadPct <= 0.80) {
2377
+ // Закрывающие заявки (sell / cover):
2378
+ // • при экстренном стоп-лоссе (isStopLoss) закрываемся гарантированно в пределах защитного лимита;
2379
+ // • при штатном выходе (Take-Profit или остывание прогноза) НЕ пробиваем несколько уровней тонкого стакана против себя:
2380
+ // при узком спреде (<= 0,10% или <= 2 шагов) ставим ровно в лучшую встречную цену (bestAsk / bestBid),
2381
+ // а при широком спреде встаём на середине спреда (midPx), не переплачивая стаканным роботам!
2382
+ const tightExitSpread = spreadPct <= 0.10 || (bestAsk - bestBid) <= 2.1 * step;
2383
+ if (ord.isStopLoss && spreadPct <= 0.80) {
2342
2384
  const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2343
2385
  if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
2386
+ } else if (tightExitSpread) {
2387
+ const cleanExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2388
+ if (cleanExitPx > 0) finalLimitPx = cleanExitPx;
2389
+ } else if (spreadPct <= 0.80) {
2390
+ const midExitPx = roundToStep(midPx, step, sp.decimals);
2391
+ if (midExitPx > 0) finalLimitPx = isBuy ? Math.min(bestAsk, midExitPx) : Math.max(bestBid, midExitPx);
2344
2392
  }
2345
2393
  } else {
2346
2394
  // Входящие заявки (buy / short):
@@ -2361,7 +2409,6 @@ async function sendOrderToBroker(acc, ord) {
2361
2409
  }
2362
2410
  const units = Math.trunc(finalLimitPx);
2363
2411
  const nano = Math.round((finalLimitPx - units) * 1e9);
2364
- let lotsToOrder = Number(ord.lots) || 0;
2365
2412
 
2366
2413
  // Перед выставлением новой лимитной заявки автоматически снимаем старые неисполненные заявки по этой же бумаге и направлению,
2367
2414
  // чтобы в стакане никогда не копились дублирующиеся заявки и не блокировали рублёвый лимит счёта!