neuroalpha 0.3.16 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,6 @@ MOEX ISS: ~100 акций MOEXBMI + IMOEX, 10-минутные свечи + жи
56
56
  - **Новости рынка** — лента новостей и корпоративных раскрытий с ИИ-оценкой тональности и влияния на конкретные тикеры.
57
57
  3. **Управление и настройки:**
58
58
  - **Эксперименты** *(доступен эксклюзивно в режиме «Хозяин»)* — Walk-Forward проверка ядра и управление витриной.
59
- - **Издержки и новости** — настройка комиссии брокера, проскальзывания, ставки за шорт и новостного фильтра.
60
59
  - **Подписка и уведомления** — управление тарифом, привязка ботов Telegram и MAX для подтверждения заявок в 1 клик.
61
60
  - **Модели ИИ** — подключение провайдеров ИИ для Стратега (OpenAI-совместимые, GigaChat с сертификатом НУЦ Минцифры, Ollama).
62
61
 
@@ -29,10 +29,9 @@
29
29
  - **Брокерские счета** — управление всеми подключёнными счетами (боевыми и тестовыми).
30
30
  2. **Аналитика рынка:**
31
31
  - **Прогноз сейчас** — живой радар ожиданий по 100 акциям Мосбиржи.
32
- - **Новости рынка** — свежие новости и раскрытия компаний с оценкой влияния ИИ.
32
+ - **Новости рынка** — свежие новости и раскрытия компаний с оценкой влияния ИИ и переключателем «Блокировать сделки против важных свежих новостей».
33
33
  3. **Управление и настройки:**
34
34
  - **Эксперименты** *(виден и доступен эксклюзивно в режиме «Хозяин»)* — исторические Walk-Forward исследования модели.
35
- - **Издержки и новости** — настройка брокерской комиссии, проскальзывания, ставки за шорт и новостного фильтра.
36
35
  - **Подписка и уведомления** — выбор тарифа и привязка ботов **Telegram / MAX**.
37
36
  - **Модели ИИ** — подключение и выбор языковых моделей для Стратега.
38
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -165,8 +165,7 @@ function updateTabBadges() {
165
165
  }
166
166
 
