neuroalpha 0.6.11 → 0.6.13

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.
@@ -69,9 +69,10 @@
69
69
 
70
70
  В разделе **«Прогноз сейчас»** каждые **45 секунд** по живым котировкам Мосбиржи обновляются две таблицы: **«Сильнее рынка»** (кандидаты в покупку) и **«Слабее рынка»** (кандидаты в шорт).
71
71
 
72
- - **Разделение на 1-й и 2-й эшелоны ликвидности:**
73
- - **1-й эшелон (33 наиболее ликвидные акции Мосбиржи):** низкие потери в стакане (`0,065%` на сторону = `0,13%` за полный круг со всеми комиссиями), базовый порог входа `~+0,40%`, размер позиции масштабируется на полное плечо счёта (до `50%` бюджета в лонг и до `38%` в шорт).
74
- - **2–3-й эшелон (остальные ~92 акции):** более широкий спред в стакане (`0,125%` на сторону = `0,25%` за полный круг), порог входа повышен в `1,20×` (`~+0,48%`), размер позиции масштабируется от плеча асимметрично (`max(0,50, 0,38 × плечо)` для лонга и `max(0,35, 0,22 × плечо)` для шорта) и ограничен `15%` от реального 10-минутного оборота свечи.
72
+ - **Разделение на 1-й и 2-й эшелоны ликвидности, перелив капитала и потеснение:**
73
+ - **1-й эшелон (33 наиболее ликвидные акции Мосбиржи):** низкие потери в стакане (`0,065%` на сторону = `0,13%` за полный круг со всеми комиссиями), базовый порог входа `~+0,40%`, живой `Conviction Sizing` разгоняет сильные одиночные сигналы вплоть до `92%` бюджета в лонг и `70%` в шорт на `1×` (в режиме «Хозяин»; до `75% / 56%` на «VIP», `52% / 40%` на «Профи», `58%` на «Инвесторе»), а с ростом плеча счёта лимит одной бумаги растёт сублинейно (`1 + 0,80 × (плечо − 1)`), сохраняя свободный маржинальный резерв под параллельные идеи.
74
+ - **2–3-й эшелон (остальные ~92 акции):** более широкий спред в стакане (`0,125%` на сторону = `0,25%` за полный круг), порог входа повышен в `1,20×` (`~+0,48%`), прямой предел позиции до `17%` в лонг и до `7%` в шорт на `1×` (с плавным ростом по плечу `0,72 / 0,40`) и жёстким потолком не более `15%` от реального 10-минутного оборота свечи.
75
+ - **Итеративный перелив (`water-filling`) и динамическое потеснение:** мелкие по ёмкости сделки 2-го эшелона не отнимают квоту у лидеров 1-го эшелона, а при нехватке лимита под новый сильный сигнал 1-го эшелона робот освобождает место ротацией остывшей позиции или частичным подрезанием 2-го эшелона (на `auto` — в реальном времени, на `confirm` — в плановых 30-минутных пакетах).
75
76
  - **Авто-балансировка по тренду индекса:** когда индекс Мосбиржи растёт, покупки идут по базовому порогу (`+0,40%` / `+0,48%`), а вход в контртрендовый шорт ужесточается в `1,25×` (`−0,50%` / `−0,60%`). На падающем рынке система автоматически облегчает шорты (`×0,90`) и ужесточает покупки (`×1,25`).
76
77
  - **Глубина сопровождения `130 минут` (`holdBars = 13`) и фиксация прибыли:** первые 45 минут после открытия действует мягкий порог удержания (`0,35×`), затем к 130-й минуте он плавно поднимается к `0,95×` с подстройкой под сегодняшнюю волатильность бумаги. При достижении порога Take-Profit включается скользящая защита прибыли (в 1-м эшелоне удерживает `≥70%` пика, во 2-м эшелоне — `≥75%` пика с допустимым откатом не более `0,35%`).
77
78
 
@@ -91,5 +92,5 @@
91
92
  - Торговля **в рост и в падение (с шортами)** с повышенной концентрацией слота;
92
93
  - Количество брокерских счетов и начальная сумма торговли определяются **по индивидуальному согласованию**.
93
94
  - **Режим «Хозяин» (`owner` — для владельца / администратора платформы):**
94
- - **Без ограничений** по размеру позиции (внутричасовой перехват `H/5` каждые 30 минут, `Conviction Sizing` до 75% капитала), числу счетов и сумме капитала;
95
+ - **Максимальная отдача капитала** (внутричасовой перехват `H/5` каждые 30 минут, живой `Conviction Sizing` до `92%` капитала в лонг / `70%` в шорт в 1-м эшелоне с сублинейным плечом и динамическим потеснением), без ограничений по числу счетов и сумме капитала;
95
96
  - Эксклюзивный доступ к разделу **«Эксперименты»**.
@@ -114,10 +114,12 @@ $$\text{Edge}_i = \hat{y}_i \cdot \sigma_i \cdot \sqrt{H}$$
114
114
  * В режиме `Покупки и продажи (longshort)` с включённым самообучением система автоматически **повышает входной фильтр для покупок (`×1.25`)** и **облегчает вход для коротких продаж (`×0.90`)** на падающем рынке (и наоборот — на растущем рынке).
115
115
  * Именно эта механика позволяет стратегии стабильно зарабатывать и в растущие месяцы, и на затяжном падении индекса Мосбиржи.
116
116
 
