neuroalpha 0.6.11 → 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.
@@ -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.12",
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';
@@ -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.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;
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 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;
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,43 +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
- 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));
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
+ }
2492
2534
  }
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--;
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];
2504
2544
  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);
2545
+ const sum = Math.min(allocSums[idx], availRub);
2514
2546
  const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
2515
2547
  const effOffsetPct = isTier1 ? Math.min(Number(acc.limitOffsetPct) || 0.05, 0.06) : Math.min(Number(acc.limitOffsetPct) || 0.04, 0.04);
2516
2548
  const ord = calcLotOrder(r.symbol, sum, refPx, side, effOffsetPct, specs);
@@ -2524,8 +2556,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2524
2556
  bar: fc.bar, expectedPct: r.expectedPct,
2525
2557
  thresholdPct: side === 'buy' ? reqBuyEdgeOf(r.symbol) : reqShortEdgeOf(r.symbol),
2526
2558
  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),
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),
2529
2561
  },
2530
2562
  reason: side === 'buy'
2531
2563
  ? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
@@ -2783,7 +2815,7 @@ async function sendOrderToBroker(acc, ord) {
2783
2815
  // при узком спреде (<= 0,10% или <= 2 шагов) ставим ровно в лучшую встречную цену (bestAsk / bestBid),
2784
2816
  // а при широком спреде встаём на середине спреда (midPx), не переплачивая стаканным роботам!
2785
2817
  const tightExitSpread = spreadPct <= 0.10 || (bestAsk - bestBid) <= 2.1 * step;
2786
- if (ord.isStopLoss && spreadPct <= 0.80) {
2818
+ if ((ord.isStopLoss || ord.urgentExit) && spreadPct <= (ord.isStopLoss ? 0.80 : 0.35)) {
2787
2819
  const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
2788
2820
  if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
2789
2821
  } else if (tightExitSpread) {
@@ -3175,10 +3207,12 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
3175
3207
  delete acc.entryFailReasonBySymbol[ord.symbol];
3176
3208
  } else if (ord.action === 'sell' || ord.action === 'cover') {
3177
3209
  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);
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
+ }
3182
3216
  }
3183
3217
  }
