neuroalpha 0.6.10 → 0.6.12

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.
@@ -13,7 +13,10 @@ import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
13
13
  import { forecastNow } from '../model/forecast.js';
14
14
  import { TIER1_LIQUID_SYMBOLS, QUARANTINE_OVERRIDE_RATIO, symbolEdgeMult, isStockQuarantined, computeAccountQuarantine, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
15
15
  import { liveDividendGuard } from '../model/dividends.js';
16
- import { STRATEGY_POLICY, sessionEdgeWarning, evaluateProfitLock, protectiveStopPct, exitHoldRatio, tierSizeMult, MAX_LEVERAGE } from '../model/policy.js';
16
+ import {
17
+ STRATEGY_POLICY, sessionEdgeWarning, evaluateProfitLock, protectiveStopPct, exitHoldRatio,
18
+ planSizingProfile, convictionMult, tierSizeMult, symbolCapRub, targetPositionRub, waterFillAllocations, MAX_LEVERAGE,
19
+ } from '../model/policy.js';
17
20
  import { MODELS } from '../model/models.js';
18
21
  import { blockingNews } from '../news/index.js';
19
22
  import { round, MSK, DAY, fmtBarMsk } from '../util.js';
@@ -2093,32 +2096,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2093
2096
  return isStockQuarantined(q.cnt, q.pnlPct / 100, edgeRatio);
2094
2097
  };
2095
2098
  const slots = dir === 'long' ? K : 2 * K;
2096
- // Фильтр мощности слота по тарифу:
2097
- // • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
2098
- // • Профи: до 5 счетов по 1 млн ₽, покупки + шорты, базовый слот до 28% капитала
2099
- // • VIP: счета и сумма по договору, покупки + шорты, слот до 48% капитала
2100
- // • Хозяин: Conviction Sizing до 75% капитала
2099
+ // Профиль мощности слотов и индивидуальных потолков по тарифу (единый с бэктестом, server/model/policy.js):
2100
+ // • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов), слот до 58% T1 / 14% T2
2101
+ // • Профи: до 5 счетов по 1 млн ₽, покупки + шорты, плечо до 3×, слот до 52% T1 / 12% T2
2102
+ // • VIP: счета и сумма по договору, Conviction Sizing до 75% T1 / 14% T2
2103
+ // • Хозяин: живой Conviction Sizing до 92% T1 лонг / 70% T1 шорт (с сублинейным ростом по плечу)
2101
2104
  const isOwnerPlan = Boolean(plan.isOwner || plan.id === 'owner');
2105
+ const sizingProfile = planSizingProfile(isOwnerPlan ? 'owner' : (vipBoost ? 'vip' : (plan.id || 'investor')), dir);
2102
2106
  let slotRub;
2103
- if (isOwnerPlan && autoAdapt) {
2104
- const maxSlotWeight = Math.min(0.45, Math.max(1 / K, 1.8 / K));
2105
- slotRub = budget * maxSlotWeight;
2106
- } else if (vipBoost && autoAdapt) {
2107
- const maxSlotWeight = Math.min(0.40, Math.max(1 / K, 1.4 / K));
2108
- slotRub = budget * maxSlotWeight;
2107
+ if (autoAdapt) {
2108
+ slotRub = budget * sizingProfile.slotW;
2109
2109
  } else if (plan.id === 'pro') {
2110
2110
  slotRub = budget * Math.min(0.28, 1.15 / Math.max(2, slots));
2111
2111
  } else {
2112
2112
  slotRub = budget * Math.min(0.24, 1.05 / Math.max(3, K + 1));
2113
2113
  }
2114
- // (Потолки доходности по тарифам и «дроссель» размера позиций удалены: тарифы различаются возможностями,
2115
- // а не обещанной доходностью — реалистичная доходность определяется стресс-тестом, а не тарифом.)
2116
2114
  const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
2117
2115
  const convOf = (r) => {
2118
2116
  if (!autoAdapt || !r.expectedPct) return 1;
2119
- if (isOwnerPlan) return Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (Math.abs(r.expectedPct) / needPct)));
2120
- if (vipBoost) return Math.min(1.30, Math.max(0.85, 0.80 + 0.25 * (Math.abs(r.expectedPct) / needPct)));
2121
- return 1;
2117
+ return convictionMult(Math.abs(r.expectedPct) / needPct, sizingProfile);
2122
2118
  };
2123
2119
  const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
2124
2120
  const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
@@ -2315,67 +2311,21 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2315
2311
  let usedRub = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) !== 0 && !closing.has(p.symbol)).reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0)