117
- ### 5.5. Распределение капитала по эшелонам, плечу и чистой отдаче (`Dynamic Top-K` + `Conviction Sizing` + `netPickSort`)
118
- В режиме автопилота (`modelTopK = 0`) система держит до 4 лонгов и до 4 шортов, распределяя капитал между отобранными лидерами пропорционально **чистой ожидаемой отдаче после вычета круговых издержек эшелона** (`|Edge| − 0,13%` для 1-го эшелона и `|Edge| − 0,25%` для 2-го):
119
- * В **1-м эшелоне** размер слота масштабируется на полное плечо счёта (`maxLeverage`), а предел на одну бумагу составляет до **`50%` бюджета в лонг и до `38%` в шорт** (умноженные на плечо).
120
- * Во **2-м эшелоне** действует асимметричное масштабирование от плеча счёта (`tierSizeMult`): для лонга — `max(0,50, 0,38 × плечо)`, для шорта — `max(0,35, 0,22 × плечо)`, при пределе `22%` в лонг / `18%` в шорт и жёстком потолке ликвидности **не более `15%` от медианного 10-минутного оборота свечи (`barTurnoverRub`)**.
117
+ ### 5.5. Адаптивная загрузка без холостого резерва: живой `Conviction Sizing`, сублинейное плечо, `water-filling` и двухступенчатое потеснение
118
+ Поскольку `81,5%` активного времени на рынке открыта всего **одна** позиция, система не замораживает половину лимита в «мёртвом резерве» под гипотетические будущие сделки, а решает три задачи раздельно:
119
+ * **1-й эшелон (живой `Conviction Sizing` + сублинейное плечо):** пограничный сигнал получает умеренный базовый объём, а сильный одиночный импульс пропорционально `conv` (до `1,92×` в режиме «Хозяин») разгоняется до **`92%` бюджета в лонг и `70%` в шорт** на счёте `1×` (`75% / 56%` на «VIP», `52% / 40%` на «Профи», `58%` в лонг на «Инвесторе»). С ростом плеча счёта потолок *одной* бумаги растёт **сублинейно** (`1 + 0,80 × (плечо − 1)`), удерживая риск одиночной сделки в рамках контроля просадки и превращая остаток бесплатного внутридневного плеча под залог `LQDT` в эластичный резерв под 2-ю и 3-ю параллельные идеи.
120
+ * **2-й эшелон (прямой риск-лимит + стакан биржи):** без двойного перемножения штрафов действует прямой потолок — до **`17%` бюджета в лонг** (`1 + 0,72 × (плечо − 1)`) и до **`7%` в шорт** (`1 + 0,40 × (плечо − 1)`), жёстко отсечённый по реальной глубине биржевого стакана (**не более `15%` от медианного 10-минутного оборота свечи `barTurnoverRub`**).
121
+ * **Итеративный перелив (`waterFillAllocations`) и чистая отдача (`netPickSort`):** веса сделок рассчитываются за вычетом круговых издержек эшелона (`|Edge| − 0,13%` в 1-м эшелоне и `|Edge| − 0,25%` во 2-м). При одновременном входе сделки с узким лимитом (2-й эшелон / тонкий стакан) забирают ровно свой потолок, а весь оставшийся ресурс переливается крупным лидерам 1-го эшелона без пустых остатков.
122
+ * **Двухступенчатое динамическое потеснение (`crowdOut`):** если свободного лимита реально не хватает под новый сильный сигнал 1-го эшелона, робот (с защитной выдержкой `≥ 20 минут` от «пилы») либо ротирует остывшую позицию целиком, либо частично подрезает позицию 2-го эшелона с полным сохранением `entryTs` и накопленного `peakPrice` для скользящего `Profit Lock`.
121
123
 
122
124
  ---
123
125
 
@@ -155,7 +157,7 @@ $$\text{Edge}_i = \hat{y}_i \cdot \sigma_i \cdot \sqrt{H}$$
155
157
  4. **Только лимитные заявки с точным шагом цены (`limitOffsetPct`):** Заявки выставляются от живой цены с защитным лимитным отступом (по умолчанию `0.1%`), округлённым строго по шагу цены (`MINSTEP` / `minPriceIncrement`) конкретной бумаги.
156
158
  5. **Защита краёв сессии (`skipEdgeMinutes`):** В окна `06:50–07:25`, `09:50–10:10` и `18:35–18:55` МСК новые входы не открываются и фиксация прибыли не срабатывает на шуме стакана; аварийные стопы работают всегда.
157
159
  6. **Аварийный стоп позиции — 7% от средней цены входа** (у брокера стоп-заявкой и во внутреннем контуре). Модель торгует возврат к среднему, поэтому стоп стоит широко: проверка на истории показала, что прежние 4,5–5% закрывали позиции на пике отклонения и снижали итог на обеих половинах периода.