3184
3218
  } catch (e) {
@@ -3513,8 +3547,9 @@ async function tickBrokerSchedule() {
3513
3547
  const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
3514
3548
  const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
3515
3549
  const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
3550
+ const canEnterOrCrowd = freeCashRub >= 15000 || (acc.marginEnabled && (Number(acc.maxLeverage) || 1) > 1) || hasOpenAlgoPos;
3516
3551
  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) {
3552
+ if (marketActiveNow && acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && canEnterOrCrowd && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
3518
3553
  await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
3519
3554
  }
3520
3555
  }
@@ -19,28 +19,44 @@ export const STRATEGY_POLICY = Object.freeze({
19
19
  profitLock: true,
20
20
  // размер позиции: 'slot' — доля бюджета × сила сигнала
21
21
  sizing: Object.freeze({ mode: 'slot' }),
22
- // Множитель размера позиции по эшелонам:
23
- // • 1-й эшелон (33 ликвидные акции, PF 2,86 и 0 стопов на 59 051 свече): масштабируется на полное плечо счёта (lev),
24
- // предельный вес бумаги — до 50% бюджета в лонг и до 38% в шорт (базовый слот 0,50);
25
- // • 2-й эшелон: асимметричное масштабирование от плеча счёта (лонг max(0,50, 0,38 × lev), шорт max(0,35, 0,22 × lev))
26
- // под защитой потолка реальной ликвидности стакана (15% от медианного 10-минутного оборота свечи barTurnoverRub)
27
- // и плотного сопровождения Take-Profit (удержание ≥75% пика и откат ≤0,35%).
28
- // Проверка 13.11.2026 (06.2024–11.2026, 59 051 свеча): улучшает итог на обеих половинах истории при любом плече
29
- // • плечо 1×: CAGR 49,2%/30,7% по половинам (весь 39,5%, PF 1,85, DD 2,0%) против 43,8%/27,3% (весь 35,1%, PF 1,63);
30
- // • плечо 2×: CAGR 73,8%/47,4% по половинам (весь 59,8%, 6 мес. 105,0%, PF 2,03, DD 3,6%) против 54,5%/40,6% (весь 47,2%, PF 1,87, DD 3,4%);
31
- // • плечо 3×: CAGR 104,6%/65,6% по половинам (весь 83,7%, PF 2,07, DD 5,9%) против 65,9%/55,2% (весь 60,3%, PF 2,03, DD 6,0%).
32
- sizeMultT2LongBase: 0.50,
33
- sizeMultT2LongLev: 0.38,
34
- sizeMultT2ShortBase: 0.35,
35
- sizeMultT2ShortLev: 0.22,
36
- capT1Long: 0.50,
37
- capT1Short: 0.38,
38
- capT2Long: 0.22,
39
- capT2Short: 0.18,
22
+ // Адаптивное распределение размера позиции, сублинейное плечо и перелив капитала (water-filling + потеснение):
23
+ // • 1-й эшелон (33 ликвидные акции, 0 стопов на 59 051 свече): живой Conviction Sizing (базовый слот 0,56 × conv до 1,92×)
24
+ // разгоняет одиночные сильные импульсы до потолка 92% бюджета в лонг и 70% в шорт на 1× (убирая холостой простой кэша,
25
+ // т.к. 81,5% активного времени открыта 1 позиция). С ростом плеча счёта потолок одной бумаги растёт сублинейно
26
+ // (1 + 0,80 × (lev − 1): до 166% на 2× и 239% на 3×), оставляя остаток бесплатного плеча под залог LQDT свободным резервом;
27
+ // • 2-й эшелон: единый прямой лимит без двойного перемножения — до 17% бюджета в лонг (масштаб плеча 0,72) и до 7% в шорт (0,40),
28
+ // под жёстким отсечением по реальной ёмкости стакана (15% от медианного 10-минутного оборота свечи barTurnoverRub);
29
+ // • waterFill: при пакетном входе мелкие по лимиту сделки 2-го эшелона не «замораживают» квоту крупных лидеров 1-го эшелона;
30
+ // • crowdOut: двухступенчатое потеснение (ротация остывшей позиции или частичное подрезание без потери peakPrice/entryTs)
31
+ // при нехватке лимита под сильный сигнал 1-го эшелона (повышение качества T2 → T1 либо перекладка из остывшей T1).
32
+ // Проверка 13.11.2026 (06.2024–11.2026, 59 051 свеча): улучшает итог и PF на обеих половинах истории при любом плече и тарифе
33
+ // • плечо 1×: CAGR 59,9%/38,7% по половинам (весь 48,7%, PF 1,90, DD 3,4%) против 49,2%/30,7% (весь 39,5%, PF 1,85);
34
+ // • плечо 2×: CAGR 95,0%/62,4% по половинам (весь 77,6%, 6 мес. 165,1%, PF 2,05, DD 5,6%) против 73,8%/47,4% (весь 59,8%, PF 2,03);
35
+ // • плечо 3×: CAGR 133,7%/90,1% по половинам (весь 110,3%, 6 мес. 292,3%, PF 2,18, DD 7,1%) против 104,6%/65,6% (весь 83,7%, PF 2,07).
36
+ slotOwner: 0.56,
37
+ convMaxOwner: 1.92,
38
+ convKOwner: 0.38,
39
+ capT1Long: 0.92,
40
+ capT1Short: 0.70,
41
+ capT2Long: 0.17,
42
+ capT2Short: 0.070,
43
+ slotT2LongMult: 0.25,
44
+ slotT2ShortMult: 0.10,
45
+ levSlopeT1: 0.80,
46
+ levSlopeT2Long: 0.72,
47
+ levSlopeT2Short: 0.40,
40
48
  turnoverCapMult: 0.15,
41
49
  tpRetainT2: 0.75,
42
50
  tpTrailT2: 0.35,
43
51
  netPickSort: true,
52
+ waterFill: true,
53
+ crowdOut: true,
54
+ crowdMinMin: 20,
55
+ crowdMinPct: 0.06,
56
+ crowdRotBufPct: 0.08,
57
+ crowdTrimBufPct: 0.12,
58
+ crowdCoolRatioT1: 0.45,
59
+ crowdCoolRatioT2: 0.80,
44
60
  // капитал хранится в фонде денежного рынка LQDT как залог: внутридневные позиции открываются под обеспечение
45
61
  // (без платы за перенос — позиции закрываются до ночи), LQDT не продаётся под входы
46
62
  cashYield: true,
@@ -141,44 +157,166 @@ export function exitHoldRatio(heldMin, horizonMin, { sigmaBarPct = 0, realizedBa
141
157
  return startRatio + (endRatio - startRatio) * ramp;
142
158
  }
143
159
 
144
- // ---------- плечо и множители размера по эшелонам ----------
160
+ // ---------- профиль слотов по тарифу, плечо и предельный размер позиции ----------
145
161
  export const MAX_LEVERAGE = 7;
162
+
163
+ export function planSizingProfile(plan = 'owner', dir = 'longshort', policy = STRATEGY_POLICY) {
164
+ const id = typeof plan === 'object' && plan ? (plan.isOwner ? 'owner' : (plan.id || 'investor')) : String(plan || 'owner');
165
+ if (id === 'owner') {
166
+ return {
167
+ id: 'owner',
168
+ slotW: policy?.slotPct ?? policy?.slotOwner ?? 0.56,
169
+ convMax: policy?.convMaxOwner ?? 1.92,
170
+ convK: policy?.convKOwner ?? 0.38,
171
+ capT1Long: policy?.capT1Long ?? 0.92,
172
+ capT1Short: policy?.capT1Short ?? 0.70,
173
+ capT2Long: policy?.capT2Long ?? 0.17,
174
+ capT2Short: policy?.capT2Short ?? 0.070,
175
+ };
176
+ }
177
+ if (id === 'vip') {
178
+ return {
179
+ id: 'vip',
180
+ slotW: policy?.slotPct ?? 0.46,
181
+ convMax: 1.50,
182
+ convK: 0.28,
183
+ capT1Long: 0.75,
184
+ capT1Short: 0.56,
185
+ capT2Long: 0.14,
186
+ capT2Short: 0.065,
187
+ };
188
+ }
189
+ if (id === 'pro') {
190
+ return {
191
+ id: 'pro',
192
+ slotW: policy?.slotPct ?? (dir === 'long' ? 0.36 : 0.34),
193
+ convMax: 1.0,
194
+ convK: 0,
195
+ capT1Long: 0.52,
196
+ capT1Short: 0.40,
197
+ capT2Long: 0.12,
198
+ capT2Short: 0.060,
199
+ };
200
+ }
201
+ return {
202
+ id: 'investor',
203
+ slotW: policy?.slotPct ?? 0.42,
204
+ convMax: 1.0,
205
+ convK: 0,
206
+ capT1Long: 0.58,
207
+ capT1Short: 0,
208
+ capT2Long: 0.14,
209
+ capT2Short: 0,
210
+ };
211
+ }
212
+
213
+ export function convictionMult(edgeRatio = 1, profile = null) {
214
+ const convMax = profile?.convMax ?? 1;
215
+ const convK = profile?.convK ?? 0;
216
+ if (!(convMax > 1) || !(edgeRatio > 0)) return 1;
217
+ return Math.min(convMax, Math.max(0.85, 0.80 + convK * edgeRatio));
218
+ }
219
+
220
+ // Сублинейный множитель масштабирования одиночной позиции от плеча счёта (на плече 1× равен 1,00):
221
+ // разгружает риск одиночной бумаги на высоких плечах и оставляет свободный маржинальный резерв под параллельные идеи.
146
222
  export function tierSizeMult(symbol, leverage = 1, sideOrPolicy = 'long', policyArg = STRATEGY_POLICY) {
147
223
  const isObj = sideOrPolicy && typeof sideOrPolicy === 'object';
148
224
  const side = isObj ? 'long' : (sideOrPolicy === 'short' ? 'short' : 'long');
149
225
  const policy = isObj ? sideOrPolicy : (policyArg || STRATEGY_POLICY);
150
226
  const lev = Math.max(1, Math.min(MAX_LEVERAGE, Number(leverage) || 1));
151
- if (TIER1_LIQUID_SYMBOLS.has(symbol)) return policy.sizeMultT1 ?? lev;
152
- if (policy.sizeMultT2 !== undefined) return policy.sizeMultT2;
153
- const raw = side === 'short'
154
- ? Math.max(policy.sizeMultT2ShortBase ?? 0.35, (policy.sizeMultT2ShortLev ?? 0.22) * lev)
155
- : Math.max(policy.sizeMultT2LongBase ?? 0.50, (policy.sizeMultT2LongLev ?? 0.38) * lev);
156
- return Math.round(raw * 1000) / 1000;
227
+ const isT1 = TIER1_LIQUID_SYMBOLS.has(symbol);
228
+ // Обратная совместимость со старыми вариантами лаборатории (sizeMultT2 / sizeMultT2LongBase)
229
+ if (policy?.sizeMultT2 !== undefined) return isT1 ? (policy.sizeMultT1 ?? lev) : policy.sizeMultT2;
230
+ if (policy?.sizeMultT2LongBase !== undefined) {
231
+ if (isT1) return policy.sizeMultT1 ?? lev;
232
+ const rawOld = side === 'short'
233
+ ? Math.max(policy.sizeMultT2ShortBase ?? 0.35, (policy.sizeMultT2ShortLev ?? 0.22) * lev)
234
+ : Math.max(policy.sizeMultT2LongBase ?? 0.50, (policy.sizeMultT2LongLev ?? 0.38) * lev);
235
+ return Math.round(rawOld * 1000) / 1000;
236
+ }
237
+ if (isT1 && policy?.sizeMultT1 !== undefined) return policy.sizeMultT1;
238
+ const slope = isT1
239
+ ? (policy?.levSlopeT1 ?? 0.80)
240
+ : (side === 'short' ? (policy?.levSlopeT2Short ?? 0.40) : (policy?.levSlopeT2Long ?? 0.72));
241
+ return Math.round((1 + slope * (lev - 1)) * 1000) / 1000;
157
242
  }
158
243
 
159
244
  // ---------- предельный размер позиции на одну бумагу ----------
160
- export function symbolCapRub(symbol, side, budget, barTurnoverRub = 0, policy = STRATEGY_POLICY) {
245
+ export function symbolCapRub(symbol, side, budget, barTurnoverRub = 0, policy = STRATEGY_POLICY, profile = null) {
161
246
  const isT1 = TIER1_LIQUID_SYMBOLS.has(symbol);
162
247
  const isShort = side === 'short';
163
- const capPct = isShort
164
- ? (isT1 ? (policy?.capT1Short ?? 0.38) : (policy?.capT2Short ?? 0.18))
165
- : (isT1 ? (policy?.capT1Long ?? 0.50) : (policy?.capT2Long ?? 0.22));
248
+ const legacyMode = policy?.sizeMultT2 !== undefined || policy?.sizeMultT2LongBase !== undefined;
249
+ let capPct;
250
+ if (legacyMode) {
251
+ capPct = isShort
252
+ ? (isT1 ? (policy?.capT1Short ?? 0.38) : (policy?.capT2Short ?? 0.18))
253
+ : (isT1 ? (policy?.capT1Long ?? 0.50) : (policy?.capT2Long ?? 0.22));
254
+ } else if (profile) {
255
+ capPct = isShort
256
+ ? (isT1 ? profile.capT1Short : profile.capT2Short)
257
+ : (isT1 ? profile.capT1Long : profile.capT2Long);
258
+ } else {
259
+ capPct = isShort
260
+ ? (isT1 ? (policy?.capT1Short ?? 0.70) : (policy?.capT2Short ?? 0.070))
261
+ : (isT1 ? (policy?.capT1Long ?? 0.92) : (policy?.capT2Long ?? 0.17));
262
+ }
166
263
  const rawCap = budget * capPct;
167
264
  const turnMult = policy?.turnoverCapMult ?? 0.15;
168
265
  return (!isT1 && barTurnoverRub > 0) ? Math.max(45000, Math.min(rawCap, barTurnoverRub * turnMult)) : rawCap;
169
266
  }
170
267
 
171
- // Размер новой позиции в рублях до учёта свободного кэша и пропорциональной доли:
172
- // 'slot' — рабочий слот × сила сигнала (conv), не больше предела бумаги;
268
+ // Размер новой позиции в рублях до учёта свободного лимита и итеративного перелива (water-filling):
269
+ // 'slot' — рабочий слот × множитель эшелона × сила сигнала (conv), не больше предела бумаги;
173
270
  // 'risk' — при срабатывании стопа счёт теряет не больше riskPct% бюджета.
174
- export function targetPositionRub({ symbol, side, budget, slotRub, conv = 1, stopPct, barTurnoverRub = 0 }, policy = STRATEGY_POLICY) {
175
- const cap = symbolCapRub(symbol, side, budget, barTurnoverRub, policy);
176
- const sz = policy.sizing || {};
271
+ export function targetPositionRub({ symbol, side, budget, slotRub, conv = 1, stopPct, barTurnoverRub = 0, profile = null }, policy = STRATEGY_POLICY) {
272
+ const cap = symbolCapRub(symbol, side, budget, barTurnoverRub, policy, profile);
273
+ const sz = policy?.sizing || {};
177
274
  if (sz.mode === 'risk' && stopPct > 0) {
178
275
  const byRisk = (budget * (sz.riskPct / 100)) / (stopPct / 100);
179
276
  return Math.min(cap, byRisk * (sz.useConv ? conv : 1), sz.maxSlotPct ? budget * sz.maxSlotPct : Infinity);
180
277
  }
181
- return Math.min(cap, slotRub * conv);
278
+ const isT1 = TIER1_LIQUID_SYMBOLS.has(symbol);
279
+ const legacyMode = policy?.sizeMultT2 !== undefined || policy?.sizeMultT2LongBase !== undefined;
280
+ const slotTierMult = (isT1 || legacyMode)
281
+ ? 1.0
282
+ : (side === 'short' ? (policy?.slotT2ShortMult ?? 0.10) : (policy?.slotT2LongMult ?? 0.25));
283
+ return Math.min(cap, slotRub * slotTierMult * conv);
284
+ }
285
+
286
+ // Итеративное распределение доступного лимита методом перелива (water-filling):
287
+ // бумаги с низким индивидуальным потолком (2-й эшелон / лимит оборота стакана) забирают ровно свой потолок,
288
+ // а весь невыбранный остаток капитала перераспределяется между флагманскими лидерами пропорционально чистой выгоде (weight).
289
+ export function waterFillAllocations(items, availRub) {
290
+ const n = Array.isArray(items) ? items.length : 0;
291
+ const out = new Float64Array(n);
292
+ if (!(availRub > 0) || !n) return out;
293
+ const totalDesired = items.reduce((s, x) => s + Math.max(0, Number(x.targetRub) || 0), 0);
294
+ if (totalDesired <= availRub) {
295
+ for (let k = 0; k < n; k++) out[k] = Math.max(0, Number(items[k].targetRub) || 0);
296
+ return out;
297
+ }
298
+ let remAvail = availRub;
299
+ const open = new Set(items.map((_, idx) => idx));
300
+ while (open.size > 0 && remAvail > 0) {
301
+ let wSum = 0;
302
+ for (const idx of open) wSum += Math.max(1e-6, Number(items[idx].weight) || 0);
303
+ let clampedAny = false;
304
+ for (const idx of [...open]) {
305
+ const share = remAvail * (Math.max(1e-6, Number(items[idx].weight) || 0) / wSum);
306
+ const cap = Math.max(0, Number(items[idx].targetRub) || 0);
307
+ if (cap <= share + 1e-6) {
308
+ out[idx] = cap;
309
+ remAvail = Math.max(0, remAvail - cap);
310
+ open.delete(idx);
311
+ clampedAny = true;
312
+ }
313
+ }
314
+ if (!clampedAny) {
315
+ for (const idx of open) out[idx] = remAvail * (Math.max(1e-6, Number(items[idx].weight) || 0) / wSum);
316
+ break;
317
+ }
318
+ }
319
+ return out;
182
320
  }
183
321
 
184
322
  // ---------- контекст торгового дня бумаги по свечам до момента решения ----------
@@ -41,7 +41,8 @@ export { isMarketDown };
41
41
  // 17: капитал в LQDT как залог, половинный размер 2-го эшелона, строки с плечом 3 и 5
42
42
  // 18: глубина сопровождения позиции и вечернего окна входов holdBars=13 (130 мин) во всех контурах симуляции
43
43
  // 19: ревизия правил 1-го и 2-го эшелонов (MTLR/BSPB/RTKM/SVCB в T1, асимметричный масштаб T2 от плеча, лимит 50%/38% T1 и плотный трал T2)
44
- const DATA_VERSION = 19;
44
+ // 20: разблокировка Conviction Sizing в T1 (до 92%/70% с сублинейным плечом 0,80), прямой риск-лимит T2, перелив water-filling и двухступенчатое потеснение
45
+ const DATA_VERSION = 20;
45
46
 
46
47
  const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
47
48
  function reuseParamsKey(params) {
@@ -822,7 +823,7 @@ async function run(runId, p, state) {
822
823
  const lqdtRows = await ensureLqdtHistory().catch(() => []);
823
824
  const lqdt = lqdtRows.length ? alignSeries(timeline, lqdtRows) : null;
824
825
  const liveRows = [];
825
- for (const lev of [1, 3, 5]) {
826
+ for (const lev of [1, 2, 3, 5]) {
826
827
  const lp = simulateStrategy({ ds, wf: wfLive, p, capital: p.capital, shortable: shortableSet(), divCal, lqdt, leverage: lev });
827
828
  const yrs = Math.max(1 / 365, ((lp.curve.at(-1)?.ts || 0) - (lp.curve[0]?.ts || 0)) / (365.25 * 86400000));
828
829
  const lastLpTs = lp.curve.at(-1)?.ts || 0;
@@ -837,7 +838,7 @@ async function run(runId, p, state) {
837
838
  liveRows.push({
838
839
  key: lev === 1 ? 'liveRules' : `liveRulesL${lev}`,
839
840
  label: lev === 1 ? 'Правила живого контура, без плеча (главная оценка)' : `Правила живого контура, плечо ${lev}`,
840
- note: `исполнение и сопровождение как у реального счёта: входы на любой свече, аварийный стоп и фиксация прибыли внутри свечи, окна сессий, планки, перегрев, паузы, размер позиции и пределы на бумагу, издержки по эшелонам${lqdt ? ', капитал в LQDT как залог' : ''}${lev > 1 ? `; позиции 1-го эшелона ×${lev}` : ''}`,
841
+ note: `исполнение и сопровождение как у реального счёта: входы на любой свече, аварийный стоп и фиксация прибыли внутри свечи, окна сессий, планки, перегрев, паузы, итеративный перелив (water-filling), двухступенчатое потеснение и издержки по эшелонам${lqdt ? ', капитал в LQDT как залог' : ''}${lev > 1 ? `; сублинейный масштаб одиночной позиции при лимите портфеля ×${lev}` : ''}`,
841
842
  full: { returnPct: full.returnPct, cagrPct, profitFactor: full.profitFactor, maxDrawdownPct: full.maxDrawdownPct, trades: full.trades, winRate: full.winRate, avgTradePct: full.avgTradePct, longPnl: round(lp.trades.filter((t) => t.side === 'long').reduce((a, t) => a + t.pnl, 0), 0), shortPnl: round(lp.trades.filter((t) => t.side === 'short').reduce((a, t) => a + t.pnl, 0), 0), afterTaxCagrPct: cagrPct === null ? null : round(cagrPct > 0 ? cagrPct * 0.87 : cagrPct, 1) },
842
843
  costs: null,
843
844
  last12: segOf(lastLpTs - 365 * 86400000),
@@ -17,7 +17,11 @@ import {
17
17
  isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isMarketDown,
18
18
  } from './edge.js';
19
19
  import { nextDividendAt, dividendRules } from './dividends.js';
20
- import { STRATEGY_POLICY, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, exitHoldRatio, tierSizeMult, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
20
+ import {
21
+ STRATEGY_POLICY, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, exitHoldRatio,
22
+ planSizingProfile, convictionMult, tierSizeMult, targetPositionRub, waterFillAllocations,
23
+ counterTrendBlocked, thesisStopPx, dayContext,
24
+ } from './policy.js';
21
25
 
22
26
  // Выравнивание внешнего ряда (свечи IMOEXF, дневные цены LQDT) по шкале датасета с переносом последней цены
23
27
  export function alignSeries(timeline, rows) {
@@ -40,7 +44,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
40
44
  const bpd = Math.max(1, Math.round(ds.barsPerDay || 94));
41
45
  const stepBars = plan === 'owner' || plan === 'vip' ? Math.max(3, Math.floor(holdBars / 5)) : holdBars;
42
46
  const K = policy.K ?? 4;
43
- const dir = p.modelDirection === 'long' ? 'long' : 'longshort';
47
+ const dir = plan === 'investor' || p.modelDirection === 'long' ? 'long' : 'longshort';
44
48
  const realCost = (p.commissionPct + p.slippagePct) / 100;
45
49
  const need = 2.5 * 2 * realCost * (policy.entryEdgeMult ?? ENTRY_EDGE_MULT);
46
50
  const roundTripFeePct = 2 * (p.commissionPct + p.slippagePct);
@@ -50,9 +54,15 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
50
54
  const cooldownBars = Math.round((policy.cooldownMin ?? 30) / BAR_INTERVAL);
51
55
  const stopCooldownBars = Math.round((policy.stopCooldownMin ?? 60) / BAR_INTERVAL);
52
56
  const horizonMin = Math.max(90, holdBars * BAR_INTERVAL);
53
- const slotW = policy.slotPct ?? (plan === 'owner' ? 0.50 : plan === 'vip' ? 0.40 : plan === 'pro' ? Math.min(0.28, 1.15 / 8) : Math.min(0.24, 1.05 / 5));
54
- const convMax = plan === 'owner' ? 1.65 : plan === 'vip' ? 1.30 : 1;
55
- const convK = plan === 'owner' ? 0.30 : 0.25;
57
+ const legacySizing = policy.sizeMultT2 !== undefined || policy.sizeMultT2LongBase !== undefined;
58
+ const profile = legacySizing
59
+ ? {
60
+ slotW: policy.slotPct ?? (plan === 'owner' ? 0.50 : plan === 'vip' ? 0.40 : plan === 'pro' ? Math.min(0.28, 1.15 / 8) : Math.min(0.24, 1.05 / 5)),
61
+ convMax: plan === 'owner' ? 1.65 : plan === 'vip' ? 1.30 : 1,
62
+ convK: plan === 'owner' ? 0.30 : 0.25,
63
+ }
64
+ : planSizingProfile(plan, dir, policy);
65
+ const slotW = profile.slotW;
56
66
  const lev = Math.max(1, Math.min(MAX_LEVERAGE, Number(leverage) || 1));
57
67
  const maxGross = policy.maxGross ?? 0.99 * lev;
58
68
  const capScale = policy.capScale ?? 1;
@@ -69,7 +79,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
69
79
  const curve = [];
70
80
  const cooldownUntil = new Int32Array(N).fill(-1);
71
81
  const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
72
- const stats = { stops: 0, thesis: 0, locks: 0, signalExits: 0, timeExits: 0, night: 0, rotations: 0, regime: 0, div: 0, blockedCT: 0, entries: 0, missedPassive: 0, hedgePnl: 0, hedgeCost: 0, cashYield: 0, exposureSum: 0, exposureN: 0 };
82
+ const stats = { stops: 0, thesis: 0, locks: 0, signalExits: 0, timeExits: 0, night: 0, rotations: 0, trims: 0, regime: 0, div: 0, blockedCT: 0, entries: 0, missedPassive: 0, hedgePnl: 0, hedgeCost: 0, cashYield: 0, exposureSum: 0, exposureN: 0 };
73
83
  let hedgeRub = 0;
74
84
 
75
85
  const divRule = (i, t) => {
@@ -110,6 +120,35 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
110
120
  stockPnl[i] = stockPnl[i] * 0.90 + (g - 2 * c);
111
121
  stockCnt[i] += 1;
112
122
  };
123
+ const trimPos = (i, pos, exitT, exitPx, trimAllocRub, why) => {
124
+ const frac = Math.min(1, Math.max(0, trimAllocRub / pos.alloc));
125
+ if (frac >= 0.999) {
126
+ closePos(i, pos, exitT, exitPx, why, 'rotation');
127
+ stats.rotations++;
128
+ return true;
129
+ }
130
+ const c = costOf(i);
131
+ const g = gainOf(pos, exitPx);
132
+ const sliceAlloc = pos.alloc * frac;
133
+ const sliceEntryFee = pos.entryFee * frac;
134
+ const gross = sliceAlloc * g;
135
+ const fee = (sliceAlloc + (pos.side === 'long' ? gross : -gross)) * c;
136
+ equity += gross - fee;
137
+ if (collectTrades) {
138
+ trades.push({
139
+ symbol: tokens[i].symbol, side: pos.side, entryTs: timeline[pos.entryT], exitTs: timeline[Math.min(exitT, T - 1)],
140
+ entryPrice: pos.entryPx, exitPrice: exitPx, alloc: sliceAlloc,
141
+ pnl: gross - fee - sliceEntryFee, pnlPct: ((gross - fee - sliceEntryFee) / sliceAlloc) * 100,
142
+ commission: fee + sliceEntryFee, barsHeld: (exitT - pos.entryT) * BAR_INTERVAL,
143
+ expectedPct: pos.expectedPct, stopPct: pos.stopPct, reasonExit: why, exitKind: 'trim',
144
+ dayMovePct: pos.dayMovePct, counterTrend: pos.counterTrend, tier: isT1(i) ? 1 : 2,
145
+ });
146
+ }
147
+ pos.alloc -= sliceAlloc;
148
+ pos.entryFee -= sliceEntryFee;
149
+ stats.trims++;
150
+ return false;
151
+ };
113
152
  const mtmUnreal = (t) => {
114
153
  let s = 0;
115
154
  for (const q of held.values()) s += q.alloc * gainOf(q, tokens[q.i].close[t]);
@@ -304,8 +343,84 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
304
343
  let nL = 0, nS = 0;
305
344
  for (const q of held.values()) { if (q.side === 'long') nL++; else nS++; }
306
345
  const budget = equity + mtmUnreal(t);
307
- // ротация остывшей позиции в сильного лидера (на плановом шаге)
308
- if (isStep && (candL.length || candS.length) && grossUsed(t) > budget * maxGross * 0.65) {
346
+ const pickWeight = (i) => (policy.netPickSort !== false ? Math.max(0.0001, Math.abs(edge(i)) - 2 * costOf(i)) : Math.max(0.0001, Math.abs(edge(i))));
347
+ const calcDesiredRub = (i, side, e, stopPct) => {
348
+ const tm = tierSizeMult(tokens[i].symbol, lev, side, policy);
349
+ if (!(tm > 0)) return 0;
350
+ const conv = convictionMult(Math.abs(e) / need, profile);
351
+ return targetPositionRub({
352
+ symbol: tokens[i].symbol, side,
353
+ budget: budget * capScale * tm,
354
+ slotRub: budget * slotW * tm,
355
+ conv, stopPct,
356
+ barTurnoverRub: isT1(i) ? 0 : barTurnover(i, t),
357
+ profile: legacySizing ? null : profile,
358
+ }, policy);
359
+ };
360
+
361
+ // Двухступенчатое динамическое потеснение (ротация остывшей позиции или частичное подрезание T2 под флагман T1)
362
+ let avail = Math.max(0, budget * maxGross - grossUsed(t));
363
+ if (!legacySizing && policy.crowdOut !== false && held.size > 0 && (candL.length || candS.length)) {
364
+ const rawPicks = [
365
+ ...candL.slice(0, Math.max(0, K - nL)).map((i) => [i, 'long']),
366
+ ...candS.slice(0, Math.max(0, K - nS)).map((i) => [i, 'short']),
367
+ ];
368
+ rawPicks.sort((a, b) => pickWeight(b[0]) - pickWeight(a[0]));
369
+ const minAgeBars = Math.round((policy.crowdMinMin ?? 20) / BAR_INTERVAL);
370
+ const minChunkRub = Math.max(45000, budget * (policy.crowdMinPct ?? 0.06));
371
+ const rotBuf = (policy.crowdRotBufPct ?? 0.08) / 100;
372
+ const trimBuf = (policy.crowdTrimBufPct ?? 0.12) / 100;
373
+ const desiredItems = [];
374
+ for (const [i, side] of rawPicks) {
375
+ const e = edge(i);
376
+ const sigmaBarPct = sg[i] * 100;
377
+ const ctx = dayContext(ds, i, t, sigmaBarPct);
378
+ if (counterTrendBlocked(side, ctx, policy)) continue;
379
+ const stopPct = protectiveStopPct(side, { sigmaBarPct, realizedBarPct: ctx.realizedBarPct, horizonBars: holdBars }, policy);
380
+ desiredItems.push({ i, side, e, netEdge: pickWeight(i), targetRub: calcDesiredRub(i, side, e, stopPct) });
381
+ }
382
+ const totalDesired = desiredItems.reduce((s, x) => s + x.targetRub, 0);
383
+ let shortfall = totalDesired - avail;
384
+ const bestT1 = desiredItems.find((x) => isT1(x.i));
385
+ if (shortfall >= minChunkRub && bestT1) {
386
+ const elig = [];
387
+ for (const [hi, q] of held) {
388
+ if (t + 1 - q.entryT < minAgeBars) continue;
389
+ if (!(tokens[hi].open[t + 1] > 0)) continue;
390
+ const curSigned = q.side === 'long' ? edge(hi) : -edge(hi);
391
+ const thrEntry = q.side === 'long' ? Math.max(needL * mult[hi] * tierScale(hi), minEdgeL) : Math.max(needS * mult[hi] * tierScale(hi), minEdgeS);
392
+ elig.push({ hi, q, curSigned, thrEntry, tierRank: isT1(hi) ? 1 : 0 });
393
+ }
394
+ elig.sort((a, b) => (a.tierRank - b.tierRank) || (a.curSigned - b.curSigned));
395
+ for (const el of elig) {
396
+ if (shortfall < minChunkRub * 0.5) break;
397
+ const { hi, q, curSigned, thrEntry } = el;
398
+ const pxExit = fill === 'mid' ? (tokens[hi].open[t + 1] + tokens[hi].close[t + 1]) / 2 : tokens[hi].open[t + 1];
399
+ const heldIsT1 = isT1(hi);
400
+ const cooled = curSigned < thrEntry * (heldIsT1 ? (policy.crowdCoolRatioT1 ?? 0.45) : (policy.crowdCoolRatioT2 ?? 0.80));
401
+ const netAdv = bestT1.netEdge - Math.max(0, curSigned);
402
+ if (cooled && netAdv >= rotBuf) {
403
+ if (!heldIsT1 || curSigned < thrEntry * 0.35 || q.alloc - shortfall < minChunkRub) {
404
+ closePos(hi, q, t + 1, pxExit, 'Ротация в сильный сигнал', 'rotation');
405
+ stats.rotations++;
406
+ if (q.side === 'long') nL--; else nS--;
407
+ } else {
408
+ const trimAmt = Math.min(shortfall, q.alloc - minChunkRub);
409
+ if (trimAmt >= minChunkRub) trimPos(hi, q, t + 1, pxExit, trimAmt, 'Частичное потеснение под сильный сигнал');
410
+ }
411
+ avail = Math.max(0, budget * maxGross - grossUsed(t));
412
+ shortfall = totalDesired - avail;
413
+ } else if (!cooled && !heldIsT1 && netAdv >= trimBuf && q.alloc >= minChunkRub * 1.5) {
414
+ const trimAmt = Math.min(shortfall, q.alloc - minChunkRub);
415
+ if (trimAmt >= minChunkRub) {
416
+ trimPos(hi, q, t + 1, pxExit, trimAmt, 'Частичное потеснение под сильный сигнал');
417
+ avail = Math.max(0, budget * maxGross - grossUsed(t));
418
+ shortfall = totalDesired - avail;
419
+ }
420
+ }
421
+ }
422
+ }
423
+ } else if (legacySizing && isStep && (candL.length || candS.length) && grossUsed(t) > budget * maxGross * 0.65) {
309
424
  const best = Math.max(candL.length ? edge(candL[0]) : 0, candS.length ? -edge(candS[0]) : 0);
310
425
  if (best >= need * 2.2) {
311
426
  let wi = -1, we = Infinity;
@@ -321,28 +436,42 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
321
436
  }
322
437
  }
323
438
  }
439
+
324
440
  const picks = [
325
441
  ...candL.slice(0, Math.max(0, K - nL)).map((i) => [i, 'long']),
326
442
  ...candS.slice(0, Math.max(0, K - nS)).map((i) => [i, 'short']),
327
443
  ];
328
- const pickWeight = (i) => (policy.netPickSort !== false ? Math.max(0.0001, Math.abs(edge(i)) - 2 * costOf(i)) : Math.max(0.0001, Math.abs(edge(i))));
329
444
  picks.sort((a, b) => pickWeight(b[0]) - pickWeight(a[0]));
330
- let avail = Math.max(0, budget * maxGross - grossUsed(t));
331
- let remW = picks.reduce((s, [i]) => s + pickWeight(i), 0);
445
+ avail = Math.max(0, budget * maxGross - grossUsed(t));
446
+ const prepared = [];
332
447
  for (const [i, side] of picks) {
333
448
  const e = edge(i);
334
- const w = pickWeight(i);
335
- const propShare = remW > 0 ? avail * (w / remW) : avail;
336
- remW -= w;
337
449
  const sigmaBarPct = sg[i] * 100;
338
450
  const ctx = dayContext(ds, i, t, sigmaBarPct);
339
451
  if (counterTrendBlocked(side, ctx, policy)) { stats.blockedCT++; continue; }
340
452
  const stopPct = protectiveStopPct(side, { sigmaBarPct, realizedBarPct: ctx.realizedBarPct, horizonBars: holdBars }, policy);
341
- const conv = Math.min(convMax, Math.max(0.85, 0.80 + convK * (Math.abs(e) / need)));
342
- const tm = tierSizeMult(tokens[i].symbol, lev, side, policy);
343
- if (!(tm > 0)) continue;
344
- const target = targetPositionRub({ symbol: tokens[i].symbol, side, budget: budget * capScale * tm, slotRub: budget * slotW * tm, conv: convMax > 1 ? conv : 1, stopPct, barTurnoverRub: isT1(i) ? 0 : barTurnover(i, t) }, policy);
345
- const alloc = Math.min(target, propShare, avail);
453
+ const targetRub = calcDesiredRub(i, side, e, stopPct);
454
+ if (!(targetRub > 0)) continue;
455
+ prepared.push({ i, side, e, weight: pickWeight(i), targetRub, stopPct, ctx });
456
+ }
457
+ const allocs = (!legacySizing && policy.waterFill !== false)
458
+ ? waterFillAllocations(prepared, avail)
459
+ : (() => {
460
+ const out = new Float64Array(prepared.length);
461
+ let a = avail;
462
+ let remW = prepared.reduce((s, x) => s + x.weight, 0);
463
+ for (let idx = 0; idx < prepared.length; idx++) {
464
+ const propShare = remW > 0 ? a * (prepared[idx].weight / remW) : a;
465
+ remW -= prepared[idx].weight;
466
+ const al = Math.min(prepared[idx].targetRub, propShare, a);
467
+ out[idx] = al;
468
+ a -= al;
469
+ }
470
+ return out;
471
+ })();
472
+ for (let idx = 0; idx < prepared.length; idx++) {
473
+ const { i, side, e, stopPct, ctx } = prepared[idx];
474
+ const alloc = Math.min(allocs[idx], avail);
346
475
  if (!(alloc > 5000)) continue;
347
476
  const tk = tokens[i];
348
477
  let entryPx = fill === 'mid' ? (tk.open[t + 1] + tk.close[t + 1]) / 2 : tk.open[t + 1];
@@ -260,12 +260,13 @@ ${liveTradingSummary}
260
260
  - Базовый порог выгоды на вход = 2,5 × 2 × (комиссия + проскальзывание) × 1,60. Для 1-го эшелона при настройке издержек 0,05% базовый порог составляет **+0,40%** (с тонкой поправкой symbolEdgeMult на оборот и волатильность бумаги в коридоре 0,88..1,45).
261
261
  - Для 2–3-го эшелона порог входа повышен в **1,20× (TIER2_EDGE_MULT)** — до **~+0,48%**, чтобы ожидаемый импульс с запасом перекрывал более широкий спред стакана. В первые 20 минут утренней сессии (07:00–07:20 МСК) шорты 2-го эшелона блокируются из-за пустого утреннего стакана.
262
262
  - Авто-балансировка по тренду индекса (isMarketDown): на растущем рынке покупки открываются от базового порога (+0,40% 1-й эшелон и +0,48% 2-й эшелон), а контртрендовые шорты ужесточаются в 1,25× (до −0,50% и −0,60%). На падающем рынке — наоборот: покупки ужесточаются в 1,25×, а шорты облегчаются в 0,90×. Всегда называй пользователю точный индивидуальный порог конкретной бумаги (\`порог_покупки_проц\` / \`порог_шорта_проц\`).
263
- - **Размер позиций, плечо счёта и приоритет аллокации (policy.js):**
264
- - **Dynamic Top-K и Conviction Sizing:** робот держит до 4 лонгов и до 4 шортов. Базовый слот в режиме владельца — 50% бюджета, масштабируется на силу сигнала conv (от 0,85× до 1,65×).
265
- - **Масштабирование по эшелонам и плечу (tierSizeMult и symbolCapRub):**
266
- 1. В **1-м эшелоне** размер позиции умножается на полное плечо счёта (maxLeverage = 1×..7×), а потолок концентрации на одну бумагу составляет **50% в лонг и 38% в шорт** (умноженные на плечо).
267
- 2. Во **2-м эшелоне** действует асимметричное масштабирование от плеча счёта: для **лонга** множитель равен max(0,50, 0,38 × плечо) (на плече 2× → 0,76×, на 3× → 1,14×), а для более рискованного **шорта** — max(0,35, 0,22 × плечо) (на 1× → 0,35×, на 2× → 0,44×, на 3× → 0,66×). Предел на одну бумагу 2-го эшелона — 22% в лонг и 18% в шорт, но **не более 15% от реального медианного 10-минутного оборота стакана (barTurnoverRub)**, чтобы заявка никогда не двигала цену против себя.
268
- - **Приоритет распределения капитала (netPickSort):** кандидаты в слоты отбираются по силе z-скора прогноза, а очерёдность и доля капитала между отобранными сделками определяются по **чистой ожидаемой отдаче за вычетом круговых издержек эшелона** (|expectedPct| − 0,13% для 1-го эшелона и |expectedPct| − 0,25% для 2-го эшелона).
263
+ - **Размер позиций, сублинейное плечо, перелив капитала и динамическое потеснение (policy.js):**
264
+ - **Живой Conviction Sizing без холостого резерва:** робот держит до 4 лонгов и до 4 шортов, но поскольку 81,5% активного времени на рынке есть ровно 1 сигнал, стратегия не замораживает половину лимита «на всякий случай». В режиме владельца базовый слот 1-го эшелона составляет 56% бюджета и масштабируется на силу сигнала conv (от 0,85× до 1,92×), разгоняя сильные одиночные импульсы до **92% бюджета в лонг и до 70% в шорт** на счёте 1× (на VIP — до 75% / 56%, на Профи — до 52% / 40%, на Инвесторе — до 58% в лонг).
265
+ - **Сублинейное масштабирование одиночной позиции по плечу (tierSizeMult):**
266
+ 1. В **1-м эшелоне** потолок одной бумаги растёт с плечом счёта сублинейно по формуле 1 + 0,80 × (плечо − 1) (на 1× → 1,00×, на 2× → 1,80×, на 3× → 2,60× при общем лимите портфеля 2×..3× капитала). Это удерживает риск одной бумаги под контролем, а разницу между лимитом бумаги и общим плечом счёта превращает в бесплатный маржинальный резерв под 2-ю и 3-ю параллельные сделки под залог LQDT.
267
+ 2. Во **2-м эшелоне** действует прямой риск-потолок без двойного штрафа: до **17% бюджета в лонг** (масштаб плеча 1 + 0,72 × (плечо − 1)) и до **7% в шорт** (1 + 0,40 × (плечо − 1)), под жёстким отсечением по реальной ликвидности биржи — **не более 15% от медианного 10-минутного оборота свечи (barTurnoverRub)** (минимум 45 000 ₽).
268
+ - **Итеративный перелив капитала (waterFillAllocations) и чистая отдача (netPickSort):** очерёдность и веса между отобранными сделками определяются по чистой ожидаемой отдаче за вычетом круговых издержек (|expectedPct| − 0,13% для 1-го эшелона и |expectedPct| − 0,25% для 2-го). При одновременном входе бумаги с узким лимитом (2-й эшелон / тонкий стакан) забирают ровно свой потолок, а весь невыбранный остаток переливается флагманским лидерам 1-го эшелона.
269
+ - **Двухступенчатое динамическое потеснение (crowdOut):** если свободного лимита не хватает под новый сильный сигнал 1-го эшелона (не моложе 20 минут жизни уже открытых сделок), робот освобождает место по факту: (1) полностью ротирует остывшую позицию либо (2) частично подрезает позицию 2-го эшелона под флагман 1-го эшелона с сохранением времени входа entryTs и накопленного пика peakPrice для трала Profit Lock (на счетах auto работает и на быстрых 20-секундных входах, на confirm — на плановых 30-минутных шагах).
269
270
  - **Капитал в фонде денежного рынка LQDT как залог:** свободный капитал работает в LQDT, а внутридневные позиции открываются под обеспечение и закрываются до ночи (без платы брокеру за перенос через ночь).
270
271
  - **Глубина сопровождения позиций (130 минут), фиксация прибыли и защитные контуры:**
271
272
  - **Глубина сопровождения (holdBars = 13 свечей = 130 минут = 2 ч 10 м):** плановая ревизия портфеля идёт каждые 20–30 минут (holdBars / 5), а вечернее окно новых внутридневных входов закрывается за 130 минут до конца торгов (после **21:30 МСК** новые входы не открываются, перед 23:40–23:50 МСК позиции закрываются перед ночью).