2316
2312
  + (onlyEntries ? activePendingOrders.reduce((s, o) => s + Math.abs(Number(o.amountRub) || 0), 0) : 0);
2317
2313
 
2318
- // Ротация остывшей позиции в мощного нового лидера (Opportunity Rotation):
2319
- // если на счёте занято >65% бюджета, а на рынке появился новый лидер с прогнозом >= +0.55% (в 2.4 раза сильнее текущей остывшей бумаги),
2320
- // освобождаем слот под лидера, чтобы капитал не застревал в вялых позициях
2321
- if (!onlyEntries && autoAdapt && usedRub > budget * 0.65 * (marginSizing ? leverage : 1) && (buyCands.length > 0 || shortCands.length > 0)) {
2322
- const bestNewPct = Math.max(
2323
- buyCands.length ? Math.abs(buyCands[0].expectedPct || 0) : 0,
2324
- shortCands.length ? Math.abs(shortCands[0].expectedPct || 0) : 0,
2325
- );
2326
- if (bestNewPct >= needPct * 2.2) {
2327
- let weakestSym = null, weakestEdge = Infinity;
2328
- for (const [sym, pos] of heldMap) {
2329
- if (closing.has(sym) || !rowMap.has(sym)) continue;
2330
- const aQ = algoQtyOf(sym, pos.qty);
2331
- if (!aQ) continue;
2332
- const rCur = rowMap.get(sym);
2333
- const curSignedPct = aQ > 0 ? (rCur.expectedPct || 0) : -(rCur.expectedPct || 0);
2334
- if (curSignedPct < exitThresholdPct * 0.90 && curSignedPct < weakestEdge && bestNewPct > Math.max(needPct, curSignedPct) * 2.4) {
2335
- weakestEdge = curSignedPct;
2336
- weakestSym = sym;
2337
- }
2338
- }
2339
- if (weakestSym) {
2340
- const wPos = heldMap.get(weakestSym);
2341
- const wRow = rowMap.get(weakestSym);
2342
- const wSp = specs[weakestSym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
2343
- const wAq = algoQtyOf(weakestSym, wPos.qty);
2344
- const wLots = Math.floor(Math.abs(wAq) / wSp.lotSize);
2345
- const wPx = Number(wRow?.price || wPos.price) || 0;
2346
- if (wLots >= 1 && wPx > 0) {
2347
- const isLongPos = wAq > 0;
2348
- const limPx = sniperLimitPrice(wPx, isLongPos ? -1 : 1, acc.limitOffsetPct, wSp);
2349
- orders.push({
2350
- symbol: weakestSym,
2351
- action: isLongPos ? 'sell' : 'cover',
2352
- lotSize: wSp.lotSize,
2353
- lots: wLots,
2354
- shares: wLots * wSp.lotSize,
2355
- refPrice: wPx,
2356
- limitPrice: limPx,
2357
- amountRub: round(wLots * wSp.lotSize * limPx, 2),
2358
- reason: `Ротация капитала из остывшей бумаги (${wRow?.expectedPct > 0 ? '+' : ''}${wRow?.expectedPct}%) в нового сильного лидера (${bestNewPct}%)`,
2359
- });
2360
- closing.add(weakestSym);
2361
- if (isLongPos) activeLongs = Math.max(0, activeLongs - 1);
2362
- else activeShorts = Math.max(0, activeShorts - 1);
2363
- usedRub = Math.max(0, usedRub - Math.abs(wAq * wPos.price));
2364
- }
2365
- }
2366
- }
2367
- }
2368
- const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
2369
- const coverSpentRub = orders.filter((o) => o.action === 'cover').reduce((s, o) => s + o.amountRub, 0);
2370
- const openShortRub = [...heldMap.values()]
2371
- .filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
2372
- .reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
2373
- const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
2374
- let availRub = marginSizing
2375
- ? Math.max(0, budget * 0.99 * leverage - usedRub)
2376
- : Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
2314
+ const recomputeAvailRub = () => {
2315
+ const freed = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
2316
+ const coverSpent = orders.filter((o) => o.action === 'cover').reduce((s, o) => s + o.amountRub, 0);
2317
+ const trimmedShortRub = orders.filter((o) => o.action === 'cover' && o.isTrim).reduce((s, o) => s + o.amountRub, 0);
2318
+ const openShortRub = Math.max(0, [...heldMap.values()]
2319
+ .filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
2320
+ .reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0) - trimmedShortRub);
2321
+ const cashLim = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freed - coverSpent - openShortRub : budget;
2322
+ return marginSizing
2323
+ ? Math.max(0, budget * 0.99 * leverage - usedRub)
2324
+ : Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLim));
2325
+ };
2326
+ let availRub = recomputeAvailRub();
2377
2327
 