158
- 6.1. **Плечо (`maxLeverage`, настройка счёта):** множитель размера позиций и предел суммарной позиции (`×` капитал). В ликвидных бумагах 1-го эшелона размер масштабируется на полное плечо счёта, а во 2–3-м эшелоне действует асимметричное масштабирование (`max(0,50, 0,38 × плечо)` для лонга и `max(0,35, 0,22 × плечо)` для шорта) под защитой потолка `15%` от 10-минутного оборота свечи (`barTurnoverRub`). Работает только при включённой маржинальной торговле; тариф «Инвестор» — без плеча, «Профи» — до 3, «VIP» и «Хозяин» — до 7. Предел заявки дополнительно проверяет брокер (`GetMaxLots`, маржинальный лимит).
160
+ 6.1. **Плечо (`maxLeverage`, настройка счёта):** предел суммарной позиции (`×` капитал) и сублинейный множитель одиночной сделки (`tierSizeMult`). В 1-м эшелоне предел одной бумаги растёт по закону `1 + 0,80 × (плечо − 1)`, оставляя часть бесплатного внутридневного плеча свободным резервом под параллельные идеи; во 2–3-м эшелоне прямой лимит (`17%` лонг / `7%` шорт на `1×`, наклон по плечу `0,72` / `0,40`) защищён потолком `15%` от 10-минутного оборота свечи (`barTurnoverRub`). Работает только при включённой маржинальной торговле; тариф «Инвестор» — без плеча, «Профи» — до 3, «VIP» и «Хозяин» — до 7. Предел заявки дополнительно проверяет брокер (`GetMaxLots`, маржинальный лимит).
159
161
  6.2. **Стоп по общей просадке (`maxDrawdownPct`, настройка счёта, 0 — выкл.):** если оценка счёта опустилась от своего максимума на заданный процент, новые входы запрещаются до ручного снятия («Снять стоп по просадке»); открытые позиции сопровождаются. После вывода денег стоп снимается вручную.
160
162
  6.3. **Капитал в LQDT как залог** (при включённых «Свободные рубли в LQDT» и маржинальной торговле): паи LQDT не продаются под входы — внутридневные позиции открываются под обеспечение и закрываются до ночи (платы за перенос нет), а свободные рубли размещаются в LQDT, когда у стратегии нет открытых позиций.
161
163
  6.4. **Вход против планки:** если брокер отклонил вход из-за ценового коридора Мосбиржи (код 30099 — бумага у планки), вход в эту бумагу в ту же сторону блокируется до конца торгового дня.
@@ -171,13 +173,14 @@ $$\text{Edge}_i = \hat{y}_i \cdot \sigma_i \cdot \sqrt{H}$$
171
173
 
172
174
  Изменение правил проверяется в лаборатории `scripts/strategy-lab.mjs` (варианты — в `scripts/strategy-lab-variants.mjs`) и принимается, только если оно лучше текущих правил **и на первой, и на второй половине периода**. Каждая отправленная заявка записывается в журнал решений `trade_decisions` (причина, ожидание и порог, контекст дня, плечо, версия программы; `GET /api/brokers/:id/decisions`).
173
175
 
174
- Проверка правил 13.11.2026 (пошаговые прогнозы 06.2024–11.2026, 59 051 свеча, 900 сделок, капитал 800 тыс. ₽, LQDT за период — 18,5% годовых):
176
+ Проверка правил 13.11.2026 (пошаговые прогнозы 06.2024–11.2026, 59 051 свеча, 896 сделок, капитал 800 тыс. ₽, LQDT за период — 18,5% годовых):
175
177
 
176
178
  | Правила | CAGR (весь период) | 1-я / 2-я половина | PF | Макс. просадка | Худшая сделка, % капитала |
177
179
  |---|---|---|---|---|---|
178
180
  | До 06.11.2026 (без LQDT, 2-й эшелон полным размером) | 12,9% | 19,3% / 6,8% | 1,40 | 4,4% | −2,0% |
179
- | Текущие (`holdBars=13`, ревизия эшелонов), плечо 1× | **39,5%** | **49,2% / 30,7%** | **1,85** | **2,0%** | −2,2% |
180
- | Текущие (`holdBars=13`, ревизия эшелонов), плечо 2× | **59,8%** | **73,8% / 47,4%** | **2,03** | **3,6%** | −4,3% |
181
- | Текущие (`holdBars=13`, ревизия эшелонов), плечо 3× | **83,7%** | **104,6% / 65,6%** | **2,07** | **5,9%** | −6,5% |
181
+ | Предыдущие (`v0.6.11`, полка `50%/38%` T1), плечо 2× | 59,8% | 73,8% / 47,4% | 2,03 | 3,6% | −1,9% |
182
+ | Текущие (живой `Conviction` + сублинейное плечо + перелив + потеснение), плечо 1× | **48,7%** | **59,9% / 38,7%** | **1,90** | **3,4%** | −1,3% |
183
+ | Текущие (живой `Conviction` + сублинейное плечо + перелив + потеснение), плечо 2× | **77,6%** | **95,0% / 62,4%** | **2,05** | **5,6%** | −2,4% |
184
+ | Текущие (живой `Conviction` + сублинейное плечо + перелив + потеснение), плечо 3× | **110,3%** | **133,7% / 90,1%** | **2,18** | **7,1%** | −3,4% |
182
185
 
183
186
  Отклонены (хуже хотя бы на одной половине или с катастрофическим хвостом): защита вечным фьючерсом IMOEXF (издержки больше выигрыша), пассивные лимитные входы, снижение порога входа, 6–8 позиций на сторону, выход по времени, отключение фиксации прибыли, стоп по волатильности и «стоп отмены идеи», размер позиции по риску. Результат на истории не гарантирует будущий: плечо пропорционально увеличивает и доходность, и просадки.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