167
167
  function showMainSec(sec) {
168
- if (sec === 'now') sec = 'brokers';
169
- if (sec === 'settings' || sec === 'g1') sec = 'g0';
168
+ if (sec === 'now' || sec === 'settings' || sec === 'g0' || sec === 'g1') sec = 'brokers';
170
169
  if (sec === 'overview' && !isOwnerUser()) sec = 'brokers';
171
170
  document.querySelectorAll('#main-nav [data-sec]').forEach((b) => b.classList.toggle('active', b.dataset.sec === sec));
172
171
  document.querySelectorAll('#main-workspace .ssec').forEach((el) => { el.hidden = el.dataset.sec !== sec; });
@@ -391,35 +390,8 @@ function syncUiAvailability() {
391
390
  }
392
391
 
393
392
  function syncSettingsDependencies() {
394
- const form = $('#params');
395
- if (!form || !state.settings) return;
396
- const s = state.settings;
397
- const autoOn = s.modelAutoAdapt !== false;
398
- const dir = s.modelDirection || 'long';
399
-
400
- const setRowEnabled = (key, enabled, whyDisabled = '') => {
401
- const row = form.querySelector(`.srow[data-key="${key}"]`);
402
- if (!row) return;
403
- row.style.opacity = enabled ? '1' : '0.45';
404
- const ctrl = form.elements[key];
405
- if (ctrl) {
406
- ctrl.disabled = !enabled;
407
- ctrl.title = enabled ? '' : whyDisabled;
408
- }
409
- };
410
-
411
- setRowEnabled('modelHorizon', !autoOn, 'Управляется автоматически при включённом «Самообучении» (мульти-горизонт 1,5–3,5 ч)');
412
- setRowEnabled('modelMinEdge', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
413
- setRowEnabled('modelExitRatio', !autoOn, 'Управляется автоматически при включённом «Самообучении»');
414
- setRowEnabled('modelShortFeePct', dir !== 'long', 'Действует только когда включены короткие продажи («В рост и в падение (с шортом)»)');
415
- setRowEnabled('modelMarketFilter', true, '');
416
-
417
- const doneCount = (state.runs || []).filter((r) => r.status !== 'running').length;
418
- if ($('#btn-clean')) $('#btn-clean').disabled = doneCount < 1;
419
- if ($('#btn-emb-auto')) {
420
- const isAutoEmb = Number(s.modelEmbedDim ?? 0) === 0;
421
- $('#btn-emb-auto').classList.toggle('primary', isAutoEmb);
422
- $('#btn-emb-auto').textContent = isAutoEmb ? '✓ Авто (0)' : '⚡ Авто (0)';
393
+ if ($('#chk-news-filter') && state.settings) {
394
+ $('#chk-news-filter').checked = Boolean(state.settings.newsFilter);
423
395
  }
424
396
  }
425
397
  function syncShowcaseBtn() {
@@ -962,7 +934,7 @@ async function loadForecast(force, silent = false) {
962
934
  };
963
935
  const row = (r) => `<tr>
964
936
  <td><b>${r.symbol}</b> <span class="muted small">${esc(r.name)}</span>${rankBadge(r)}${dot(r.news)}</td>
965
- <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, 2)} ₽` : '—'}</td>
937
+ <td class="num" style="white-space:nowrap">${r.price ? `${fmtNum(r.price, r.price < 1 ? 5 : r.price < 10 ? 3 : 2)} ₽` : '—'}</td>
966
938
  <td class="num ${cls(r.expectedPct)}" style="white-space:nowrap"><b>${fmtPct(r.expectedPct, 2)}</b></td>
967
939
  <td class="num" style="white-space:nowrap">${arrowOf(r)}</td>
968
940
  <td style="text-align:center;white-space:nowrap">${sigBadge(r)}</td>
@@ -1010,43 +982,14 @@ async function loadNews() {
1010
982
  }
1011
983
 
1012
984
  // ======================================================================
1013
- // НАСТРОЙКИ (ИЗДЕРЖКИ И НОВОСТИ)
985
+ // НАСТРОЙКА ФИЛЬТРА НОВОСТЕЙ
1014
986
  // ======================================================================
1015
- const GROUPS = [
1016
- ['Издержки брокера и новости', ['commissionPct', 'slippagePct', 'modelShortFeePct', 'newsFilter']],
1017
- ];
1018
- function buildForm() {
1019
- const field = (p) => {
1020
- let input;
1021
- if (p.type === 'select') input = `<select name="${p.key}" class="sctrl">${p.options.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('')}</select>`;
1022
- else if (p.type === 'checkbox') input = `<input type="checkbox" name="${p.key}" class="switch">`;
1023
- else input = `<input type="number" step="${p.step ?? 'any'}" name="${p.key}" min="${p.min}" max="${p.max}" class="sctrl">`;
1024
- return `<div class="srow" data-key="${p.key}"><div class="stxt"><div class="slbl">${esc(p.label)}<button type="button" class="mark" data-def="${p.key}" hidden title="Отличается от умолчания — нажмите, чтобы вернуть">изменено</button></div><div class="hint">${esc(p.hint)}</div></div>${input}</div>`;
1025
- };
1026
- const shown = state.config.params.filter((p) => !p.hidden);
1027
- const curSec = document.querySelector('#main-nav [data-sec].active')?.dataset.sec || 'brokers';
1028
- $('#params').innerHTML = GROUPS.map(([name, keys], i) => {
1029
- const items = keys.map((k) => shown.find((p) => p.key === k)).filter(Boolean);
1030
- const secKey = `g${i}`;
1031
- return items.length ? `<section class="ssec" data-sec="${secKey}"${curSec === secKey ? '' : ' hidden'}><div class="stitle">${name}</div><div class="scard">${items.map(field).join('')}</div></section>` : '';
1032
- }).join('');
1033
- }
987
+ function buildForm() {}
1034
988
  function fillForm(s) {
1035
- for (const el of $('#params').elements) {
1036
- if (!el.name || el === document.activeElement) continue;
1037
- if (el.type === 'checkbox') el.checked = !!s[el.name];
1038
- else el.value = s[el.name];
1039
- }
1040
- markDefaults(s);
1041
- }
1042
- // Метка «изменено» у параметров, отличающихся от умолчания
1043
- function markDefaults(s) {
1044
- const d = state.config?.defaults || {};
1045
- $('#params').querySelectorAll('[data-def]').forEach((b) => {
1046
- b.hidden = JSON.stringify(s[b.dataset.def]) === JSON.stringify(d[b.dataset.def]);
1047
- });
989
+ if ($('#chk-news-filter') && s) {
990
+ $('#chk-news-filter').checked = Boolean(s.newsFilter);
991
+ }
1048
992
  syncApplyBtn();
1049
- syncSettingsDependencies();
1050
993
  updateTabBadges();
1051
994
  }
1052
995
  const saveSettings = async (body) => {
@@ -1054,28 +997,30 @@ const saveSettings = async (body) => {
1054
997
  const r = await api('/api/settings', { method: 'PUT', body });
1055
998
  if (r.errors?.length) toast(r.errors.join('; '), 'error');
1056
999
  state.settings = r.settings;
1057
- markDefaults(state.settings);
1000
+ fillForm(state.settings);
1058
1001
  } catch (e) { toast(e.message, 'error'); }
1059
1002
  };
1060
1003
  const saveSetting = (k, v) => saveSettings({ [k]: v });
1061
- $('#params').addEventListener('change', (e) => e.target.name && saveSetting(e.target.name, e.target.type === 'checkbox' ? e.target.checked : e.target.value));
1062
- // клик по метке «изменено» — вернуть параметр к умолчанию
1063
- $('#params').addEventListener('click', async (e) => {
1064
- const b = e.target.closest('[data-def]');
1065
- if (!b) return;
1066
- await saveSetting(b.dataset.def, state.config.defaults[b.dataset.def]);
1067
- fillForm(state.settings);
1004
+ $('#chk-news-filter')?.addEventListener('change', (e) => {
1005
+ saveSetting('newsFilter', e.target.checked);
1068
1006
  });
1069
1007
 
1070
1008
  // ======================================================================
1071
1009
  // ЧАТ СО СТРАТЕГОМ
1072
1010
  // ======================================================================
1073
1011
  const TOOL_LABELS = {
1074
- get_settings: 'Смотрит настройки', list_runs: 'Смотрит список исследований', analyze_run: 'Разбирает исследование', compare_runs: 'Сравнивает исследования',
1075
- run_model_research: 'Исследование стратегии', model_forecast: 'Смотрит прогноз', market_neighbors: 'Смотрит соседей на карте',
1076
- journal_list: 'Читает журнал', journal_add: 'Запись в журнал', final_check: 'Открывает контрольный период',
1077
- set_settings: 'Изменение настроек', paper_start: 'Запуск живой проверки', paper_stop: 'Остановка живой проверки',
1078
- broker_onboarding_guide: 'Смотрит инструкцию по брокеру', discover_broker_accounts: 'Ищет субсчета по токену', connect_broker_account: 'Подключение брокерского счёта',
1012
+ get_brokers_status: 'Смотрит брокерские счета и портфель',
1013
+ get_settings: 'Смотрит настройки издержек',
1014
+ list_runs: 'Смотрит список исследований',
1015
+ analyze_run: 'Разбирает исследование',
1016
+ compare_runs: 'Сравнивает исследования',
1017
+ model_forecast: 'Смотрит живой прогноз',
1018
+ get_news: 'Смотрит свежие новости',
1019
+ market_neighbors: 'Смотрит соседей на карте',
1020
+ set_settings: 'Изменение настроек',
1021
+ broker_onboarding_guide: 'Смотрит инструкцию по брокеру',
1022
+ discover_broker_accounts: 'Ищет субсчета по токену',
1023
+ connect_broker_account: 'Подключение брокерского счёта',
1079
1024
  };
1080
1025
  const STATUS = { pending: 'ждёт вашего решения', approved: 'подтверждено', running: 'выполняется…', done: 'готово', error: 'не получилось', rejected: 'отклонено' };
1081
1026
  const feed = $('#feed');
@@ -1252,34 +1197,28 @@ function renderChatState() {
1252
1197
  const s = state.chat.state;
1253
1198
  if (!s) return;
1254
1199
  const noTools = s.tools === false;
1255
- const curMode = noTools ? 'discuss' : (s.mode in MODE_META ? s.mode : 'advisor');
1256
- const mm = MODE_META[curMode] || MODE_META.advisor;
1257
- if ($('#chat-mode-ico')) $('#chat-mode-ico').innerHTML = mm.svg;
1258
- if ($('#chat-mode-lbl')) $('#chat-mode-lbl').textContent = mm.label;
1259
- if ($('#chat-mode-btn')) $('#chat-mode-btn').title = noTools ? 'Выбранная модель не умеет пользоваться инструментами — Стратег только беседует' : MODE_TITLE;
1260
- document.querySelectorAll('#mode-menu [data-mode-val]').forEach((b) => {
1261
- const v = b.dataset.modeVal;
1262
- b.disabled = noTools && v !== 'discuss';
1263
- b.classList.toggle('active', v === curMode);
1264
- });
1265
1200
  $('#chat-stop').hidden = !s.busy && !s.waiting;
1266
1201
  syncActionBtn();
1267
1202
  let st = '';
1268
- if (!s.llm) st = 'ИИ не подключён: выберите модель и ключ (Настройки → Модели ИИ)';
1269
- else if (s.waiting) st = 'Стратег ждёт вашего решения (или просто напишите ответ)';
1203
+ if (!s.llm) st = 'ИИ не подключён: выберите модель и ключ (Модели ИИ)';
1204
+ else if (s.waiting) st = 'Стратег ждёт вашего подтверждения (или напишите ответ)';
1270
1205
  else if (s.busy) {
1271
1206
  const m = [...state.chat.msgs.values()].reverse().find((x) => x.tool_calls && Object.values(x.data?.tools || {}).some((t) => t.status === 'running'));
1272
1207
  const c = m?.tool_calls.find((x) => m.data.tools[x.id]?.status === 'running');
1273
1208
  st = c ? `${TOOL_LABELS[c.function.name] || 'Работает'}…` : 'Думает…';
1274
- if (s.mode === 'autopilot') st += ` · осталось исследований: ${s.budgetLeft}`;
1275
- } else if (noTools) st = 'У этой модели нет инструментов: Стратег только беседует';
1209
+ } else if (noTools) st = 'У этой модели нет инструментов: Стратег отвечает по контексту';
1276
1210
  $('#chat-status').textContent = st;
1277
1211
  for (const m of state.chat.msgs.values()) if (m.tool_calls) upsertMsg(m);
1278
1212
  renderSuggest();
1279
1213
  syncUiAvailability();
1280
1214
  }
1281
1215
 
1282
- const SUGGEST = ['Объясни идею простыми словами', 'Исследуй стратегию и объясни результат', 'Что стратегия ждёт в ближайшие часы?', 'Найди, как улучшить стратегию'];
1216
+ const SUGGEST = [
1217
+ 'Помоги подключить брокерский счёт',
1218
+ 'Что стратегия ждёт в ближайшие часы?',
1219
+ 'Разбери мой портфель и текущие заявки',
1220
+ 'Какие сейчас важные новости по рынку?',
1221
+ ];
1283
1222
  function renderSuggest() {
1284
1223
  const has = [...state.chat.msgs.values()].some((m) => m.kind === 'user');
1285
1224
  $('#suggest').innerHTML = has || state.chat.state?.busy ? '' : SUGGEST.map((s) => `<button>${esc(s)}</button>`).join('');
@@ -1312,31 +1251,12 @@ function closeModeMenu() {
1312
1251
  if (m) m.hidden = true;
1313
1252
  }
1314
1253
 
1315
- $('#chat-mode-btn')?.addEventListener('click', (e) => {
1316
- e.stopPropagation();
1317
- closeModelMenu();
1318
- const m = $('#mode-menu');
1319
- if (m) m.hidden = !m.hidden;
1320
- });
1321
-
1322
- $('#mode-menu')?.addEventListener('click', async (e) => {
1323
- const b = e.target.closest('button[data-mode-val]');
1324
- if (!b || b.disabled) return;
1325
- closeModeMenu();
1326
- try {
1327
- state.chat.state = await post('/api/chat/mode', { mode: b.dataset.modeVal });
1328
- renderChatState();
1329
- } catch (err) { toast(err.message, 'error'); }
1330
- });
1331
-
1332
1254
  async function sendChat(text) {
1333
1255
  if (speechRec && speechActive) stopVoiceInput(false);
1334
1256
  text = (text ?? $('#chat-input').value).trim();
1335
1257
  if (!text) return;
1336
1258
  try {
1337
- const s = state.chat.state;
1338
- const mode = s?.tools === false ? 'discuss' : (s?.mode || 'advisor');
1339
- await post('/api/chat/send', { text, mode });
1259
+ await post('/api/chat/send', { text, mode: 'advisor' });
1340
1260
  $('#chat-input').value = '';
1341
1261
  resizeChatInput();
1342
1262
  toBottom();
@@ -2815,7 +2735,6 @@ function connectWs() {
2815
2735
  state.settings = m.settings;
2816
2736
  fillForm(m.settings);
2817
2737
  if (m.source === 'ai') {
2818
- for (const k of Object.keys(m.changed || {})) { const l = $('#params').elements[k]?.closest('.srow, label'); if (l) { l.classList.remove('flash'); void l.offsetWidth; l.classList.add('flash'); } }
2819
2738
  toast(`Стратег изменил настройки: ${Object.entries(m.changed).map(([k, v]) => `${labelOf(k)} ${v.было} → ${v.стало}`).join('; ')}`);
2820
2739
  }
2821
2740
  break;
@@ -3204,7 +3123,7 @@ document.addEventListener('keydown', (e) => {
3204
3123
  }
3205
3124
  if (urlParams.get('run') && isOwnerUser()) {
3206
3125
  showMainSec('overview');
3207
- } else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'g0', 'cloud', 'llm'].includes(savedNav.mainSec)) {
3126
+ } else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'cloud', 'llm'].includes(savedNav.mainSec)) {
3208
3127
  showMainSec(savedNav.mainSec);
3209
3128
  } else {
3210
3129
  showMainSec('brokers');
package/public/index.html CHANGED
@@ -30,7 +30,7 @@
30
30
  <b>Стратег</b>
31
31
  <span id="chat-status" class="muted small"></span>
32
32
  <span class="grow"></span>
33
- <button id="chat-new" class="icon small" title="Начать новый разговор (журнал гипотез сохранится)">+ Новый диалог</button>
33
+ <button id="chat-new" class="icon small" title="Начать новый диалог со Стратегом">+ Новый диалог</button>
34
34
  <button id="chat-hide" class="icon small" title="Свернуть панель чата со Стратегом">◀</button>
35
35
  </div>
36
36
  <div class="feed-wrap">
@@ -40,30 +40,10 @@
40
40
  <div class="composer">
41
41
  <div id="suggest" class="suggest"></div>
42
42
  <div class="prompt-box" id="prompt-box">
43
- <textarea id="chat-input" rows="2" placeholder="Ответьте агенту…"></textarea>
43
+ <textarea id="chat-input" rows="2" placeholder="Спросите Стратега о прогнозе, портфеле или настройках счёта…"></textarea>
44
44
  <div class="prompt-bar">
45
45
  <div class="prompt-left">
46
- <div class="mode-picker" id="mode-picker">
47
- <button type="button" id="chat-mode-btn" class="ghost-select" title="Режим работы Стратега">
48
- <span id="chat-mode-ico" class="g-ico"></span>
49
- <span id="chat-mode-lbl">Авто</span>
50
- <svg class="caret-svg" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.5L6 7.5L9 4.5"/></svg>
51
- </button>
52
- <div id="mode-menu" class="pop-menu mode-menu" hidden>
53
- <button type="button" class="model-opt" data-mode-val="autopilot">
54
- <span class="m-name">⚡ Авто</span>
55
- <span class="muted small">сам ставит эксперименты</span>
56
- </button>
57
- <button type="button" class="model-opt" data-mode-val="advisor">
58
- <span class="m-name">✋ С согласия</span>
59
- <span class="muted small">спрашивает перед запуском</span>
60
- </button>
61
- <button type="button" class="model-opt" data-mode-val="discuss">
62
- <span class="m-name">💬 Обсуждение</span>
63
- <span class="muted small">только анализ и беседа</span>
64
- </button>
65
- </div>
66
- </div>
46
+ <span class="muted small" style="padding:0 6px;user-select:none">🤖 ИИ-консультант и помощник по счетам</span>
67
47
  </div>
68
48
  <div class="prompt-right">
69
49
  <div class="model-picker" id="model-picker">
@@ -109,7 +89,6 @@
109
89
  <button type="button" data-sec="news" id="nnav-news">Новости рынка</button>
110
90
  <div class="ssep"></div>
111
91
  <button type="button" data-sec="overview" id="nnav-overview" hidden>Эксперименты</button>
112
- <button type="button" data-sec="g0" id="snav-costs">Издержки и новости</button>
113
92
  <button type="button" data-sec="cloud" id="snav-cloud">Подписка и уведомления</button>
114
93
  <button type="button" data-sec="llm" id="snav-llm">Модели ИИ</button>
115
94
  </aside>
@@ -199,7 +178,13 @@
199
178
  </section>
200
179
 
201
180
  <section class="ssec" data-sec="news" id="news-box" hidden>
202
- <div class="stitle">Новости и раскрытия компаний</div>
181
+ <div class="stitle bar">
182
+ <span>Новости и раскрытия компаний</span>
183
+ <label class="row-gap small" style="cursor:pointer;font-weight:500;color:var(--text)" title="Не покупать бумагу на сильном негативе и не шортить на сильном позитиве за последние 6 часов">
184
+ <span>Блокировать сделки против важных свежих новостей</span>
185
+ <input id="chk-news-filter" type="checkbox" class="switch">
186
+ </label>
187
+ </div>
203
188
  <div class="scard spadded">
204
189
  <div id="news-list" class="muted">Свежих размеченных новостей пока нет.</div>
205
190
  </div>
@@ -255,8 +240,6 @@
255
240
  </div>
256
241
  </section>
257
242
 
258
- <form id="params"></form>
259
-
260
243
  <section class="ssec" data-sec="cloud" id="cloud-box" hidden>
261
244
  <div class="stitle">Подписка, лицензия и уведомления (Telegram / MAX)</div>
262
245
  <div class="scard spadded" id="cloud-pane"></div>
@@ -382,7 +382,7 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
382
382
  async function ensureTbankShortableMap(acc) {
383
383
  if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
384
384
  const cached = kvGet('tbankShortableMap');
385
- if (cached?.v === 3 && cached?.map && Date.now() - cached.at < 12 * 3600000) {
385
+ if (cached?.v === 4 && cached?.map && Date.now() - cached.at < 12 * 3600000) {
386
386
  if (cached.uidMap) {
387
387
  for (const [sym, uid] of Object.entries(cached.uidMap)) tbankUidCache.set(sym, uid);
388
388
  }
@@ -413,9 +413,7 @@ async function ensureTbankShortableMap(acc) {
413
413
  }
414
414
  const minStep = qVal(inst.minPriceIncrement);
415
415
  if (minStep > 0) {
416
- const stepStr = String(minStep);
417
- const dotIdx = stepStr.indexOf('.');
418
- const decimals = dotIdx >= 0 ? Math.min(6, stepStr.length - dotIdx - 1) : 0;
416
+ const decimals = Math.max(0, Math.min(6, Math.ceil(-Math.log10(minStep) - 1e-9)));
419
417
  specMap[inst.ticker] = {
420
418
  lotSize: Math.max(1, Number(inst.lot) || 1),
421
419
  minStep,
@@ -424,7 +422,7 @@ async function ensureTbankShortableMap(acc) {
424
422
  }
425
423
  }
426
424
  }
427
- kvSet('tbankShortableMap', { v: 3, at: Date.now(), map, uidMap, specMap });
425
+ kvSet('tbankShortableMap', { v: 4, at: Date.now(), map, uidMap, specMap });
428
426
  return map;
429
427
  } catch {
430
428
  return cached?.map || null;
@@ -1171,10 +1169,10 @@ export async function prepareBrokerOrders(id) {
1171
1169
  const isOwnerPlan = Boolean(plan.isOwner || plan.id === 'owner' || plan.maxAnnualReturnPct === 0);
1172
1170
  let slotRub;
1173
1171
  if (isOwnerPlan && autoAdapt) {
1174
- const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
1172
+ const maxSlotWeight = Math.min(0.45, Math.max(1 / K, 1.8 / K));
1175
1173
  slotRub = budget * maxSlotWeight;
1176
1174
  } else if (vipBoost && autoAdapt) {
1177
- const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.4 / K)) : Math.min(0.48, 1.9 / (2 * K));
1175
+ const maxSlotWeight = Math.min(0.40, Math.max(1 / K, 1.4 / K));
1178
1176
  slotRub = budget * maxSlotWeight;
1179
1177
  } else if (plan.id === 'pro') {
1180
1178
  slotRub = budget * Math.min(0.28, 1.15 / Math.max(2, slots));
@@ -1314,9 +1312,9 @@ export async function prepareBrokerOrders(id) {
1314
1312
  const totalNew = newBuys.length + newShorts.length;
1315
1313
  const activeTotal = Math.max(2, activeLongs + activeShorts + totalNew);
1316
1314
  const dynSlotRub = (isOwnerPlan && autoAdapt)
1317
- ? budget * Math.min(dir === 'long' ? 0.58 : 0.75, Math.max(slotRub / Math.max(1, budget), (dir === 'long' ? 1.8 : 3.0) / Math.min(slots, activeTotal + 1)))
1315
+ ? budget * Math.min(0.45, Math.max(slotRub / Math.max(1, budget), 1.8 / Math.min(slots, activeTotal + 1)))
1318
1316
  : (vipBoost && autoAdapt)
1319
- ? budget * Math.min(0.48, Math.max(slotRub / Math.max(1, budget), (dir === 'long' ? 1.4 : 1.9) / Math.min(slots, activeTotal + 1)))
1317
+ ? budget * Math.min(0.40, Math.max(slotRub / Math.max(1, budget), 1.5 / Math.min(slots, activeTotal + 1)))
1320
1318
  : slotRub;
1321
1319
 
1322
1320
  // В режиме «Плавный переход» (smooth):
@@ -1371,12 +1369,18 @@ export async function prepareBrokerOrders(id) {
1371
1369
  }
1372
1370
  let remWeightSum = combinedNew.reduce((s, x) => s + Math.max(0.01, Math.abs(x.r.expectedPct || 0)), 0);
1373
1371
 
1372
+ // Жёсткий предохранитель концентрации на одну бумагу в реальном стакане брокера:
1373
+ // максимум до 45% счёта на один лонг и максимум до 35% счёта на один шорт (включая множитель силы сигнала convOf)
1374
+ const maxLongPerStockRub = budget * 0.45;
1375
+ const maxShortPerStockRub = budget * 0.35;
1376
+
1374
1377
  for (const { r, side } of combinedNew) {
1375
1378
  const wCur = Math.max(0.01, Math.abs(r.expectedPct || 0));
1376
1379
  const propShare = autoAdapt && remWeightSum > 0 ? availRub * (wCur / remWeightSum) : availRub / Math.max(1, remNew);
1377
1380
  remWeightSum = Math.max(0, remWeightSum - wCur);
1378
1381
  remNew--;
1379
- const rawSum = (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r);
1382
+ const sideCapRub = side === 'short' ? maxShortPerStockRub : maxLongPerStockRub;
1383
+ const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
1380
1384
  const sum = Math.min(rawSum, propShare, availRub);
1381
1385
  const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
1382
1386
  const ord = calcLotOrder(r.symbol, sum, refPx, side, acc.limitOffsetPct, specs);
@@ -18,9 +18,9 @@ export const PARAMS = [
18
18
  { key: 'modelType', label: 'Какая стратегия торгует', hint: 'Внимание через карту.', type: 'select', options: [['attention', 'Внимание через карту'], ['leadlag', 'Кто за кем ходит'], ['inertia', 'Своя инерция']], hidden: true, fixed: true },
19
19
  { key: 'modelUseDividends', label: 'Учитывать дивиденды', hint: 'Цены до даты отсечки уменьшаются на размер дивиденда со сверкой по реальному падению цены.', type: 'checkbox', hidden: true, fixed: true },
20
20
  { key: 'capital', label: 'Стартовая сумма, ₽', hint: 'С какой суммой начинает счёт.', min: 10000, max: 1e9, hidden: true },
21
- { key: 'commissionPct', label: 'Комиссия брокера и биржи, % за сделку', hint: 'Сколько берут брокер и биржа за покупку и отдельно за продажу.', min: 0, max: 1, ai: false },
22
- { key: 'slippagePct', label: 'Проскальзывание при исполнении, %', hint: 'На сколько реальная цена сделки в стакане обычно хуже ожидаемой.', min: 0, max: 1, ai: false },
23
- { key: 'modelShortFeePct', label: 'Плата брокеру за заём акций (шорт), % годовых', hint: 'Ставка брокера за удержание коротких позиций (начисляется пропорционально времени удержания сделки).', min: 0, max: 100 },
21
+ { key: 'commissionPct', label: 'Комиссия брокера и биржи, % за сделку', hint: 'Сколько берут брокер и биржа за покупку и отдельно за продажу.', min: 0, max: 1, ai: false, hidden: true, fixed: true },
22
+ { key: 'slippagePct', label: 'Проскальзывание при исполнении, %', hint: 'На сколько реальная цена сделки в стакане обычно хуже ожидаемой.', min: 0, max: 1, ai: false, hidden: true, fixed: true },
23
+ { key: 'modelShortFeePct', label: 'Плата брокеру за заём акций (шорт), % годовых', hint: 'Ставка брокера за удержание коротких позиций (начисляется пропорционально времени удержания сделки).', min: 0, max: 100, hidden: true, fixed: true },
24
24
  { key: 'autopilotBudget', label: 'Лимит исследований автопилота за задание', hint: 'Сколько экспериментов Стратег может провести подряд за одно задание.', min: 1, max: 30, step: 1, int: true, ai: false, hidden: true },
25
25
  { key: 'paperDailyReview', label: 'ИИ разбирает каждый торговый день', hint: 'После окончания торгового дня Стратег сам разбирает сделки и пишет сводку в чат.', type: 'checkbox', hidden: true },
26
26
  { key: 'newsEnabled', label: 'Следить за новостями', hint: 'Раз в 10 минут собирает новости и раскрытия компаний.', type: 'checkbox', hidden: true, fixed: true },