2378
- // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
2328
+ // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
2379
2329
  const rawNewBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol)) && !(onlyEntries && activeOrderSymbols.has(r.symbol)));
2380
2330
  // Не открываем шорт по тикеру, в котором ещё лежит непроданный остаток старого портфеля (baseQty > 0), чтобы брокер не превратил шорт в продажу старой позиции
2381
2331
  const rawNewShorts = shortCands.filter((r) => {
@@ -2395,30 +2345,53 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2395
2345
  const newBuys = buyExhaustion.ready;
2396
2346
  const newShorts = shortExhaustion.ready;
2397
2347
  const deferredByImpulse = [...buyExhaustion.deferred, ...shortExhaustion.deferred];
2398
- const totalNew = newBuys.length + newShorts.length;
2399
- const activeTotal = Math.max(2, activeLongs + activeShorts + totalNew);
2400
- const dynSlotRub = (isOwnerPlan && autoAdapt)
2401
- ? budget * Math.min(0.45, Math.max(slotRub / Math.max(1, budget), 1.8 / Math.min(slots, activeTotal + 1)))
2402
- : (vipBoost && autoAdapt)
2403
- ? budget * Math.min(0.40, Math.max(slotRub / Math.max(1, budget), 1.5 / Math.min(slots, activeTotal + 1)))
2404
- : slotRub;
2348
+ const dynSlotRub = slotRub;
2349
+
2350
+ const netPickWeight = (r) => {
2351
+ const rawPct = Math.abs(r.expectedPct || 0);
2352
+ if (!autoAdapt) return Math.max(0.01, rawPct);
2353
+ const roundTripCostPct = TIER1_LIQUID_SYMBOLS.has(r.symbol) ? 0.13 : 0.25;
2354
+ return Math.max(0.01, rawPct - roundTripCostPct);
2355
+ };
2356
+ const calcDesiredNewRub = (r, side) => {
2357
+ const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2358
+ const posSide = side === 'short' ? 'short' : 'long';
2359
+ const tierMult = autoAdapt ? tierSizeMult(r.symbol, leverage, posSide) : 1;
2360
+ const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
2361
+ const stopPct = protectiveStopPct(posSide);
2362
+ const effSlotRub = (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * tierMult;
2363
+ const targetRub = autoAdapt
2364
+ ? targetPositionRub({ symbol: r.symbol, side: posSide, budget: budget * tierMult, slotRub: effSlotRub, conv: convOf(r), stopPct, barTurnoverRub, profile: sizingProfile })
2365
+ : Math.min(symbolCapRub(r.symbol, posSide, budget * tierMult, barTurnoverRub), effSlotRub);
2366
+ return { targetRub, tierMult, stopPct };
2367
+ };
2368
+
2369
+ const combinedNew = [
2370
+ ...newBuys.map((r) => ({ r, side: 'buy' })),
2371
+ ...newShorts.map((r) => ({ r, side: 'short' })),
2372
+ ];
2373
+ if (autoAdapt && combinedNew.length > 1) {
2374
+ combinedNew.sort((a, b) => netPickWeight(b.r) - netPickWeight(a.r));
2375
+ }
2376
+ const preparedNew = combinedNew.map(({ r, side }) => {
2377
+ const { targetRub, tierMult, stopPct } = calcDesiredNewRub(r, side);
2378
+ return { r, side, weight: netPickWeight(r), targetRub, tierMult, stopPct };
2379
+ }).filter((x) => x.targetRub > 5000);
2380
+ const totalDesiredNewRub = preparedNew.reduce((s, x) => s + x.targetRub, 0);
2405
2381
 
2406
2382
  // В режиме «Плавный переход» (smooth):
2407
- // • высвобождаем кэш из старого портфеля как под новые ПОКУПКИ, так и под новые ШОРТЫ (чтобы шорты открывались полным рабочим слотом dynSlotRub, а не на остаток копеек);
2408
- // • даже если прямо сейчас нет нового входа, но свободного кэша на счёте меньше 1 рабочего слота, плавно продаём на каждом шаге по 1–2 самые слабые старые акции с отрицательным прогнозом (expectedPct <= -0.02%), заранее формируя рублёвый резерв;
2409
- // • сортируем кандидатов строго по худшему прогнозу ИИ (сначала акции с отрицательным ожиданием, среди близких — позиции в плюсе или с минимальным убытком), до 4 бумаг за шаг при активном сигнале.
2383
+ // • высвобождаем кэш из старого портфеля как под новые ПОКУПКИ, так и под новые ШОРТЫ;
2384
+ // • даже если прямо сейчас нет нового входа, но свободного кэша на счёте меньше 1 рабочего слота, плавно продаём на каждом шаге по 1–2 самые слабые старые акции с отрицательным прогнозом (expectedPct <= -0.02%).
2410
2385
  if (legacyMode === 'smooth' && smoothSellPool.length > 0) {
2411
- const activeEntries = [...newBuys, ...newShorts];
2412
- const neededForSignalsRub = activeEntries.reduce((s, r) => s + (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r), 0);
2386
+ const activeEntries = preparedNew.map((x) => x.r);
2413
2387
  const hasWeakLegacy = smoothSellPool.some((x) => (x.r.expectedPct || 0) <= -0.02);
2414
2388
  const proactiveReserveRub = (!activeEntries.length && hasWeakLegacy && availRub < slotRub) ? slotRub : 0;
2415
- const targetNeedRub = Math.max(neededForSignalsRub, proactiveReserveRub);
2389
+ const targetNeedRub = Math.max(totalDesiredNewRub, proactiveReserveRub);
2416
2390
  let shortfall = Math.min(Math.max(0, budget * 0.99 - usedRub), targetNeedRub) - availRub;
2417
2391
  if (shortfall > 1000) {
2418
2392
  smoothSellPool.sort((a, b) => {
2419
2393
  const ea = Number(a.r.expectedPct || 0);
2420
2394
  const eb = Number(b.r.expectedPct || 0);
2421
- // Сначала бумаги с явно худшим прогнозом (разница > 0.03%), при близком прогнозе — сначала плюсовые/околонулевые
2422
2395
  if (Math.abs(ea - eb) > 0.03) return ea - eb;
2423
2396
  return ((b.pos.pnlRub >= 0 ? 1 : 0) - (a.pos.pnlRub >= 0 ? 1 : 0)) || (ea - eb);
2424
2397
  });
@@ -2445,27 +2418,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2445
2418
  }
2446
2419
  }
2447
2420
  }
2448
- let remNew = totalNew;
2449
- const combinedNew = [
2450
- ...newBuys.map((r) => ({ r, side: 'buy' })),
2451
- ...newShorts.map((r) => ({ r, side: 'short' })),
2452
- ];
2453
- // Если включено «Свободные рубли в LQDT» (cashSweep) и для новых сигналов не хватает свободных рублей,
2454
- // сначала автоматически продаём нужное число паёв фонда ликвидности LQDT, которые открыла стратегия (сверх неприкосновенного baselineQty)
2455
- if (!collateral && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length > 0 && !closing.has('LQDT') && !activeOrderSymbols.has('LQDT')) {
2421
+
2422
+ // Если включено «Свободные рубли в LQDT» (cashSweep) и счёт без залогового плеча (!collateral), при нехватке рублей продаём нужное число паёв LQDT
2423
+ if (!collateral && acc.cashSweep && acc.broker === 'tbank' && preparedNew.length > 0 && !closing.has('LQDT') && !activeOrderSymbols.has('LQDT')) {
2456
2424
  const lqdtPos = heldMap.get('LQDT');
2457
2425
  const lqdtAlgoQty = lqdtPos ? algoQtyOf('LQDT', lqdtPos.qty) : 0;
2458
2426
  if (lqdtAlgoQty > 0 && lqdtPos.price > 0) {
2459
2427
  const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.001, decimals: 3 };
2460
2428
  const lqdtLots = Math.floor(lqdtAlgoQty / lqdtSp.lotSize);
2461
- const neededForNewRub = combinedNew.reduce((s, { r, side }) => {
2462
- const isT1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2463
- const rawCap = budget * (side === 'short' ? (isT1 ? 0.35 : 0.18) : (isT1 ? 0.45 : 0.22));
2464
- const barTurnRub = !isT1 ? recentBarTurnoverRub(r.symbol) : 0;
2465
- const liqCap = (!isT1 && barTurnRub > 0) ? Math.max(45000, Math.min(rawCap, barTurnRub * 0.12)) : rawCap;
2466
- return s + Math.min(liqCap, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
2467
- }, 0);
2468
- const needFromLqdtRub = Math.min(Math.max(0, budget * 0.99 - usedRub), neededForNewRub) - availRub;
2429
+ const needFromLqdtRub = Math.min(Math.max(0, budget * 0.99 - usedRub), totalDesiredNewRub) - availRub;
2469
2430
  if (lqdtLots >= 1 && needFromLqdtRub > 1500) {
2470
2431
  const limPx = sniperLimitPrice(lqdtPos.price, -1, 0.03, lqdtSp);
2471
2432
  const lotVal = limPx * lqdtSp.lotSize;
@@ -2474,46 +2435,114 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2474
2435
  orders.push({
2475
2436
  symbol: 'LQDT', action: 'sell', lotSize: lqdtSp.lotSize, lots: sellLots, shares: sellLots * lqdtSp.lotSize,
2476
2437
  refPrice: lqdtPos.price, limitPrice: limPx, amountRub,
2477
- reason: `Высвобождение ${fmtRubSimple(amountRub)} ₽ из фонда денежного рынка LQDT под сигнал стратегии (${combinedNew.map((x) => x.r.symbol).join(', ')})`,
2438
+ reason: `Высвобождение ${fmtRubSimple(amountRub)} ₽ из фонда денежного рынка LQDT под сигнал стратегии (${preparedNew.map((x) => x.r.symbol).join(', ')})`,
2478
2439
  });
2479
2440
  closing.add('LQDT');
2480
2441
  availRub += amountRub;
2481
2442
  }
2482
2443
  }
2483
2444
  }
2484
- if (autoAdapt && combinedNew.length > 1) {
2485
- combinedNew.sort((a, b) => Math.abs(b.r.expectedPct || 0) - Math.abs(a.r.expectedPct || 0));
2486
- }
2487
- let remWeightSum = combinedNew.reduce((s, x) => s + Math.max(0.01, Math.abs(x.r.expectedPct || 0)), 0);
2488
-
2489
- // Жёсткий предохранитель концентрации на одну бумагу в реальном стакане брокера:
2490
- // • для ликвидных бумаг 1-го эшелона: до 45% счёта на один лонг и до 35% счёта на один шорт;
2491
- // • для бумаг 2–3 эшелона (MRKC, MRKZ, NKHP, AQUA, FESH, FEES и др.): до 22% счёта на один лонг и до 18% на один шорт,
2492
- // чтобы крупная заявка не выкупала тонкий стакан против самой себя и всегда оставляла капитал на 3–4 бумаги!
2493
- const maxLongTier1Rub = budget * 0.45;
2494
- const maxShortTier1Rub = budget * 0.35;
2495
- const maxLongTier2Rub = budget * 0.22;
2496
- const maxShortTier2Rub = budget * 0.18;
2497
-
2498
- for (const { r, side } of combinedNew) {
2499
- const wCur = Math.max(0.01, Math.abs(r.expectedPct || 0));
2500
- const propShare = autoAdapt && remWeightSum > 0 ? availRub * (wCur / remWeightSum) : availRub / Math.max(1, remNew);
2501
- remWeightSum = Math.max(0, remWeightSum - wCur);
2502
- remNew--;
2445
+
2446
+ // Двухступенчатое динамическое потеснение под новый сильный сигнал 1-го эшелона (единое с server/model/simulator.js):
2447
+ // в режиме auto работает и на быстрых внутрисвечных входах, а в режиме confirm — на плановых 30-минутных шагах (!onlyEntries),
2448
+ // ротируя остывшие позиции либо частично подрезая позицию 2-го эшелона (с сохранением entryTs и peakPrice оставшихся лотов).
2449
+ if (autoAdapt && STRATEGY_POLICY.crowdOut !== false && (acc.mode === 'auto' || !onlyEntries) && preparedNew.length > 0) {
2450
+ const bestT1 = preparedNew.find((x) => TIER1_LIQUID_SYMBOLS.has(x.r.symbol));
2451
+ const minChunkRub = Math.max(45000, budget * (STRATEGY_POLICY.crowdMinPct ?? 0.06));
2452
+ let shortfall = totalDesiredNewRub - availRub;
2453
+ if (bestT1 && shortfall >= minChunkRub) {
2454
+ const minHoldMs = (STRATEGY_POLICY.crowdMinMin ?? 20) * 60000;
2455
+ const rotBufPct = STRATEGY_POLICY.crowdRotBufPct ?? 0.08;
2456
+ const trimBufPct = STRATEGY_POLICY.crowdTrimBufPct ?? 0.12;
2457
+ const elig = [];
2458
+ for (const [sym, pos] of heldMap) {
2459
+ if (closing.has(sym) || activeOrderSymbols.has(sym) || !rowMap.has(sym)) continue;
2460
+ const aQ = algoQtyOf(sym, pos.qty);
2461
+ if (!aQ) continue;
2462
+ // На безналичном счёте без маржиналки закрытие шорта тратит кэш, а не высвобождает его под покупку
2463
+ if (!marginSizing && aQ < 0) continue;
2464
+ const entryAt = Number(acc.entryTsBySymbol?.[sym]) || 0;
2465
+ if (entryAt > 0 && nowTs - entryAt < minHoldMs) continue;
2466
+ const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
2467
+ const totalLots = Math.floor(Math.abs(aQ) / sp.lotSize);
2468
+ const rCur = rowMap.get(sym);
2469
+ const px = Number(rCur?.price || pos.price) || 0;
2470
+ if (totalLots < 1 || !(px > 0)) continue;
2471
+ const isLongPos = aQ > 0;
2472
+ const curSignedPct = isLongPos ? Number(rCur?.expectedPct || 0) : -Number(rCur?.expectedPct || 0);
2473
+ const thrEntryPct = isLongPos ? reqBuyEdgeOf(sym) : reqShortEdgeOf(sym);
2474
+ const heldIsT1 = TIER1_LIQUID_SYMBOLS.has(sym);
2475
+ elig.push({ sym, pos, rCur, sp, totalLots, px, isLongPos, curSignedPct, thrEntryPct, heldIsT1, posRub: totalLots * sp.lotSize * px, tierRank: heldIsT1 ? 1 : 0 });
2476
+ }
2477
+ elig.sort((a, b) => (a.tierRank - b.tierRank) || (a.curSignedPct - b.curSignedPct));
2478
+
2479
+ for (const el of elig) {
2480
+ if (shortfall < minChunkRub * 0.5) break;
2481
+ const coolRatio = el.heldIsT1 ? (STRATEGY_POLICY.crowdCoolRatioT1 ?? 0.45) : (STRATEGY_POLICY.crowdCoolRatioT2 ?? 0.80);
2482
+ const cooled = el.curSignedPct < el.thrEntryPct * coolRatio;
2483
+ const netAdvPct = bestT1.weight - Math.max(0, el.curSignedPct);
2484
+ const limPx = sniperLimitPrice(el.px, el.isLongPos ? -1 : 1, acc.limitOffsetPct, el.sp);
2485
+ const lotVal = limPx * el.sp.lotSize;
2486
+ if (!(lotVal > 0)) continue;
2487
+
2488
+ let lotsToClose = 0;
2489
+ let fullRotate = false;
2490
+ if (cooled && netAdvPct >= rotBufPct) {
2491
+ if (!el.heldIsT1 || el.curSignedPct < el.thrEntryPct * 0.35 || el.posRub - shortfall < minChunkRub) {
2492
+ lotsToClose = el.totalLots;
2493
+ fullRotate = true;
2494
+ } else {
2495
+ const maxTrimRub = el.posRub - minChunkRub;
2496
+ const trimRub = Math.min(shortfall, maxTrimRub);
2497
+ if (trimRub >= minChunkRub) lotsToClose = Math.min(el.totalLots - 1, Math.max(1, Math.ceil(trimRub / lotVal)));
2498
+ }
2499
+ } else if (!cooled && !el.heldIsT1 && netAdvPct >= trimBufPct && el.posRub >= minChunkRub * 1.5) {
2500
+ const maxTrimRub = el.posRub - minChunkRub;
2501
+ const trimRub = Math.min(shortfall, maxTrimRub);
2502
+ if (trimRub >= minChunkRub) lotsToClose = Math.min(el.totalLots - 1, Math.max(1, Math.ceil(trimRub / lotVal)));
2503
+ }
2504
+ if (lotsToClose >= 1) {
2505
+ const isTrim = !fullRotate && lotsToClose < el.totalLots;
2506
+ const amountRub = round(lotsToClose * el.sp.lotSize * limPx, 2);
2507
+ const freedExpRub = lotsToClose * el.sp.lotSize * el.pos.price;
2508
+ orders.push({
2509
+ symbol: el.sym,
2510
+ action: el.isLongPos ? 'sell' : 'cover',
2511
+ lotSize: el.sp.lotSize,
2512
+ lots: lotsToClose,
2513
+ shares: lotsToClose * el.sp.lotSize,
2514
+ refPrice: el.px,
2515
+ limitPrice: limPx,
2516
+ amountRub,
2517
+ isTrim,
2518
+ urgentExit: true,
2519
+ reason: isTrim
2520
+ ? `Частичное потеснение (${lotsToClose} из ${el.totalLots} лот., прогноз ${el.rCur?.expectedPct > 0 ? '+' : ''}${el.rCur?.expectedPct}%) под сильный сигнал 1-го эшелона ${bestT1.r.symbol} (${bestT1.r.expectedPct > 0 ? '+' : ''}${bestT1.r.expectedPct}%)`
2521
+ : `Ротация капитала из остывшей бумаги (${el.rCur?.expectedPct > 0 ? '+' : ''}${el.rCur?.expectedPct}%) в флагман 1-го эшелона ${bestT1.r.symbol} (${bestT1.r.expectedPct > 0 ? '+' : ''}${bestT1.r.expectedPct}%)`,
2522
+ });
2523
+ if (!isTrim) {
2524
+ closing.add(el.sym);
2525
+ if (el.isLongPos) activeLongs = Math.max(0, activeLongs - 1);
2526
+ else activeShorts = Math.max(0, activeShorts - 1);
2527
+ }
2528
+ usedRub = Math.max(0, usedRub - freedExpRub);
2529
+ availRub = recomputeAvailRub();
2530
+ shortfall = totalDesiredNewRub - availRub;
2531
+ }
2532
+ }
2533
+ }
2534
+ }
2535
+
2536
+ // Итеративное распределение свободного ресурса методом перелива (waterFillAllocations в policy.js):
2537
+ // кандидаты с узким потолком (2-й эшелон / лимит 15% от 10-минутного оборота свечи) забирают ровно свой потолок,
2538
+ // а весь оставшийся лимит без холостого простоя переливается флагманским лидерам 1-го эшелона.
2539
+ const allocSums = autoAdapt
2540
+ ? waterFillAllocations(preparedNew, availRub)
2541
+ : Float64Array.from(preparedNew.map((x) => Math.min(x.targetRub, availRub / Math.max(1, preparedNew.length))));
2542
+ for (let idx = 0; idx < preparedNew.length; idx++) {
2543
+ const { r, side, targetRub, tierMult, stopPct } = preparedNew[idx];
2503
2544
  const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2504
- // множитель размера по эшелону: 1-й эшелон — плечо счёта, 2–3-й — половинный размер (policy.js)
2505
- const tierMult = autoAdapt ? tierSizeMult(r.symbol, leverage) : 1;
2506
- const rawSideCapRub = tierMult * (side === 'short'
2507
- ? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
2508
- : (isTier1 ? maxLongTier1Rub : maxLongTier2Rub));
2509
- // Лимит от реального 10-минутного оборота стакана (Liquidity Cap): во 2-м эшелоне не входим объёмом больше 12% от 10-минутного оборота биржи,
2510
- // чтобы заявка не становилась «стенкой» для стаканных роботов и исполнялась целиком без проскальзывания
2511
- const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
2512
- const sideCapRub = (!isTier1 && barTurnoverRub > 0)
2513
- ? Math.max(45000, Math.min(rawSideCapRub, barTurnoverRub * 0.12))
2514
- : rawSideCapRub;
2515
- const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * tierMult * convOf(r));
2516
- const sum = Math.min(rawSum, propShare, availRub);
2545
+ const sum = Math.min(allocSums[idx], availRub);
2517
2546
  const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
2518
2547
  const effOffsetPct = isTier1 ? Math.min(Number(acc.limitOffsetPct) || 0.05, 0.06) : Math.min(Number(acc.limitOffsetPct) || 0.04, 0.04);
2519
2548
  const ord = calcLotOrder(r.symbol, sum, refPx, side, effOffsetPct, specs);
@@ -2527,8 +2556,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2527
2556
  bar: fc.bar, expectedPct: r.expectedPct,
2528
2557
  thresholdPct: side === 'buy' ? reqBuyEdgeOf(r.symbol) : reqShortEdgeOf(r.symbol),
2529
2558
  edgeMult: r.edgeMult, sigmaBarPct: r.sigmaPct, lastMovePct: r.lastMovePct, selfPct: r.selfPct,
2530
- marketDown: Boolean(fc.marketDown), conv: round(convOf(r), 3), budgetRub: budget, targetRub: round(rawSum, 0), leverage, tierMult, collateral,
2531
- stopPct: protectiveStopPct(side === 'buy' ? 'long' : 'short'), day: r.day || null, onlyEntries: Boolean(onlyEntries),
2559
+ marketDown: Boolean(fc.marketDown), conv: round(convOf(r), 3), budgetRub: budget, targetRub: round(targetRub, 0), leverage, tierMult, collateral,
2560
+ stopPct, day: r.day || null, onlyEntries: Boolean(onlyEntries),
2532
2561
  },
2533
2562
  reason: side === 'buy'
2534
2563
  ? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
@@ -2786,7 +2815,7 @@ async function sendOrderToBroker(acc, ord) {
2786
2815
  // при узком спреде (<= 0,10% или <= 2 шагов) ставим ровно в лучшую встречную цену (bestAsk / bestBid),
2787
2816
  // а при широком спреде встаём на середине спреда (midPx), не переплачивая стаканным роботам!
2788
2817
  const tightExitSpread = spreadPct <= 0.10 || (bestAsk - bestBid) <= 2.1 * step;
2789
- if (ord.isStopLoss && spreadPct <= 0.80) {
2818
+ if ((ord.isStopLoss || ord.urgentExit) && spreadPct <= (ord.isStopLoss ? 0.80 : 0.35)) {
2790
2819
  const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2791
2820
  if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
2792
2821
  } else if (tightExitSpread) {
@@ -3178,10 +3207,12 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
3178
3207
  delete acc.entryFailReasonBySymbol[ord.symbol];
3179
3208
  } else if (ord.action === 'sell' || ord.action === 'cover') {
3180
3209
  hadExits = true;
3181
- delete acc.entryTsBySymbol[ord.symbol];
3182
- delete acc.peakPriceBySymbol[ord.symbol];
3183
- if (ord.symbol !== 'LQDT') {
3184
- acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
3210
+ if (!ord.isTrim) {
3211
+ delete acc.entryTsBySymbol[ord.symbol];
3212
+ delete acc.peakPriceBySymbol[ord.symbol];
3213
+ if (ord.symbol !== 'LQDT') {
3214
+ acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
3215
+ }
3185
3216
  }
3186
3217
  }
3187
3218
  } catch (e) {
@@ -3516,8 +3547,9 @@ async function tickBrokerSchedule() {
3516
3547
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
3517
3548
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
3518
3549
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
3550
+ const canEnterOrCrowd = freeCashRub >= 15000 || (acc.marginEnabled && (Number(acc.maxLeverage) || 1) > 1) || hasOpenAlgoPos;
3519
3551
  const marketActiveNow = Boolean(latest && nowTs - latest <= 25 * 60000);
3520
- if (marketActiveNow && acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
3552
+ if (marketActiveNow && acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && canEnterOrCrowd && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
3521
3553
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
3522
3554
  }
3523
3555
  }
@@ -8,12 +8,13 @@
8
8
  export const ENTRY_EDGE_MULT = 1.60;
9
9
  export const TIER2_EDGE_MULT = 1.20;
10
10
 
11
- // Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный спред):
12
- // высоковолатильные бумаги со спредом 2-го эшелона (SMLT, PIKK, POSI, AFKS, VKCO, CBOM, BSPB, SVCB) идут по порогу TIER2_EDGE_MULT.
11
+ // Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный спред, нулевая частота срабатывания 7%-го стопа):
12
+ // бумаги с повышенным хвостовым риском и широким спредом (SMLT, PIKK, POSI, AFKS, VKCO, SPBE, UGLD, HEAD, CBOM) идут по правилам 2-го эшелона (TIER2_EDGE_MULT).
13
13
  export const TIER1_LIQUID_SYMBOLS = new Set([
14
14
  'SBER', 'SBERP', 'LKOH', 'GAZP', 'T', 'YDEX', 'ROSN', 'NVTK', 'GMKN', 'PLZL',
15
15
  'VTBR', 'TATN', 'TATNP', 'SNGS', 'SNGSP', 'ALRS', 'CHMF', 'NLMK', 'MAGN', 'MOEX',
16
- 'OZON', 'HEAD', 'PHOR', 'AFLT', 'MTSS', 'IRAO', 'TRNFP', 'RUAL', 'FLOT', 'X5',
16
+ 'OZON', 'PHOR', 'AFLT', 'MTSS', 'IRAO', 'TRNFP', 'RUAL', 'FLOT', 'X5',
17
+ 'MTLR', 'BSPB', 'RTKM', 'SVCB',
17
18
  ]);
18
19
 
19
20
  export function symbolEdgeMult(symbol, { sigma = 0, avgSigma = 0, avgBarValRub = 0 } = {}) {