@@ -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, symbolCapRub, 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';
@@ -188,7 +191,6 @@ function logBrokerEvent(acc, kind, text) {
188
191
  const uid = currentUserId();
189
192
  db.prepare('INSERT INTO broker_events (ts, user_id, account_id, kind, text) VALUES (?, ?, ?, ?, ?)')
190
193
  .run(Date.now(), uid, String(acc?.id || ''), String(kind), String(text || '').slice(0, 500));
191
- if (/error|stop|halt/.test(kind)) console.warn(`[брокер ${acc?.name || acc?.id}] ${kind}: ${text}`);
192
194
  // храним последние 5000 событий на счёт
193
195
  if (Math.random() < 0.02) {
194
196
  db.prepare(`DELETE FROM broker_events WHERE user_id = ? AND account_id = ? AND id NOT IN
@@ -272,7 +274,6 @@ function migrateKvAccounts(uid) {
272
274
  db.exec('COMMIT');
273
275
  } catch (e) { db.exec('ROLLBACK'); throw e; }
274
276
  // прежняя запись не удаляется: при откате на версию до 0.4.1 счета останутся на месте (в состоянии на момент переноса)
275
- console.log(`✓ Брокерские счета пользователя ${uid} перенесены в отдельные записи (${old.length})`);
276
277
  }
277
278
  userKvSet('brokerAccountsMigrated', true, uid);
278
279
  }
@@ -2093,32 +2094,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2093
2094
  return isStockQuarantined(q.cnt, q.pnlPct / 100, edgeRatio);
2094
2095
  };
2095
2096
  const slots = dir === 'long' ? K : 2 * K;
2096
- // Фильтр мощности слота по тарифу:
2097
- // • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
2098
- // • Профи: до 5 счетов по 1 млн ₽, покупки + шорты, базовый слот до 28% капитала
2099
- // • VIP: счета и сумма по договору, покупки + шорты, слот до 48% капитала
2100
- // • Хозяин: Conviction Sizing до 75% капитала
2097
+ // Профиль мощности слотов и индивидуальных потолков по тарифу (единый с бэктестом, server/model/policy.js):
2098
+ // • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов), слот до 58% T1 / 14% T2
2099
+ // • Профи: до 5 счетов по 1 млн ₽, покупки + шорты, плечо до 3×, слот до 52% T1 / 12% T2
2100
+ // • VIP: счета и сумма по договору, Conviction Sizing до 75% T1 / 14% T2
2101
+ // • Хозяин: живой Conviction Sizing до 92% T1 лонг / 70% T1 шорт (с сублинейным ростом по плечу)
2101
2102
  const isOwnerPlan = Boolean(plan.isOwner || plan.id === 'owner');
2103
+ const sizingProfile = planSizingProfile(isOwnerPlan ? 'owner' : (vipBoost ? 'vip' : (plan.id || 'investor')), dir);
2102
2104
  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;
2105
+ if (autoAdapt) {
2106
+ slotRub = budget * sizingProfile.slotW;
2109
2107
  } else if (plan.id === 'pro') {
2110
2108
  slotRub = budget * Math.min(0.28, 1.15 / Math.max(2, slots));
2111
2109
  } else {
2112
2110
  slotRub = budget * Math.min(0.24, 1.05 / Math.max(3, K + 1));
2113
2111
  }
2114
- // (Потолки доходности по тарифам и «дроссель» размера позиций удалены: тарифы различаются возможностями,
2115
- // а не обещанной доходностью — реалистичная доходность определяется стресс-тестом, а не тарифом.)
2116
2112
  const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
2117
2113
  const convOf = (r) => {
2118
2114
  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;
2115
+ return convictionMult(Math.abs(r.expectedPct) / needPct, sizingProfile);
2122
2116
  };
2123
2117
  const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
2124
2118
  const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
@@ -2315,67 +2309,21 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2315
2309
  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
2310
  + (onlyEntries ? activePendingOrders.reduce((s, o) => s + Math.abs(Number(o.amountRub) || 0), 0) : 0);
2317
2311
 
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));
2312
+ const recomputeAvailRub = () => {
2313
+ const freed = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
2314
+ const coverSpent = orders.filter((o) => o.action === 'cover').reduce((s, o) => s + o.amountRub, 0);
2315
+ const trimmedShortRub = orders.filter((o) => o.action === 'cover' && o.isTrim).reduce((s, o) => s + o.amountRub, 0);
2316
+ const openShortRub = Math.max(0, [...heldMap.values()]
2317
+ .filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
2318
+ .reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0) - trimmedShortRub);
2319
+ const cashLim = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freed - coverSpent - openShortRub : budget;
2320
+ return marginSizing
2321
+ ? Math.max(0, budget * 0.99 * leverage - usedRub)
2322
+ : Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLim));
2323
+ };
2324
+ let availRub = recomputeAvailRub();
2377
2325
 
2378
- // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
2326
+ // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
2379
2327
  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
2328
  // Не открываем шорт по тикеру, в котором ещё лежит непроданный остаток старого портфеля (baseQty > 0), чтобы брокер не превратил шорт в продажу старой позиции
2381
2329
  const rawNewShorts = shortCands.filter((r) => {
@@ -2395,30 +2343,53 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2395
2343
  const newBuys = buyExhaustion.ready;
2396
2344
  const newShorts = shortExhaustion.ready;
2397
2345
  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.50, 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;
2346
+ const dynSlotRub = slotRub;
2347
+
2348
+ const netPickWeight = (r) => {
2349
+ const rawPct = Math.abs(r.expectedPct || 0);
2350
+ if (!autoAdapt) return Math.max(0.01, rawPct);
2351
+ const roundTripCostPct = TIER1_LIQUID_SYMBOLS.has(r.symbol) ? 0.13 : 0.25;
2352
+ return Math.max(0.01, rawPct - roundTripCostPct);
2353
+ };
2354
+ const calcDesiredNewRub = (r, side) => {
2355
+ const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2356
+ const posSide = side === 'short' ? 'short' : 'long';
2357
+ const tierMult = autoAdapt ? tierSizeMult(r.symbol, leverage, posSide) : 1;
2358
+ const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
2359
+ const stopPct = protectiveStopPct(posSide);
2360
+ const effSlotRub = (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * tierMult;
2361
+ const targetRub = autoAdapt
2362
+ ? targetPositionRub({ symbol: r.symbol, side: posSide, budget: budget * tierMult, slotRub: effSlotRub, conv: convOf(r), stopPct, barTurnoverRub, profile: sizingProfile })
2363
+ : Math.min(symbolCapRub(r.symbol, posSide, budget * tierMult, barTurnoverRub), effSlotRub);
2364
+ return { targetRub, tierMult, stopPct };
2365
+ };
2366
+
2367
+ const combinedNew = [
2368
+ ...newBuys.map((r) => ({ r, side: 'buy' })),
2369
+ ...newShorts.map((r) => ({ r, side: 'short' })),
2370
+ ];
2371
+ if (autoAdapt && combinedNew.length > 1) {
2372
+ combinedNew.sort((a, b) => netPickWeight(b.r) - netPickWeight(a.r));
2373
+ }
2374
+ const preparedNew = combinedNew.map(({ r, side }) => {
2375
+ const { targetRub, tierMult, stopPct } = calcDesiredNewRub(r, side);
2376
+ return { r, side, weight: netPickWeight(r), targetRub, tierMult, stopPct };
2377
+ }).filter((x) => x.targetRub > 5000);
2378
+ const totalDesiredNewRub = preparedNew.reduce((s, x) => s + x.targetRub, 0);
2405
2379
 
2406
2380
  // В режиме «Плавный переход» (smooth):
2407
- // • высвобождаем кэш из старого портфеля как под новые ПОКУПКИ, так и под новые ШОРТЫ (чтобы шорты открывались полным рабочим слотом dynSlotRub, а не на остаток копеек);
2408
- // • даже если прямо сейчас нет нового входа, но свободного кэша на счёте меньше 1 рабочего слота, плавно продаём на каждом шаге по 1–2 самые слабые старые акции с отрицательным прогнозом (expectedPct <= -0.02%), заранее формируя рублёвый резерв;
2409
- // • сортируем кандидатов строго по худшему прогнозу ИИ (сначала акции с отрицательным ожиданием, среди близких — позиции в плюсе или с минимальным убытком), до 4 бумаг за шаг при активном сигнале.
2381
+ // • высвобождаем кэш из старого портфеля как под новые ПОКУПКИ, так и под новые ШОРТЫ;
2382
+ // • даже если прямо сейчас нет нового входа, но свободного кэша на счёте меньше 1 рабочего слота, плавно продаём на каждом шаге по 1–2 самые слабые старые акции с отрицательным прогнозом (expectedPct <= -0.02%).
2410
2383
  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);
2384
+ const activeEntries = preparedNew.map((x) => x.r);
2413
2385
  const hasWeakLegacy = smoothSellPool.some((x) => (x.r.expectedPct || 0) <= -0.02);
2414
2386
  const proactiveReserveRub = (!activeEntries.length && hasWeakLegacy && availRub < slotRub) ? slotRub : 0;
2415
- const targetNeedRub = Math.max(neededForSignalsRub, proactiveReserveRub);
2387
+ const targetNeedRub = Math.max(totalDesiredNewRub, proactiveReserveRub);
2416
2388
  let shortfall = Math.min(Math.max(0, budget * 0.99 - usedRub), targetNeedRub) - availRub;
2417
2389
  if (shortfall > 1000) {
2418
2390
  smoothSellPool.sort((a, b) => {
2419
2391
  const ea = Number(a.r.expectedPct || 0);
2420
2392
  const eb = Number(b.r.expectedPct || 0);
2421
- // Сначала бумаги с явно худшим прогнозом (разница > 0.03%), при близком прогнозе — сначала плюсовые/околонулевые
2422
2393
  if (Math.abs(ea - eb) > 0.03) return ea - eb;
2423
2394
  return ((b.pos.pnlRub >= 0 ? 1 : 0) - (a.pos.pnlRub >= 0 ? 1 : 0)) || (ea - eb);
2424
2395
  });
@@ -2445,27 +2416,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2445
2416
  }
2446
2417
  }
2447
2418
  }
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')) {
2419
+
2420
+ // Если включено «Свободные рубли в LQDT» (cashSweep) и счёт без залогового плеча (!collateral), при нехватке рублей продаём нужное число паёв LQDT
2421
+ if (!collateral && acc.cashSweep && acc.broker === 'tbank' && preparedNew.length > 0 && !closing.has('LQDT') && !activeOrderSymbols.has('LQDT')) {
2456
2422
  const lqdtPos = heldMap.get('LQDT');
2457
2423
  const lqdtAlgoQty = lqdtPos ? algoQtyOf('LQDT', lqdtPos.qty) : 0;
2458
2424
  if (lqdtAlgoQty > 0 && lqdtPos.price > 0) {
2459
2425
  const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.001, decimals: 3 };
2460
2426
  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 posSide = side === 'short' ? 'short' : 'long';
2464
- const barTurnRub = !isT1 ? recentBarTurnoverRub(r.symbol) : 0;
2465
- const liqCap = symbolCapRub(r.symbol, posSide, budget, barTurnRub);
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;
2427
+ const needFromLqdtRub = Math.min(Math.max(0, budget * 0.99 - usedRub), totalDesiredNewRub) - availRub;
2469
2428
  if (lqdtLots >= 1 && needFromLqdtRub > 1500) {
2470
2429
  const limPx = sniperLimitPrice(lqdtPos.price, -1, 0.03, lqdtSp);
2471
2430
  const lotVal = limPx * lqdtSp.lotSize;
@@ -2474,43 +2433,114 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2474
2433
  orders.push({
2475
2434
  symbol: 'LQDT', action: 'sell', lotSize: lqdtSp.lotSize, lots: sellLots, shares: sellLots * lqdtSp.lotSize,
2476
2435
  refPrice: lqdtPos.price, limitPrice: limPx, amountRub,
2477
- reason: `Высвобождение ${fmtRubSimple(amountRub)} ₽ из фонда денежного рынка LQDT под сигнал стратегии (${combinedNew.map((x) => x.r.symbol).join(', ')})`,
2436
+ reason: `Высвобождение ${fmtRubSimple(amountRub)} ₽ из фонда денежного рынка LQDT под сигнал стратегии (${preparedNew.map((x) => x.r.symbol).join(', ')})`,
2478
2437
  });
2479
2438
  closing.add('LQDT');
2480
2439
  availRub += amountRub;
2481
2440
  }
2482
2441
  }
2483
2442
  }
2484
- const netPickWeight = (r) => {
2485
- const rawPct = Math.abs(r.expectedPct || 0);
2486
- if (!autoAdapt) return Math.max(0.01, rawPct);
2487
- const roundTripCostPct = TIER1_LIQUID_SYMBOLS.has(r.symbol) ? 0.13 : 0.25;
2488
- return Math.max(0.01, rawPct - roundTripCostPct);
2489
- };
2490
- if (autoAdapt && combinedNew.length > 1) {
2491
- combinedNew.sort((a, b) => netPickWeight(b.r) - netPickWeight(a.r));
2443
+
2444
+ // Двухступенчатое динамическое потеснение под новый сильный сигнал 1-го эшелона (единое с server/model/simulator.js):
2445
+ // в режиме auto работает и на быстрых внутрисвечных входах, а в режиме confirm — на плановых 30-минутных шагах (!onlyEntries),
2446
+ // ротируя остывшие позиции либо частично подрезая позицию 2-го эшелона (с сохранением entryTs и peakPrice оставшихся лотов).
2447
+ if (autoAdapt && STRATEGY_POLICY.crowdOut !== false && (acc.mode === 'auto' || !onlyEntries) && preparedNew.length > 0) {
2448
+ const bestT1 = preparedNew.find((x) => TIER1_LIQUID_SYMBOLS.has(x.r.symbol));
2449
+ const minChunkRub = Math.max(45000, budget * (STRATEGY_POLICY.crowdMinPct ?? 0.06));
2450
+ let shortfall = totalDesiredNewRub - availRub;
2451
+ if (bestT1 && shortfall >= minChunkRub) {
2452
+ const minHoldMs = (STRATEGY_POLICY.crowdMinMin ?? 20) * 60000;
2453
+ const rotBufPct = STRATEGY_POLICY.crowdRotBufPct ?? 0.08;
2454
+ const trimBufPct = STRATEGY_POLICY.crowdTrimBufPct ?? 0.12;
2455
+ const elig = [];
2456
+ for (const [sym, pos] of heldMap) {
2457
+ if (closing.has(sym) || activeOrderSymbols.has(sym) || !rowMap.has(sym)) continue;
2458
+ const aQ = algoQtyOf(sym, pos.qty);
2459
+ if (!aQ) continue;
2460
+ // На безналичном счёте без маржиналки закрытие шорта тратит кэш, а не высвобождает его под покупку
2461
+ if (!marginSizing && aQ < 0) continue;
2462
+ const entryAt = Number(acc.entryTsBySymbol?.[sym]) || 0;
2463
+ if (entryAt > 0 && nowTs - entryAt < minHoldMs) continue;
2464
+ const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
2465
+ const totalLots = Math.floor(Math.abs(aQ) / sp.lotSize);
2466
+ const rCur = rowMap.get(sym);
2467
+ const px = Number(rCur?.price || pos.price) || 0;
2468
+ if (totalLots < 1 || !(px > 0)) continue;
2469
+ const isLongPos = aQ > 0;
2470
+ const curSignedPct = isLongPos ? Number(rCur?.expectedPct || 0) : -Number(rCur?.expectedPct || 0);
2471
+ const thrEntryPct = isLongPos ? reqBuyEdgeOf(sym) : reqShortEdgeOf(sym);
2472
+ const heldIsT1 = TIER1_LIQUID_SYMBOLS.has(sym);
2473
+ elig.push({ sym, pos, rCur, sp, totalLots, px, isLongPos, curSignedPct, thrEntryPct, heldIsT1, posRub: totalLots * sp.lotSize * px, tierRank: heldIsT1 ? 1 : 0 });
2474
+ }
2475
+ elig.sort((a, b) => (a.tierRank - b.tierRank) || (a.curSignedPct - b.curSignedPct));
2476
+
2477
+ for (const el of elig) {
2478
+ if (shortfall < minChunkRub * 0.5) break;
2479
+ const coolRatio = el.heldIsT1 ? (STRATEGY_POLICY.crowdCoolRatioT1 ?? 0.45) : (STRATEGY_POLICY.crowdCoolRatioT2 ?? 0.80);
2480
+ const cooled = el.curSignedPct < el.thrEntryPct * coolRatio;
2481
+ const netAdvPct = bestT1.weight - Math.max(0, el.curSignedPct);
2482
+ const limPx = sniperLimitPrice(el.px, el.isLongPos ? -1 : 1, acc.limitOffsetPct, el.sp);
2483
+ const lotVal = limPx * el.sp.lotSize;
2484
+ if (!(lotVal > 0)) continue;
2485
+
2486
+ let lotsToClose = 0;
2487
+ let fullRotate = false;
2488
+ if (cooled && netAdvPct >= rotBufPct) {
2489
+ if (!el.heldIsT1 || el.curSignedPct < el.thrEntryPct * 0.35 || el.posRub - shortfall < minChunkRub) {
2490
+ lotsToClose = el.totalLots;
2491
+ fullRotate = true;
2492
+ } else {
2493
+ const maxTrimRub = el.posRub - minChunkRub;
2494
+ const trimRub = Math.min(shortfall, maxTrimRub);
2495
+ if (trimRub >= minChunkRub) lotsToClose = Math.min(el.totalLots - 1, Math.max(1, Math.ceil(trimRub / lotVal)));
2496
+ }
2497
+ } else if (!cooled && !el.heldIsT1 && netAdvPct >= trimBufPct && el.posRub >= minChunkRub * 1.5) {
2498
+ const maxTrimRub = el.posRub - minChunkRub;
2499
+ const trimRub = Math.min(shortfall, maxTrimRub);
2500
+ if (trimRub >= minChunkRub) lotsToClose = Math.min(el.totalLots - 1, Math.max(1, Math.ceil(trimRub / lotVal)));
2501
+ }
2502
+ if (lotsToClose >= 1) {
2503
+ const isTrim = !fullRotate && lotsToClose < el.totalLots;
2504
+ const amountRub = round(lotsToClose * el.sp.lotSize * limPx, 2);
2505
+ const freedExpRub = lotsToClose * el.sp.lotSize * el.pos.price;
2506
+ orders.push({
2507
+ symbol: el.sym,
2508
+ action: el.isLongPos ? 'sell' : 'cover',
2509
+ lotSize: el.sp.lotSize,
2510
+ lots: lotsToClose,
2511
+ shares: lotsToClose * el.sp.lotSize,
2512
+ refPrice: el.px,
2513
+ limitPrice: limPx,
2514
+ amountRub,
2515
+ isTrim,
2516
+ urgentExit: true,
2517
+ reason: isTrim
2518
+ ? `Частичное потеснение (${lotsToClose} из ${el.totalLots} лот., прогноз ${el.rCur?.expectedPct > 0 ? '+' : ''}${el.rCur?.expectedPct}%) под сильный сигнал 1-го эшелона ${bestT1.r.symbol} (${bestT1.r.expectedPct > 0 ? '+' : ''}${bestT1.r.expectedPct}%)`
2519
+ : `Ротация капитала из остывшей бумаги (${el.rCur?.expectedPct > 0 ? '+' : ''}${el.rCur?.expectedPct}%) в флагман 1-го эшелона ${bestT1.r.symbol} (${bestT1.r.expectedPct > 0 ? '+' : ''}${bestT1.r.expectedPct}%)`,
2520
+ });
2521
+ if (!isTrim) {
2522
+ closing.add(el.sym);
2523
+ if (el.isLongPos) activeLongs = Math.max(0, activeLongs - 1);
2524
+ else activeShorts = Math.max(0, activeShorts - 1);
2525
+ }
2526
+ usedRub = Math.max(0, usedRub - freedExpRub);
2527
+ availRub = recomputeAvailRub();
2528
+ shortfall = totalDesiredNewRub - availRub;
2529
+ }
2530
+ }
2531
+ }
2492
2532
  }
2493
- let remWeightSum = combinedNew.reduce((s, x) => s + netPickWeight(x.r), 0);
2494
-
2495
- // Жёсткий предохранитель концентрации на одну бумагу в реальном стакане брокера (symbolCapRub в policy.js):
2496
- // • для ликвидных бумаг 1-го эшелона: до 50% счёта на один лонг и до 38% счёта на один шорт;
2497
- // • для бумаг 2–3 эшелона: до 22% счёта на один лонг и до 18% на один шорт (но не более 15% от 10-минутного оборота свечи),
2498
- // чтобы крупная заявка не выкупала тонкий стакан против самой себя и всегда оставляла капитал на 3–4 бумаги!
2499
- for (const { r, side } of combinedNew) {
2500
- const wCur = netPickWeight(r);
2501
- const propShare = autoAdapt && remWeightSum > 0 ? availRub * (wCur / remWeightSum) : availRub / Math.max(1, remNew);
2502
- remWeightSum = Math.max(0, remWeightSum - wCur);
2503
- remNew--;
2533
+
2534
+ // Итеративное распределение свободного ресурса методом перелива (waterFillAllocations в policy.js):
2535
+ // кандидаты с узким потолком (2-й эшелон / лимит 15% от 10-минутного оборота свечи) забирают ровно свой потолок,
2536
+ // а весь оставшийся лимит без холостого простоя переливается флагманским лидерам 1-го эшелона.
2537
+ const allocSums = autoAdapt
2538
+ ? waterFillAllocations(preparedNew, availRub)
2539
+ : Float64Array.from(preparedNew.map((x) => Math.min(x.targetRub, availRub / Math.max(1, preparedNew.length))));
2540
+ for (let idx = 0; idx < preparedNew.length; idx++) {
2541
+ const { r, side, targetRub, tierMult, stopPct } = preparedNew[idx];
2504
2542
  const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
2505
- const posSide = side === 'short' ? 'short' : 'long';
2506
- // множитель размера по эшелону и направлению: 1-й эшелон — плечо счёта, 2–3-й — асимметричное масштабирование от плеча (policy.js)
2507
- const tierMult = autoAdapt ? tierSizeMult(r.symbol, leverage, posSide) : 1;
2508
- // Лимит от реального 10-минутного оборота стакана (Liquidity Cap): во 2-м эшелоне не входим объёмом больше 15% от 10-минутного оборота биржи,
2509
- // чтобы заявка не становилась «стенкой» для стаканных роботов и исполнялась целиком без проскальзывания
2510
- const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
2511
- const sideCapRub = symbolCapRub(r.symbol, posSide, budget * tierMult, barTurnoverRub);
2512
- const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * tierMult * convOf(r));
2513
- const sum = Math.min(rawSum, propShare, availRub);
2543
+ const sum = Math.min(allocSums[idx], availRub);
2514
2544
  const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
2515
2545
  const effOffsetPct = isTier1 ? Math.min(Number(acc.limitOffsetPct) || 0.05, 0.06) : Math.min(Number(acc.limitOffsetPct) || 0.04, 0.04);
2516
2546
  const ord = calcLotOrder(r.symbol, sum, refPx, side, effOffsetPct, specs);
@@ -2524,8 +2554,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2524
2554
  bar: fc.bar, expectedPct: r.expectedPct,
2525
2555
  thresholdPct: side === 'buy' ? reqBuyEdgeOf(r.symbol) : reqShortEdgeOf(r.symbol),
2526
2556
  edgeMult: r.edgeMult, sigmaBarPct: r.sigmaPct, lastMovePct: r.lastMovePct, selfPct: r.selfPct,
2527
- marketDown: Boolean(fc.marketDown), conv: round(convOf(r), 3), budgetRub: budget, targetRub: round(rawSum, 0), leverage, tierMult, collateral,
2528
- stopPct: protectiveStopPct(side === 'buy' ? 'long' : 'short'), day: r.day || null, onlyEntries: Boolean(onlyEntries),
2557
+ marketDown: Boolean(fc.marketDown), conv: round(convOf(r), 3), budgetRub: budget, targetRub: round(targetRub, 0), leverage, tierMult, collateral,
2558
+ stopPct, day: r.day || null, onlyEntries: Boolean(onlyEntries),
2529
2559
  },
2530
2560
  reason: side === 'buy'
2531
2561
  ? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
@@ -2783,7 +2813,7 @@ async function sendOrderToBroker(acc, ord) {
2783
2813
  // при узком спреде (<= 0,10% или <= 2 шагов) ставим ровно в лучшую встречную цену (bestAsk / bestBid),
2784
2814
  // а при широком спреде встаём на середине спреда (midPx), не переплачивая стаканным роботам!
2785
2815
  const tightExitSpread = spreadPct <= 0.10 || (bestAsk - bestBid) <= 2.1 * step;
2786
- if (ord.isStopLoss && spreadPct <= 0.80) {
2816
+ if ((ord.isStopLoss || ord.urgentExit) && spreadPct <= (ord.isStopLoss ? 0.80 : 0.35)) {
2787
2817
  const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2788
2818
  if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
2789
2819
  } else if (tightExitSpread) {
@@ -3175,10 +3205,12 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
3175
3205
  delete acc.entryFailReasonBySymbol[ord.symbol];
3176
3206
  } else if (ord.action === 'sell' || ord.action === 'cover') {
3177
3207
  hadExits = true;
3178
- delete acc.entryTsBySymbol[ord.symbol];
3179
- delete acc.peakPriceBySymbol[ord.symbol];
3180
- if (ord.symbol !== 'LQDT') {
3181
- acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
3208
+ if (!ord.isTrim) {
3209
+ delete acc.entryTsBySymbol[ord.symbol];
3210
+ delete acc.peakPriceBySymbol[ord.symbol];
3211
+ if (ord.symbol !== 'LQDT') {
3212
+ acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
3213
+ }
3182
3214
  }
3183
3215
  }
3184
3216
  } catch (e) {
@@ -3513,8 +3545,9 @@ async function tickBrokerSchedule() {
3513
3545
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
3514
3546
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
3515
3547
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
3548
+ const canEnterOrCrowd = freeCashRub >= 15000 || (acc.marginEnabled && (Number(acc.maxLeverage) || 1) > 1) || hasOpenAlgoPos;
3516
3549
  const marketActiveNow = Boolean(latest && nowTs - latest <= 25 * 60000);
3517
- if (marketActiveNow && acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
3550
+ if (marketActiveNow && acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && canEnterOrCrowd && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
3518
3551
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
3519
3552
  }
3520
3553
  }