neuroalpha 0.3.15 → 0.3.16

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.
@@ -74,7 +74,7 @@ export const BROKER_META = [
74
74
  const SPECS_URL = 'https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=securities,marketdata';
75
75
  export async function ensureBoardSpecs() {
76
76
  const cached = kvGet('moexSpecs');
77
- if (cached?.specs && Date.now() - cached.at < DAY) return cached.specs;
77
+ if (cached?.specs && cached?.v === 2 && Date.now() - cached.at < DAY) return cached.specs;
78
78
  try {
79
79
  const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
80
80
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
@@ -95,9 +95,10 @@ export async function ensureBoardSpecs() {
95
95
  const minStep = Number(row[is('MINSTEP')]) > 0 ? Number(row[is('MINSTEP')]) : 0.01;
96
96
  const decimals = Math.max(0, Number(row[is('DECIMALS')]) || 0);
97
97
  const prev = Number(row[is('PREVPRICE')] || row[is('PREVADMITTEDQUOTE')] || 0);
98
- specs[sym] = { lotSize, minStep, decimals, lastPrice: prices.get(sym) || prev || 0 };
98
+ const issueSize = Number(row[is('ISSUESIZE')] || 0);
99
+ specs[sym] = { lotSize, minStep, decimals, issueSize, lastPrice: prices.get(sym) || prev || 0 };
99
100
  }
100
- kvSet('moexSpecs', { at: Date.now(), specs });
101
+ kvSet('moexSpecs', { v: 2, at: Date.now(), specs });
101
102
  return specs;
102
103
  } catch {
103
104
  return cached?.specs || {};
@@ -144,13 +145,24 @@ function saveRawAccounts(list) {
144
145
  }
145
146
 
146
147
  function publicAccount(a) {
147
- const { token, ...rest } = a;
148
148
  let baseRunLabel = null;
149
149
  if (a.baseRunId) {
150
150
  const r = getRun(Number(a.baseRunId));
151
151
  if (r) baseRunLabel = `№${r.id}${r.params?._title ? ` (${r.params._title})` : ''}`;
152
152
  }
153
- return { ...rest, hasToken: Boolean(token && String(token).trim()), baseRunLabel };
153
+ const specs = kvGet('moexSpecs')?.specs || {};
154
+ let lastSync = a.lastSync || null;
155
+ if (lastSync && Array.isArray(lastSync.positions)) {
156
+ lastSync = {
157
+ ...lastSync,
158
+ positions: lastSync.positions.map((p) => {
159
+ const issueSize = p.issueSize || specs[p.symbol]?.issueSize || 0;
160
+ const ownershipPct = issueSize > 0 && p.qty > 0 ? (p.qty / issueSize) * 100 : (p.ownershipPct || null);
161
+ return { ...p, issueSize, ownershipPct };
162
+ }),
163
+ };
164
+ }
165
+ return { ...a, lastSync, token: a.token || '', hasToken: Boolean(a.token && String(a.token).trim()), baseRunLabel };
154
166
  }
155
167
 
156
168
  export function brokersState() {
@@ -204,9 +216,12 @@ export function saveBrokerAccount(input = {}) {
204
216
  mode,
205
217
  legacyMode,
206
218
  baselineQty: acctChanged ? null : (prev.baselineQty || null),
219
+ initialTotalRub: acctChanged ? 0 : (prev.initialTotalRub || 0),
207
220
  tbankAccountName: acctChanged ? '' : (prev.tbankAccountName || ''),
208
221
  equityHistory: acctChanged ? [] : (prev.equityHistory || []),
209
222
  lastHistory: acctChanged ? null : (prev.lastHistory || null),
223
+ entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
224
+ exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
210
225
  baseRunId: nextBaseRunId,
211
226
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
212
227
  maxCapitalRub,
@@ -363,11 +378,16 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
363
378
  return cache;
364
379
  }
365
380
 
366
- // Загружает и кэширует на 12 часов справочник доступности шортов по тикерам в Т-Банке (shortEnabledFlag)
381
+ // Загружает и кэширует на 12 часов справочник доступности шортов, UID и точных параметров лота/шага цены по тикерам в Т-Банке (TQBR + SPBRU для SPBE)
367
382
  async function ensureTbankShortableMap(acc) {
368
383
  if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
369
384
  const cached = kvGet('tbankShortableMap');
370
- if (cached?.map && Date.now() - cached.at < 12 * 3600000) return cached.map;
385
+ if (cached?.v === 3 && cached?.map && Date.now() - cached.at < 12 * 3600000) {
386
+ if (cached.uidMap) {
387
+ for (const [sym, uid] of Object.entries(cached.uidMap)) tbankUidCache.set(sym, uid);
388
+ }
389
+ return cached.map;
390
+ }
371
391
  try {
372
392
  const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
373
393
  const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
@@ -380,13 +400,31 @@ async function ensureTbankShortableMap(acc) {
380
400
  if (!r.ok) return cached?.map || null;
381
401
  const j = await r.json();
382
402
  const map = {};
403
+ const uidMap = { ...(cached?.uidMap || {}) };
404
+ const specMap = { ...(cached?.specMap || {}) };
383
405
  for (const inst of j.instruments || []) {
384
- if (inst.ticker && inst.classCode === 'TQBR') {
406
+ if (!inst.ticker || inst.apiTradeAvailableFlag === false) continue;
407
+ if (inst.classCode === 'TQBR' || (inst.classCode === 'SPBRU' && !(inst.ticker in map))) {
385
408
  map[inst.ticker] = Boolean(inst.shortEnabledFlag);
386
- if (inst.uid || inst.figi) tbankUidCache.set(inst.ticker, inst.uid || inst.figi);
409
+ const uid = inst.uid || inst.figi;
410
+ if (uid) {
411
+ tbankUidCache.set(inst.ticker, uid);
412
+ uidMap[inst.ticker] = uid;
413
+ }
414
+ const minStep = qVal(inst.minPriceIncrement);
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;
419
+ specMap[inst.ticker] = {
420
+ lotSize: Math.max(1, Number(inst.lot) || 1),
421
+ minStep,
422
+ decimals,
423
+ };
424
+ }
387
425
  }
388
426
  }
389
- kvSet('tbankShortableMap', { at: Date.now(), map });
427
+ kvSet('tbankShortableMap', { v: 3, at: Date.now(), map, uidMap, specMap });
390
428
  return map;
391
429
  } catch {
392
430
  return cached?.map || null;
@@ -395,16 +433,39 @@ async function ensureTbankShortableMap(acc) {
395
433
 
396
434
  async function resolveTbankInstrumentId(base, headers, symbol) {
397
435
  if (tbankUidCache.has(symbol)) return tbankUidCache.get(symbol);
436
+ const savedUid = kvGet('tbankShortableMap')?.uidMap?.[symbol];
437
+ if (savedUid) {
438
+ tbankUidCache.set(symbol, savedUid);
439
+ return savedUid;
440
+ }
441
+ for (const classCode of ['TQBR', 'SPBRU']) {
442
+ try {
443
+ const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/ShareBy`, {
444
+ method: 'POST',
445
+ headers,
446
+ body: JSON.stringify({ idType: 'INSTRUMENT_ID_TYPE_TICKER', classCode, id: symbol }),
447
+ timeoutMs: 10000,
448
+ });
449
+ if (r.ok) {
450
+ const j = await r.json();
451
+ if (j.instrument?.apiTradeAvailableFlag !== false) {
452
+ const uid = j.instrument?.uid || j.instrument?.figi;
453
+ if (uid) { tbankUidCache.set(symbol, uid); return uid; }
454
+ }
455
+ }
456
+ } catch { /* next classCode */ }
457
+ }
398
458
  try {
399
- const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/ShareBy`, {
459
+ const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/FindInstrument`, {
400
460
  method: 'POST',
401
461
  headers,
402
- body: JSON.stringify({ idType: 'INSTRUMENT_ID_TYPE_TICKER', classCode: 'TQBR', id: symbol }),
462
+ body: JSON.stringify({ query: symbol }),
403
463
  timeoutMs: 10000,
404
464
  });
405
465
  if (r.ok) {
406
466
  const j = await r.json();
407
- const uid = j.instrument?.uid || j.instrument?.figi;
467
+ const hit = (j.instruments || []).find((x) => x.ticker === symbol && x.apiTradeAvailableFlag === true && x.instrumentKind === 'INSTRUMENT_TYPE_SHARE');
468
+ const uid = hit?.uid || hit?.figi;
408
469
  if (uid) { tbankUidCache.set(symbol, uid); return uid; }
409
470
  }
410
471
  } catch { /* fallback */ }
@@ -475,9 +536,43 @@ async function syncTbank(acc) {
475
536
  marginEnabled = !mTxt.includes('30051') && !/margin.*disabled/i.test(mTxt) ? false : false;
476
537
  }
477
538
  const p = await rPort.json();
539
+ const jOrd = rOrd?.ok ? await rOrd.json().catch(() => null) : null;
540
+ let rawOrders = jOrd?.orders || [];
541
+
542
+ // Автоматическая уборка стакана (если счёт не в режиме «Только чтение»):
543
+ // 1) если по одному инструменту и направлению висит несколько дублирующих заявок — оставляем только самую свежую, остальные снимаем;
544
+ // 2) если лимитная заявка висит неисполненной дольше 12 минут (прошла свеча, цена ушла) — автоматически снимаем её и разблокируем рубли!
545
+ if (acc.mode !== 'readonly' && rawOrders.length > 0) {
546
+ const seenInstDir = new Set();
547
+ const toCancelIds = [];
548
+ const keptOrders = [];
549
+ const sortedByDate = [...rawOrders].sort((a, b) => Date.parse(b.orderDate || 0) - Date.parse(a.orderDate || 0));
550
+ const nowMs = Date.now();
551
+ for (const o of sortedByDate) {
552
+ const k = `${o.instrumentUid || o.figi || o.ticker}:${o.direction}`;
553
+ const ageMs = o.orderDate ? (nowMs - Date.parse(o.orderDate)) : 0;
554
+ if (seenInstDir.has(k) || ageMs > 12 * 60000) {
555
+ if (o.orderId) toCancelIds.push(o.orderId);
556
+ } else {
557
+ seenInstDir.add(k);
558
+ keptOrders.push(o);
559
+ }
560
+ }
561
+ if (toCancelIds.length > 0) {
562
+ await Promise.all(toCancelIds.map((orderId) => (
563
+ brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
564
+ method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 8000,
565
+ }).catch(() => null)
566
+ )));
567
+ rawOrders = keptOrders;
568
+ }
569
+ }
478
570
  const totalRub = round(qVal(p.totalAmountPortfolio), 2);
479
571
  const rawPositions = p.positions || [];
480
- const lookupIds = rawPositions.flatMap((pos) => [pos.ticker, pos.figi, pos.instrumentUid]).filter(Boolean);
572
+ const lookupIds = [
573
+ ...rawPositions.flatMap((pos) => [pos.ticker, pos.figi, pos.instrumentUid]),
574
+ ...rawOrders.flatMap((o) => [o.ticker, o.figi, o.instrumentUid]),
575
+ ].filter(Boolean);
481
576
  const metaMap = await resolveTbankMetaMap(base, headers, lookupIds);
482
577
 
483
578
  const positions = [];
@@ -491,7 +586,7 @@ async function syncTbank(acc) {
491
586
  const qty = qVal(pos.quantity);
492
587
  const px = qVal(pos.currentPrice);
493
588
  if (isRubCashPos(pos, rawTicker)) {
494
- cashRub += qty;
589
+ cashRub += Math.max(0, qty);
495
590
  continue;
496
591
  }
497
592
  if (qty === 0) continue;
@@ -527,23 +622,28 @@ async function syncTbank(acc) {
527
622
  }
528
623
 
529
624
  const activeOrders = [];
530
- if (rOrd?.ok) {
531
- const jOrd = await rOrd.json();
532
- for (const o of jOrd.orders || []) {
533
- const m = metaMap[o.figi] || metaMap[o.instrumentUid] || {};
534
- activeOrders.push({
535
- orderId: o.orderId,
536
- symbol: m.ticker || o.figi || '—',
537
- name: m.name || '',
538
- direction: String(o.direction || '').includes('BUY') ? 'buy' : 'sell',
539
- lotsRequested: Number(o.lotsRequested || 0),
540
- lotsExecuted: Number(o.lotsExecuted || 0),
541
- price: round(qVal(o.initialSecurityPrice), 4),
542
- amountRub: round(qVal(o.initialOrderPrice), 2),
543
- orderDate: o.orderDate || null,
544
- });
545
- }
625
+ for (const o of rawOrders) {
626
+ const m = metaMap[o.ticker] || metaMap[o.figi] || metaMap[o.instrumentUid] || {};
627
+ const sym = m.ticker || o.ticker || o.figi || '—';
628
+ const lotsReq = Number(o.lotsRequested || 0);
629
+ const lotsExec = Number(o.lotsExecuted || 0);
630
+ const remLots = Math.max(0, lotsReq - lotsExec);
631
+ const totalOrdRub = qVal(o.initialOrderPrice);
632
+ const remRub = lotsReq > 0 ? round(totalOrdRub * (remLots / lotsReq), 2) : round(totalOrdRub, 2);
633
+ activeOrders.push({
634
+ orderId: o.orderId,
635
+ symbol: sym,
636
+ name: m.name || '',
637
+ direction: String(o.direction || '').includes('BUY') ? 'buy' : 'sell',
638
+ lotsRequested: lotsReq,
639
+ lotsExecuted: lotsExec,
640
+ price: round(qVal(o.initialSecurityPrice), 4),
641
+ amountRub: remRub,
642
+ orderDate: o.orderDate || null,
643
+ });
546
644
  }
645
+ const activeBuyOrdersRub = activeOrders.filter((o) => o.direction === 'buy').reduce((s, o) => s + (o.amountRub || 0), 0);
646
+ cashRub = Math.max(0, cashRub - activeBuyOrdersRub);
547
647
 
548
648
  const breakdown = {
549
649
  sharesRub: round(qVal(p.totalAmountShares), 0),
@@ -863,13 +963,43 @@ export async function syncBrokerAccount(id) {
863
963
  else if (isManualCapable) data = await syncQuikOrManual(acc);
864
964
  else throw new Error(`Неизвестный брокер: ${acc.broker}`);
865
965
  if (data.accountId && !acc.accountId) acc.accountId = data.accountId;
966
+ const specs = await ensureBoardSpecs();
967
+ if (Array.isArray(data.positions)) {
968
+ data.positions = data.positions.map((p) => {
969
+ const issueSize = specs[p.symbol]?.issueSize || 0;
970
+ const ownershipPct = issueSize > 0 && p.qty > 0 ? (p.qty / issueSize) * 100 : null;
971
+ return { ...p, issueSize, ownershipPct };
972
+ });
973
+ }
866
974
  if (data.accountName) acc.tbankAccountName = data.accountName;
867
975
  if ((!acc.name || acc.name === 'Т-Банк (T-Invest API)') && data.accountName) {
868
976
  acc.name = `Т-Банк · ${data.accountName}`;
869
977
  }
870
- // При первой синхронизации (или если старый снимок был пустым, а бумаги на счёте есть) запоминаем стартовый портфель
978
+ // При первой синхронизации запоминаем стартовый портфель и стартовую базу капитала;
979
+ // а при продаже старых бумаг (в режиме «Плавный переход» или вручную) автоматически уменьшаем baselineQty до фактического остатка,
980
+ // чтобы новые покупки стратегии по тем же тикерам никогда не путались со старым портфелем
871
981
  if (!acc.baselineQty || (!Object.keys(acc.baselineQty).length && data.positions?.length)) {
872
982
  acc.baselineQty = Object.fromEntries((data.positions || []).filter((p) => p.qty !== 0).map((p) => [p.symbol, p.qty]));
983
+ } else if (acc.baselineQty && Array.isArray(data.positions)) {
984
+ const curPosMap = new Map(data.positions.map((p) => [p.symbol, p.qty]));
985
+ for (const [sym, bq] of Object.entries(acc.baselineQty)) {
986
+ const curQ = Number(curPosMap.get(sym) || 0);
987
+ if (bq > 0 && curQ < bq) {
988
+ if (curQ <= 0) delete acc.baselineQty[sym];
989
+ else acc.baselineQty[sym] = curQ;
990
+ } else if (bq < 0 && curQ > bq) {
991
+ if (curQ >= 0) delete acc.baselineQty[sym];
992
+ else acc.baselineQty[sym] = curQ;
993
+ }
994
+ }
995
+ }
996
+ if (!acc.initialTotalRub && data.totalRub > 0) {
997
+ acc.initialTotalRub = round(data.totalRub, 2);
998
+ }
999
+ // С началом нового календарного дня автоматически сбрасываем вчерашний дневной стоп по просадке
1000
+ if (acc.lastSync?.day && acc.lastSync.day !== today && acc.haltedReason && acc.haltedReason.startsWith('Сработал дневной стоп')) {
1001
+ acc.haltedReason = null;
1002
+ acc.ddBreachCount = 0;
873
1003
  }
874
1004
  const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
875
1005
  const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
@@ -877,7 +1007,13 @@ export async function syncBrokerAccount(id) {
877
1007
  if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
878
1008
  acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
879
1009
  } else if (ddPct >= acc.maxDailyDrawdownPct) {
880
- acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
1010
+ // Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
1011
+ acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
1012
+ if (acc.ddBreachCount >= 2) {
1013
+ acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
1014
+ }
1015
+ } else {
1016
+ acc.ddBreachCount = 0;
881
1017
  }
882
1018
  // История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
883
1019
  const hist = Array.isArray(acc.equityHistory) ? acc.equityHistory : [];
@@ -887,6 +1023,17 @@ export async function syncBrokerAccount(id) {
887
1023
  if (hist.length > 500) hist.splice(0, hist.length - 500);
888
1024
  }
889
1025
  acc.equityHistory = hist;
1026
+ const entryMap = { ...(acc.entryTsBySymbol || {}) };
1027
+ const curSymbols = new Set((data.positions || []).filter((p) => p.qty !== 0).map((p) => p.symbol));
1028
+ for (const s of curSymbols) {
1029
+ // Если позиция обнаружена при синхронизации без записи в executeBrokerOrders (например, перешла со вчерашнего дня),
1030
+ // помечаем её как зрелую (120 мин), чтобы для неё сразу действовал штатный порог выхода 0.95, а не стартовый 65-минутный буфер
1031
+ if (!entryMap[s]) entryMap[s] = nowTs - 120 * 60000;
1032
+ }
1033
+ for (const s of Object.keys(entryMap)) {
1034
+ if (!curSymbols.has(s)) delete entryMap[s];
1035
+ }
1036
+ acc.entryTsBySymbol = entryMap;
890
1037
  acc.lastSync = { at: nowTs, ok: true, error: null, day: today, dayStartRub, dayDrawdownPct: ddPct, ...data };
891
1038
  } catch (e) {
892
1039
  acc.lastSync = { ...(acc.lastSync || {}), at: Date.now(), ok: false, error: e.message };
@@ -897,6 +1044,7 @@ export async function syncBrokerAccount(id) {
897
1044
  saveRawAccounts(list);
898
1045
  const st = brokersState();
899
1046
  hub.emit({ type: 'brokers:update', state: st });
1047
+ hub.emit({ type: 'trading:pulse', kind: 'broker_sync', ts: Date.now(), accountId: acc.id, name: acc.name });
900
1048
  return st;
901
1049
  }
902
1050
 
@@ -935,52 +1083,106 @@ function sessionEdgeWarning(now = Date.now()) {
935
1083
 
936
1084
  // ---------- формирование и исполнение пакета лимитных заявок ----------
937
1085
  export async function prepareBrokerOrders(id) {
938
- const list = loadRawAccounts();
939
- const acc = list.find((x) => x.id === id);
1086
+ let list = loadRawAccounts();
1087
+ let acc = list.find((x) => x.id === id);
940
1088
  if (!acc) throw new Error('Счёт не найден');
941
1089
  if (acc.haltedReason) throw new Error(acc.haltedReason);
942
1090
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
943
1091
 
944
1092
  const plan = effectivePlan(currentUser());
945
1093
  if ((plan.maxBrokerAccounts ?? 0) < 1) {
946
- throw new Error(`На бесплатном тарифе «${plan.name}» работа с реальными брокерскими счетами недоступна (доступно тестирование в Экспериментах и виртуальный счёт). Оформите тариф «Инвестор», «Профи» или «VIP».`);
1094
+ throw new Error('Для работы с брокерскими счетами оформите подписку «Инвестор» (3 900 ₽/мес), «Профи» (9 900 ₽/мес) или «VIP» в разделе «Подписка и уведомления».');
1095
+ }
1096
+ hub.emit({ type: 'trading:pulse', kind: 'broker_calc', ts: Date.now(), accountId: acc.id, name: acc.name, text: `Пересчёт заявок по счёту «${acc.name}»…` });
1097
+ // Перед расчётом заявок автоматически снимаем старые неисполненные заявки со стакана и синхронизируем свежий баланс!
1098
+ if (acc.token && acc.mode !== 'readonly' && acc.broker === 'tbank' && acc.lastSync?.activeOrders?.length > 0) {
1099
+ await cancelBrokerOrder(id, 'all').catch(() => {});
1100
+ list = loadRawAccounts();
1101
+ acc = list.find((x) => x.id === id) || acc;
1102
+ } else if (acc.token && (!acc.lastSync || Date.now() - (acc.lastSync.at || 0) > 45000)) {
1103
+ await syncBrokerAccount(id).catch(() => {});
1104
+ list = loadRawAccounts();
1105
+ acc = list.find((x) => x.id === id) || acc;
947
1106
  }
948
- const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
949
- const params = normalizeParams(baseRun?.params || getSettings());
950
- const [fc, specs, shortableMap] = await Promise.all([
1107
+ const params = normalizeParams(getSettings());
1108
+ const [fc, rawSpecs, shortableMap] = await Promise.all([
951
1109
  forecastNow(params),
952
1110
  ensureBoardSpecs(),
953
1111
  ensureTbankShortableMap(acc),
954
1112
  ]);
1113
+ const specs = { ...rawSpecs };
1114
+ if (acc.broker === 'tbank') {
1115
+ const tbSpecs = kvGet('tbankShortableMap')?.specMap || {};
1116
+ for (const [sym, ts] of Object.entries(tbSpecs)) {
1117
+ specs[sym] = { ...(specs[sym] || { lastPrice: 0, issueSize: 0 }), ...ts };
1118
+ }
1119
+ }
955
1120
  const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
956
1121
  const effectiveMaxCap = Math.min(acc.maxCapitalRub, capCeiling);
957
- const budget = Math.min(effectiveMaxCap, acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : effectiveMaxCap);
958
- const K = params.modelTopK || 3;
1122
+ // Лимит тарифа (1 млн ₽ на Инвесторе/Профи) ограничивает СТАРТОВУЮ выделенную сумму на счёте,
1123
+ // а вся заработанная стратегией сверху прибыль автоматически реинвестируется в рабочий бюджет (сложный процент):
1124
+ const curTotalRub = acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : effectiveMaxCap;
1125
+ const initBaseRub = acc.initialTotalRub > 0 ? acc.initialTotalRub : (acc.equityHistory?.[0]?.totalRub > 0 ? acc.equityHistory[0].totalRub : curTotalRub);
1126
+ const reinvestedProfitRub = round(Math.max(0, curTotalRub - Math.max(initBaseRub, effectiveMaxCap)), 0);
1127
+ const allowedTotalCapRub = effectiveMaxCap + reinvestedProfitRub;
1128
+ const budget = round(Math.min(allowedTotalCapRub, curTotalRub), 0);
1129
+ const autoAdapt = params.modelAutoAdapt !== false;
1130
+ const rawTopK = Number(params.modelTopK ?? 2);
1131
+ const autoK = rawTopK === 0 || autoAdapt;
1132
+ const K = rawTopK > 0 ? rawTopK : 4;
1133
+ const filterBrokerDynamicTopK = (cands, needVal, maxCount) => {
1134
+ if (!cands.length || maxCount <= 0) return [];
1135
+ const lim = Math.min(cands.length, maxCount);
1136
+ if (!autoK || lim <= 1) return cands.slice(0, lim);
1137
+ const bestE = Math.abs(cands[0].expectedPct || 0);
1138
+ const out = [cands[0]];
1139
+ for (let m = 1; m < lim; m++) {
1140
+ const curE = Math.abs(cands[m].expectedPct || 0);
1141
+ const relMin = m === 1 ? 0.62 : 0.78;
1142
+ const absMin = m === 1 ? needVal * 1.15 : needVal * 1.45;
1143
+ if (curE >= bestE * relMin && curE >= absMin) out.push(cands[m]);
1144
+ else break;
1145
+ }
1146
+ return out;
1147
+ };
959
1148
  // Автоматическая подстройка направления и мощности торговли под тариф и реальные данные счёта:
960
1149
  const marginAllowed = acc.lastSync?.marginEnabled !== false;
961
1150
  const planAllowsShorts = Boolean(plan.allowShorts);
962
1151
  const vipBoost = Boolean(plan.allowVipBoost);
963
1152
  const rawDir = params.modelDirection || 'long';
964
1153
  const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
965
- const autoAdapt = params.modelAutoAdapt !== false;
966
1154
  const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
967
1155
  const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
1156
+ const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.35, 3);
1157
+ const nowTs = Date.now();
1158
+ const entryTsMap = acc.entryTsBySymbol || {};
1159
+ const exitCooldownMap = acc.exitCooldownBySymbol || {};
1160
+ const mskDate = new Date(nowTs + MSK);
1161
+ const isEveningSession = (mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes()) >= 19 * 60;
1162
+ const eveningBoost = (autoAdapt && isEveningSession) ? 1.25 : 1.0;
1163
+ const minEntryEdgePct = round((fc.thresholdPct || 0) * eveningBoost, 3);
1164
+ const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
968
1165
  const slots = dir === 'long' ? K : 2 * K;
969
1166
  // Фильтр мощности слота по тарифу:
970
- // • Инвестор (до 50% годовых): только покупки (без шортов), консервативный слот 1 / (K + 1) (до 22% капитала)
971
- // • Профи (до 100% годовых): покупки + шорты, стандартный равный слот 1 / (2K) (до 25% капитала, шаг раз в полный горизонт H)
972
- // • VIP (без ограничений): полный форсаж (Conviction Slot Sizing до 42% капитала в лучших сигналах + полушаговый перехват H/2)
1167
+ // • Инвестор (до 60% годовых): 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
1168
+ // • Профи (до 120% годовых): до 5 счетов по 1 млн ₽, покупки + шорты, базовый слот до 28% капитала
1169
+ // • VIP (до 240% годовых): счета и сумма по согласованию, покупки + шорты, слот до 48% капитала
1170
+ // • Хозяин (без ограничений): полная мощность ядра (>400% годовых, Conviction Sizing до 75% капитала)
1171
+ const isOwnerPlan = Boolean(plan.isOwner || plan.id === 'owner' || plan.maxAnnualReturnPct === 0);
973
1172
  let slotRub;
974
- if (vipBoost && autoAdapt) {
975
- const maxSlotWeight = dir === 'long' ? Math.min(0.48, Math.max(1 / K, 1.4 / K)) : Math.min(0.48, 1.9 / (2 * K));
1173
+ 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));
1175
+ slotRub = budget * maxSlotWeight;
1176
+ } 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));
976
1178
  slotRub = budget * maxSlotWeight;
977
1179
  } else if (plan.id === 'pro') {
978
- slotRub = budget * Math.min(0.25, 1 / Math.max(2, slots));
1180
+ slotRub = budget * Math.min(0.28, 1.15 / Math.max(2, slots));
979
1181
  } else {
980
- slotRub = budget * Math.min(0.22, 1 / Math.max(3, K + 1));
1182
+ slotRub = budget * Math.min(0.24, 1.05 / Math.max(3, K + 1));
981
1183
  }
982
- // Дополнительный мягкий дроссель при опережении годового потолка доходности тарифа (50% для Инвестора, 100% для Профи)
983
- if (!vipBoost && plan.maxAnnualReturnPct > 0 && Array.isArray(acc.equityHistory) && acc.equityHistory.length >= 2) {
1184
+ // Мягкий дроссель при опережении годового потолка доходности тарифа (60% Инвестор, 120% Профи, 240% VIP; у Хозяина = 0 — без потолка)
1185
+ if (!isOwnerPlan && plan.maxAnnualReturnPct > 0 && Array.isArray(acc.equityHistory) && acc.equityHistory.length >= 2) {
984
1186
  const firstPt = acc.equityHistory[0];
985
1187
  const lastPt = acc.equityHistory[acc.equityHistory.length - 1];
986
1188
  const elapsedDays = Math.max(7, (lastPt.ts - firstPt.ts) / DAY);
@@ -993,14 +1195,19 @@ export async function prepareBrokerOrders(id) {
993
1195
  }
994
1196
  }
995
1197
  const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
996
- const convOf = (r) => (vipBoost && autoAdapt && r.expectedPct ? Math.min(1.25, Math.max(0.85, 0.80 + 0.25 * (Math.abs(r.expectedPct) / needPct))) : 1);
1198
+ const convOf = (r) => {
1199
+ if (!autoAdapt || !r.expectedPct) return 1;
1200
+ if (isOwnerPlan) return Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (Math.abs(r.expectedPct) / needPct)));
1201
+ if (vipBoost) return Math.min(1.30, Math.max(0.85, 0.80 + 0.25 * (Math.abs(r.expectedPct) / needPct)));
1202
+ return 1;
1203
+ };
997
1204
  const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
998
1205
  const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
999
- const quarantined = new Set(baseRun?.extra?.adaptation?.quarantined || []);
1206
+ const quarantined = new Set();
1000
1207
  const nb = params.newsFilter ? blockingNews(6) : null;
1001
1208
 
1002
- const buyCands = fc.rows.filter((r) => r.signal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad).sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1003
- const allShortSignals = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct) : [];
1209
+ const buyCands = fc.rows.filter((r) => r.signal && r.expectedPct >= minEntryEdgePct && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad).sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1210
+ const allShortSignals = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -minEntryEdgePct && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct) : [];
1004
1211
  const nonShortableSkipped = [];
1005
1212
  const rawShortCands = [];
1006
1213
  for (const r of allShortSignals) {
@@ -1036,38 +1243,42 @@ export async function prepareBrokerOrders(id) {
1036
1243
  const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
1037
1244
  const aQty = algoQtyOf(sym, pos.qty);
1038
1245
  const baseQty = pos.qty - aQty;
1246
+ const heldMs = entryTsMap[sym] ? (nowTs - entryTsMap[sym]) : (120 * 60000);
1247
+ // Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
1248
+ // чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95)
1249
+ const curExitThr = (autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct;
1039
1250
  if (aQty > 0) {
1040
1251
  const lots = Math.floor(aQty / sp.lotSize);
1041
- const keep = r.liquid && r.expectedPct > exitThresholdPct;
1252
+ const keep = r.liquid && r.expectedPct > curExitThr;
1042
1253
  if (lots >= 1 && !keep) {
1043
1254
  const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
1044
1255
  orders.push({
1045
1256
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1046
1257
  refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1047
- reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${exitThresholdPct}%`,
1258
+ reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`,
1048
1259
  });
1049
1260
  closing.add(sym);
1050
1261
  }
1051
1262
  } else if (aQty < 0) {
1052
1263
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1053
- const keep = dir !== 'long' && r.liquid && r.expectedPct < -exitThresholdPct;
1264
+ const keep = dir !== 'long' && r.liquid && r.expectedPct < -curExitThr;
1054
1265
  if (lots >= 1 && !keep) {
1055
1266
  const limitPrice = roundToStep(pos.price * (1 + acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
1056
1267
  orders.push({
1057
1268
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1058
1269
  refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1059
- reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${exitThresholdPct}%`,
1270
+ reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`,
1060
1271
  });
1061
1272
  closing.add(sym);
1062
1273
  }
1063
1274
  }
1064
1275
  // В режиме «Плавный переход» (smooth) проверяем начальные акции:
1065
- // если по старой акции вышел сильный сигнал падения (shortSignal), защищаем счёт и закрываем её;
1066
- // иначе сохраняем в список кандидатов на плавное высвобождение капитала под новые покупки
1276
+ // 1) если по старой акции вышел сильный медвежий сигнал (ожидание ниже −needPct), сразу закрываем всю позицию для защиты счёта;
1277
+ // 2) иначе добавляем бумагу в пул плавной ротации (в первую очередь высвобождаются бумаги с отрицательным/слабым прогнозом под новые лонги и шорты)
1067
1278
  if (legacyMode === 'smooth' && baseQty > 0 && !closing.has(sym)) {
1068
1279
  const bLots = Math.floor(baseQty / sp.lotSize);
1069
1280
  if (bLots >= 1 && !(r.liquid && r.expectedPct > exitThresholdPct)) {
1070
- if (r.expectedPct < -needPct) {
1281
+ if (r.expectedPct <= -needPct) {
1071
1282
  const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
1072
1283
  orders.push({
1073
1284
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots: bLots, shares: bLots * sp.lotSize,
@@ -1088,63 +1299,95 @@ export async function prepareBrokerOrders(id) {
1088
1299
  const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
1089
1300
  let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
1090
1301
 
1091
- // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи и свободного лимита)
1092
- const newBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol))).slice(0, Math.max(0, K - activeLongs));
1093
- const newShorts = shortCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) < 0 && !closing.has(r.symbol))).slice(0, Math.max(0, K - activeShorts));
1094
-
1095
- // В режиме «Плавный переход», если под новые покупки не хватает свободных рублей, плавно высвобождаем из начального портфеля
1096
- // (в первую очередь бумаги в плюсе или с наименьшим прогнозом, не более 2 бумаг за шаг)
1097
- if (legacyMode === 'smooth' && newBuys.length > 0 && smoothSellPool.length > 0) {
1098
- const neededRub = newBuys.reduce((s, r) => s + (params.modelAllocMode === 'invvol' && r.weight ? slotRub * r.weight : slotRub) * convOf(r), 0);
1099
- let shortfall = Math.min(Math.max(0, budget * 0.99 - usedRub), neededRub) - availRub;
1302
+ // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
1303
+ const rawNewBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol)));
1304
+ // Не открываем шорт по тикеру, в котором ещё лежит непроданный остаток старого портфеля (baseQty > 0), чтобы брокер не превратил шорт в продажу старой позиции
1305
+ const rawNewShorts = shortCands.filter((r) => {
1306
+ const curQ = heldMap.get(r.symbol)?.qty || 0;
1307
+ const aQ = algoQtyOf(r.symbol, curQ);
1308
+ const bQ = curQ - aQ;
1309
+ if (bQ > 0 && !closing.has(r.symbol)) return false;
1310
+ return !(aQ < 0 && !closing.has(r.symbol));
1311
+ });
1312
+ const newBuys = filterBrokerDynamicTopK(rawNewBuys, minEntryEdgePct, Math.max(0, K - activeLongs));
1313
+ const newShorts = filterBrokerDynamicTopK(rawNewShorts, minEntryEdgePct, Math.max(0, K - activeShorts));
1314
+ const totalNew = newBuys.length + newShorts.length;
1315
+ const activeTotal = Math.max(2, activeLongs + activeShorts + totalNew);
1316
+ 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)))
1318
+ : (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)))
1320
+ : slotRub;
1321
+
1322
+ // В режиме «Плавный переход» (smooth):
1323
+ // • высвобождаем кэш из старого портфеля как под новые ПОКУПКИ, так и под новые ШОРТЫ (чтобы шорты открывались полным рабочим слотом dynSlotRub, а не на остаток копеек);
1324
+ // • даже если прямо сейчас нет нового входа, но свободного кэша на счёте меньше 1 рабочего слота, плавно продаём на каждом шаге по 1–2 самые слабые старые акции с отрицательным прогнозом (expectedPct <= -0.02%), заранее формируя рублёвый резерв;
1325
+ // • сортируем кандидатов строго по худшему прогнозу ИИ (сначала акции с отрицательным ожиданием, среди близких — позиции в плюсе или с минимальным убытком), до 4 бумаг за шаг при активном сигнале.
1326
+ if (legacyMode === 'smooth' && smoothSellPool.length > 0) {
1327
+ const activeEntries = [...newBuys, ...newShorts];
1328
+ const neededForSignalsRub = activeEntries.reduce((s, r) => s + (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r), 0);
1329
+ const hasWeakLegacy = smoothSellPool.some((x) => (x.r.expectedPct || 0) <= -0.02);
1330
+ const proactiveReserveRub = (!activeEntries.length && hasWeakLegacy && availRub < slotRub) ? slotRub : 0;
1331
+ const targetNeedRub = Math.max(neededForSignalsRub, proactiveReserveRub);
1332
+ let shortfall = Math.min(Math.max(0, budget * 0.99 - usedRub), targetNeedRub) - availRub;
1100
1333
  if (shortfall > 1000) {
1101
- smoothSellPool.sort((a, b) => ((b.pos.pnlRub >= 0 ? 1 : 0) - (a.pos.pnlRub >= 0 ? 1 : 0)) || (a.r.expectedPct - b.r.expectedPct));
1102
- for (const item of smoothSellPool.slice(0, 2)) {
1334
+ smoothSellPool.sort((a, b) => {
1335
+ const ea = Number(a.r.expectedPct || 0);
1336
+ const eb = Number(b.r.expectedPct || 0);
1337
+ // Сначала бумаги с явно худшим прогнозом (разница > 0.03%), при близком прогнозе — сначала плюсовые/околонулевые
1338
+ if (Math.abs(ea - eb) > 0.03) return ea - eb;
1339
+ return ((b.pos.pnlRub >= 0 ? 1 : 0) - (a.pos.pnlRub >= 0 ? 1 : 0)) || (ea - eb);
1340
+ });
1341
+ const maxSteps = activeEntries.length > 0 ? 4 : 2;
1342
+ const poolToUse = activeEntries.length > 0
1343
+ ? smoothSellPool.slice(0, maxSteps)
1344
+ : smoothSellPool.filter((x) => (x.r.expectedPct || 0) <= -0.02).slice(0, maxSteps);
1345
+ for (const item of poolToUse) {
1103
1346
  if (shortfall <= 1000) break;
1104
1347
  const limitPrice = roundToStep(item.pos.price * (1 - acc.limitOffsetPct / 100), item.sp.minStep, item.sp.decimals);
1105
1348
  const lotVal = limitPrice * item.sp.lotSize;
1106
1349
  const lotsToSell = Math.min(item.bLots, Math.max(1, Math.ceil(shortfall / lotVal)));
1107
1350
  const amountRub = round(lotsToSell * item.sp.lotSize * limitPrice, 2);
1351
+ const sigReason = activeEntries.length > 0
1352
+ ? `под сигнал стратегии (${activeEntries.map((x) => x.symbol).join(', ')})`
1353
+ : 'упреждающее освобождение кэша из слабой бумаги';
1108
1354
  orders.push({
1109
1355
  symbol: item.sym, action: 'sell', lotSize: item.sp.lotSize, lots: lotsToSell, shares: lotsToSell * item.sp.lotSize,
1110
1356
  refPrice: item.pos.price, limitPrice, amountRub,
1111
- reason: `Плавный переход: высвобождение ${fmtRubSimple(amountRub)} ₽ из старого портфеля (ожидание ${item.r.expectedPct > 0 ? '+' : ''}${item.r.expectedPct}%) под новый сигнал покупки`,
1357
+ reason: `Плавный переход: высвобождение ${fmtRubSimple(amountRub)} ₽ из старого портфеля (прогноз ${item.r.expectedPct > 0 ? '+' : ''}${item.r.expectedPct}%) ${sigReason}`,
1112
1358
  });
1113
1359
  availRub += amountRub;
1114
1360
  shortfall -= amountRub;
1115
1361
  }
1116
1362
  }
1117
1363
  }
1118
- const totalNew = newBuys.length + newShorts.length;
1119
1364
  let remNew = totalNew;
1120
-
1121
- for (const r of newBuys) {
1122
- const rawSum = (params.modelAllocMode === 'invvol' && r.weight ? slotRub * r.weight : slotRub) * convOf(r);
1123
- const sum = Math.min(rawSum, availRub / Math.max(1, remNew));
1124
- remNew--;
1125
- const refPx = specs[r.symbol]?.lastPrice || 0;
1126
- const ord = calcLotOrder(r.symbol, sum, refPx, 'buy', acc.limitOffsetPct, specs);
1127
- if (ord) {
1128
- availRub = Math.max(0, availRub - ord.amountRub);
1129
- orders.push({
1130
- ...ord,
1131
- expectedPct: r.expectedPct,
1132
- reason: `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`,
1133
- });
1134
- }
1365
+ const combinedNew = [
1366
+ ...newBuys.map((r) => ({ r, side: 'buy' })),
1367
+ ...newShorts.map((r) => ({ r, side: 'short' })),
1368
+ ];
1369
+ if (autoAdapt && combinedNew.length > 1) {
1370
+ combinedNew.sort((a, b) => Math.abs(b.r.expectedPct || 0) - Math.abs(a.r.expectedPct || 0));
1135
1371
  }
1136
- for (const r of newShorts) {
1137
- const rawSum = (params.modelAllocMode === 'invvol' && r.weight ? slotRub * r.weight : slotRub) * convOf(r);
1138
- const sum = Math.min(rawSum, availRub / Math.max(1, remNew));
1372
+ let remWeightSum = combinedNew.reduce((s, x) => s + Math.max(0.01, Math.abs(x.r.expectedPct || 0)), 0);
1373
+
1374
+ for (const { r, side } of combinedNew) {
1375
+ const wCur = Math.max(0.01, Math.abs(r.expectedPct || 0));
1376
+ const propShare = autoAdapt && remWeightSum > 0 ? availRub * (wCur / remWeightSum) : availRub / Math.max(1, remNew);
1377
+ remWeightSum = Math.max(0, remWeightSum - wCur);
1139
1378
  remNew--;
1140
- const refPx = specs[r.symbol]?.lastPrice || 0;
1141
- const ord = calcLotOrder(r.symbol, sum, refPx, 'short', acc.limitOffsetPct, specs);
1379
+ const rawSum = (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r);
1380
+ const sum = Math.min(rawSum, propShare, availRub);
1381
+ const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
1382
+ const ord = calcLotOrder(r.symbol, sum, refPx, side, acc.limitOffsetPct, specs);
1142
1383
  if (ord) {
1143
1384
  availRub = Math.max(0, availRub - ord.amountRub);
1144
1385
  orders.push({
1145
1386
  ...ord,
1146
1387
  expectedPct: r.expectedPct,
1147
- reason: `Продажа без покупки: ожидание ${r.expectedPct}% за ${params.modelHorizon} ч`,
1388
+ reason: side === 'buy'
1389
+ ? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
1390
+ : `Продажа без покупки: ожидание ${r.expectedPct}% за ${params.modelHorizon} ч`,
1148
1391
  });
1149
1392
  }
1150
1393
  }
@@ -1153,9 +1396,13 @@ export async function prepareBrokerOrders(id) {
1153
1396
  let planThrottleNote = '';
1154
1397
  if (plan.id === 'investor') {
1155
1398
  const shInfo = rawShortCands.length ? ` (пропущены сигналы на шорт: ${rawShortCands.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
1156
- planThrottleNote = `Тариф «Инвестор» (целевой потолок до 50% годовых, лимит до 1 млн ₽/счёт): активен режим «Только в рост (без шорта)» и консервативный слот 22%${shInfo}. Для включения заработка на падении акций (шорты, до 100% годовых и до 10 млн ₽/счёт) перейдите на тариф «Профи».`;
1399
+ const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
1400
+ planThrottleNote = `Тариф «Инвестор» (целевой потолок до 60% годовых, 1 счёт со стартовым лимитом до 1 млн ₽${reinvNote}): активен режим «Только в рост (без шорта)»${shInfo}. Для включения заработка на падении акций (шорты, до 120% годовых и до 5 счетов) перейдите на тариф «Профи».`;
1157
1401
  } else if (plan.id === 'pro') {
1158
- planThrottleNote = 'Тариф «Профи» (целевой потолок до 100% годовых, лимит до 10 млн ₽/счёт): активны покупки и шорты со стандартным шагом H и базовым слотом 25%. Для снятия всех ограничений (внутричасовой перехват H/2 + концентрация слота Conviction Sizing до 42%, как в максимальных экспериментах) подключите тариф «VIP».';
1402
+ const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
1403
+ planThrottleNote = `Тариф «Профи» (целевой потолок до 120% годовых, до 5 счетов со стартовым лимитом до 1 млн ₽/счёт${reinvNote}): активны покупки и шорты. Для расширения потолка доходности до 240% годовых и индивидуальных лимитов подключите тариф «VIP».`;
1404
+ } else if (plan.id === 'vip') {
1405
+ planThrottleNote = `Тариф «VIP» (целевой потолок до 240% годовых, лимиты по согласованию): активны покупки и шорты с повышенной концентрацией слота.`;
1159
1406
  }
1160
1407
  if (!orders.length) {
1161
1408
  if ((newBuys.length || newShorts.length) && availRub < 1500) {
@@ -1164,7 +1411,7 @@ export async function prepareBrokerOrders(id) {
1164
1411
  diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
1165
1412
  } else if (!newBuys.length && !planAllowsShorts && rawShortCands.length > 0) {
1166
1413
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
1167
- diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 50% годовых). Чтобы зарабатывать и на падении акций (до 100% годовых), перейдите на тариф «Профи» или «VIP».`;
1414
+ diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 60% годовых). Чтобы зарабатывать и на падении акций (до 120% годовых), перейдите на тариф «Профи» или «VIP».`;
1168
1415
  } else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
1169
1416
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
1170
1417
  diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
@@ -1198,6 +1445,7 @@ export async function prepareBrokerOrders(id) {
1198
1445
  }
1199
1446
  const st = brokersState();
1200
1447
  hub.emit({ type: 'brokers:update', state: st });
1448
+ hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
1201
1449
  return st;
1202
1450
  }
1203
1451
 
@@ -1237,6 +1485,25 @@ async function sendOrderToBroker(acc, ord) {
1237
1485
  const isBuy = ord.action === 'buy' || ord.action === 'cover';
1238
1486
  let lotsToOrder = Number(ord.lots) || 0;
1239
1487
 
1488
+ // Перед выставлением новой лимитной заявки автоматически снимаем старые неисполненные заявки по этой же бумаге и направлению,
1489
+ // чтобы в стакане никогда не копились дублирующиеся заявки и не блокировали рублёвый лимит счёта!
1490
+ const rActive = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
1491
+ method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId }), timeoutMs: 8000,
1492
+ }).catch(() => null);
1493
+ if (rActive?.ok) {
1494
+ const jActive = await rActive.json().catch(() => null);
1495
+ const wantDir = isBuy ? 'BUY' : 'SELL';
1496
+ for (const ao of jActive?.orders || []) {
1497
+ const sameInst = ao.instrumentUid === instrumentId || ao.figi === instrumentId || ao.ticker === ord.symbol;
1498
+ const sameDir = String(ao.direction || '').includes(wantDir);
1499
+ if (sameInst && sameDir && ao.orderId) {
1500
+ await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
1501
+ method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId, orderId: ao.orderId }), timeoutMs: 8000,
1502
+ }).catch(() => null);
1503
+ }
1504
+ }
1505
+ }
1506
+
1240
1507
  // Проверяем реальный доступный лимит лотов у брокера через GetMaxLots перед отправкой заявки
1241
1508
  if (!acc.sandbox && lotsToOrder > 0) {
1242
1509
  const rMax = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetMaxLots`, {
@@ -1450,10 +1717,31 @@ export async function executeBrokerOrders(id) {
1450
1717
  const batch = acc.pendingBatch;
1451
1718
  if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
1452
1719
  const results = [];
1453
- for (const ord of batch.orders) {
1720
+ acc.entryTsBySymbol = acc.entryTsBySymbol || {};
1721
+ acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
1722
+ const execNowTs = Date.now();
1723
+ const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
1724
+ const entryOrders = batch.orders.filter((o) => o.action !== 'sell' && o.action !== 'cover');
1725
+ const orderedBatch = [...exitOrders, ...entryOrders];
1726
+ let hadExits = false;
1727
+ for (const ord of orderedBatch) {
1728
+ // Если перед открытием новых позиций мы отправили закрывающие ордера (sell/cover),
1729
+ // даём брокеру 1.2 секунды на исполнение в стакане и пересчёт свободного маржинального/рублёвого лимита перед вызовом GetMaxLots
1730
+ if (hadExits && (ord.action === 'buy' || ord.action === 'short')) {
1731
+ hadExits = false;
1732
+ await new Promise((r) => setTimeout(r, 1200));
1733
+ }
1454
1734
  try {
1455
1735
  const r = await sendOrderToBroker(acc, ord);
1456
1736
  results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
1737
+ if (ord.action === 'buy' || ord.action === 'short') {
1738
+ acc.entryTsBySymbol[ord.symbol] = execNowTs;
1739
+ delete acc.exitCooldownBySymbol[ord.symbol];
1740
+ } else if (ord.action === 'sell' || ord.action === 'cover') {
1741
+ hadExits = true;
1742
+ delete acc.entryTsBySymbol[ord.symbol];
1743
+ acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
1744
+ }
1457
1745
  } catch (e) {
1458
1746
  results.push({ symbol: ord.symbol, ok: false, error: e.message });
1459
1747
  }
@@ -1462,11 +1750,45 @@ export async function executeBrokerOrders(id) {
1462
1750
  batch.results = results;
1463
1751
  batch.executedAt = Date.now();
1464
1752
  saveRawAccounts(list);
1753
+ await syncBrokerAccount(id).catch(() => {});
1465
1754
  const st = brokersState();
1466
1755
  hub.emit({ type: 'brokers:update', state: st });
1467
1756
  return st;
1468
1757
  }
1469
1758
 
1759
+ export async function cancelBrokerOrder(id, orderId = null) {
1760
+ const list = loadRawAccounts();
1761
+ const acc = list.find((x) => x.id === id);
1762
+ if (!acc) throw new Error('Счёт не найден');
1763
+ if (!acc.token) throw new Error('Не задан токен API брокера');
1764
+ if (acc.broker === 'tbank') {
1765
+ const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
1766
+ const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
1767
+ const accountId = acc.accountId || acc.lastSync?.accountId;
1768
+ if (!accountId) throw new Error('Не определён ID счёта');
1769
+ if (orderId && orderId !== 'all') {
1770
+ const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
1771
+ method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 10000,
1772
+ });
1773
+ if (!r.ok) throw new Error(`T-Invest CancelOrder: HTTP ${r.status}`);
1774
+ } else {
1775
+ const rOrd = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
1776
+ method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
1777
+ });
1778
+ if (rOrd.ok) {
1779
+ const jOrd = await rOrd.json();
1780
+ await Promise.all((jOrd.orders || []).map((o) => (
1781
+ o.orderId ? brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
1782
+ method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(o.orderId) }), timeoutMs: 10000,
1783
+ }).catch(() => null) : null
1784
+ )));
1785
+ }
1786
+ }
1787
+ return syncBrokerAccount(id);
1788
+ }
1789
+ throw new Error('Отмена активных заявок из интерфейса сейчас поддерживается для Т-Банка (T-Invest API)');
1790
+ }
1791
+
1470
1792
  export function rejectBrokerOrders(id) {
1471
1793
  const list = loadRawAccounts();
1472
1794
  const acc = list.find((x) => x.id === id);
@@ -1633,13 +1955,12 @@ async function tickBrokerSchedule() {
1633
1955
  if ((plan.maxBrokerAccounts ?? 0) < 1) return;
1634
1956
  for (const acc of list) {
1635
1957
  if (acc.autoSchedule === false || acc.haltedReason) continue;
1636
- const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
1637
- const params = normalizeParams(baseRun?.params || getSettings());
1958
+ const params = normalizeParams(getSettings());
1638
1959
  const autoAdapt = params.modelAutoAdapt !== false;
1639
1960
  const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
1640
1961
  const H = barsFromHours(effHorizon);
1641
- // Внутричасовой перехват H/5 (каждые 30 мин) активен в режиме VIP (полный форсаж); на тарифах Инвестор и Профи шаг равен полному горизонту H
1642
- const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
1962
+ // Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
1963
+ const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
1643
1964
  if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
1644
1965
  acc.lastScheduledBarTs = latest;
1645
1966
  saveRawAccounts(list);
@@ -1704,6 +2025,11 @@ async function tickBrokerAutoRefresh() {
1704
2025
  }
1705
2026
 
1706
2027
  export function startBrokerScheduler() {
2028
+ ensureBoardSpecs().catch(() => {});
2029
+ // В фоне после старта сервера сразу прогреваем прогноз, чтобы при открытии вкладки «Прогноз сейчас» он уже был в памяти
2030
+ setTimeout(() => {
2031
+ forecastNow(getSettings()).catch(() => {});
2032
+ }, 800);
1707
2033
  setTimeout(resumeBrokerAccountsOnBoot, 2500);
1708
2034
  const timer = setInterval(tickBrokerSchedule, 60000);
1709
2035
  const syncTimer = setInterval(tickBrokerAutoRefresh, 120000);