neuroalpha 0.3.14 → 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.
@@ -14,17 +14,67 @@ import { round, MSK, DAY } from '../util.js';
14
14
  import { hub } from '../hub.js';
15
15
 
16
16
  export const BROKER_META = [
17
- { id: 'tbank', name: 'Т-Банк (T-Invest API)', accountHint: 'ID счёта (оставьте пустым для выбора первого доступного)', tokenHint: 'Токен T-Invest API (t.…)' },
18
- { id: 'finam', name: 'Финам (Trade API)', accountHint: 'Код клиента (ClientId, например 123456R)', tokenHint: 'Токен Finam Trade API (CA…)' },
19
- { id: 'alor', name: 'Алор (OpenAPI)', accountHint: 'Портфель Мосбиржи (например D12345)', tokenHint: 'Refresh-токен Alor OpenAPI' },
20
- { id: 'bcs', name: 'БКС Мир инвестиций', accountHint: 'Номер соглашения / субсчёта (необязательно)', tokenHint: 'Refresh-токен БКС Trade API' },
17
+ {
18
+ id: 'tbank',
19
+ name: 'Т-Банк (T-Invest API)',
20
+ accountHint: 'ID счёта (или нажмите «Найти мои счета по токену»)',
21
+ tokenHint: 'Токен T-Invest API (t.…)',
22
+ },
23
+ {
24
+ id: 'finam',
25
+ name: 'Финам (Finam Trade API)',
26
+ accountHint: 'Код клиента (ClientId, например 123456R)',
27
+ tokenHint: 'Токен Finam Trade API (CA…)',
28
+ },
29
+ {
30
+ id: 'bcs',
31
+ name: 'БКС Мир инвестиций (Trade API)',
32
+ accountHint: 'Номер субсчёта / соглашения (необязательно)',
33
+ tokenHint: 'Refresh-токен БКС Trade API',
34
+ },
35
+ {
36
+ id: 'alor',
37
+ name: 'Алор Брокер (Alor OpenAPI v2)',
38
+ accountHint: 'Фондовый портфель Мосбиржи (например D12345)',
39
+ tokenHint: 'Refresh-токен Alor OpenAPI',
40
+ },
41
+ {
42
+ id: 'cifra',
43
+ name: 'Цифра брокер (Tradernet API)',
44
+ accountHint: 'Номер счёта Tradernet (необязательно)',
45
+ tokenHint: 'Ключи Tradernet в формате PublicKey:SecretKey',
46
+ },
47
+ {
48
+ id: 'iticapital',
49
+ name: 'ITI Capital (OpenAPI)',
50
+ accountHint: 'Код портфеля Мосбиржи (например BP12345)',
51
+ tokenHint: 'Refresh-токен ITI Capital OpenAPI',
52
+ },
53
+ {
54
+ id: 'sber',
55
+ name: 'СберИнвестиции (Сбербанк)',
56
+ accountHint: 'Номер брокерского счёта (например 400ABCD)',
57
+ tokenHint: 'URL моста QUIK (http://…) или оставьте пустым для режима подтверждения заявок',
58
+ },
59
+ {
60
+ id: 'vtb',
61
+ name: 'ВТБ Мои Инвестиции',
62
+ accountHint: 'Номер брокерского счёта / субсчёта ВТБ',
63
+ tokenHint: 'URL моста QUIK (http://…) или оставьте пустым для режима подтверждения заявок',
64
+ },
65
+ {
66
+ id: 'alfa',
67
+ name: 'Альфа-Инвестиции',
68
+ accountHint: 'Номер брокерского счёта Альфа-Инвестиций',
69
+ tokenHint: 'URL моста QUIK (http://…) или оставьте пустым для режима подтверждения заявок',
70
+ },
21
71
  ];
22
72
 
23
73
  // ---------- справочник лотов и шага цены Мосбиржи (TQBR) ----------
24
74
  const SPECS_URL = 'https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=securities,marketdata';
25
75
  export async function ensureBoardSpecs() {
26
76
  const cached = kvGet('moexSpecs');
27
- 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;
28
78
  try {
29
79
  const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
30
80
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
@@ -45,9 +95,10 @@ export async function ensureBoardSpecs() {
45
95
  const minStep = Number(row[is('MINSTEP')]) > 0 ? Number(row[is('MINSTEP')]) : 0.01;
46
96
  const decimals = Math.max(0, Number(row[is('DECIMALS')]) || 0);
47
97
  const prev = Number(row[is('PREVPRICE')] || row[is('PREVADMITTEDQUOTE')] || 0);
48
- 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 };
49
100
  }
50
- kvSet('moexSpecs', { at: Date.now(), specs });
101
+ kvSet('moexSpecs', { v: 2, at: Date.now(), specs });
51
102
  return specs;
52
103
  } catch {
53
104
  return cached?.specs || {};
@@ -94,13 +145,24 @@ function saveRawAccounts(list) {
94
145
  }
95
146
 
96
147
  function publicAccount(a) {
97
- const { token, ...rest } = a;
98
148
  let baseRunLabel = null;
99
149
  if (a.baseRunId) {
100
150
  const r = getRun(Number(a.baseRunId));
101
151
  if (r) baseRunLabel = `№${r.id}${r.params?._title ? ` (${r.params._title})` : ''}`;
102
152
  }
103
- 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 };
104
166
  }
105
167
 
106
168
  export function brokersState() {
@@ -154,9 +216,12 @@ export function saveBrokerAccount(input = {}) {
154
216
  mode,
155
217
  legacyMode,
156
218
  baselineQty: acctChanged ? null : (prev.baselineQty || null),
219
+ initialTotalRub: acctChanged ? 0 : (prev.initialTotalRub || 0),
157
220
  tbankAccountName: acctChanged ? '' : (prev.tbankAccountName || ''),
158
221
  equityHistory: acctChanged ? [] : (prev.equityHistory || []),
159
222
  lastHistory: acctChanged ? null : (prev.lastHistory || null),
223
+ entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
224
+ exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
160
225
  baseRunId: nextBaseRunId,
161
226
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
162
227
  maxCapitalRub,
@@ -176,7 +241,8 @@ export function saveBrokerAccount(input = {}) {
176
241
  const st = brokersState();
177
242
  hub.emit({ type: 'brokers:update', state: st });
178
243
  // При изменении стратегии, лимита капитала или режима счёта сразу пересинхронизируем счёт и пересчитываем заявки под новые настройки
179
- if (strategyOrCapChanged && token) {
244
+ const canAutoInit = Boolean(token) || ['sber', 'vtb', 'alfa'].includes(broker);
245
+ if (strategyOrCapChanged && canAutoInit) {
180
246
  const activeUser = currentUser();
181
247
  setImmediate(() => {
182
248
  runWithUser(activeUser, async () => {
@@ -312,18 +378,94 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
312
378
  return cache;
313
379
  }
314
380
 
381
+ // Загружает и кэширует на 12 часов справочник доступности шортов, UID и точных параметров лота/шага цены по тикерам в Т-Банке (TQBR + SPBRU для SPBE)
382
+ async function ensureTbankShortableMap(acc) {
383
+ if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
384
+ const cached = kvGet('tbankShortableMap');
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
+ }
391
+ try {
392
+ const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
393
+ const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
394
+ const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/Shares`, {
395
+ method: 'POST',
396
+ headers,
397
+ body: JSON.stringify({ instrumentStatus: 'INSTRUMENT_STATUS_BASE' }),
398
+ timeoutMs: 15000,
399
+ });
400
+ if (!r.ok) return cached?.map || null;
401
+ const j = await r.json();
402
+ const map = {};
403
+ const uidMap = { ...(cached?.uidMap || {}) };
404
+ const specMap = { ...(cached?.specMap || {}) };
405
+ for (const inst of j.instruments || []) {
406
+ if (!inst.ticker || inst.apiTradeAvailableFlag === false) continue;
407
+ if (inst.classCode === 'TQBR' || (inst.classCode === 'SPBRU' && !(inst.ticker in map))) {
408
+ map[inst.ticker] = Boolean(inst.shortEnabledFlag);
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
+ }
425
+ }
426
+ }
427
+ kvSet('tbankShortableMap', { v: 3, at: Date.now(), map, uidMap, specMap });
428
+ return map;
429
+ } catch {
430
+ return cached?.map || null;
431
+ }
432
+ }
433
+
315
434
  async function resolveTbankInstrumentId(base, headers, symbol) {
316
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
+ }
317
458
  try {
318
- 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`, {
319
460
  method: 'POST',
320
461
  headers,
321
- body: JSON.stringify({ idType: 'INSTRUMENT_ID_TYPE_TICKER', classCode: 'TQBR', id: symbol }),
462
+ body: JSON.stringify({ query: symbol }),
322
463
  timeoutMs: 10000,
323
464
  });
324
465
  if (r.ok) {
325
466
  const j = await r.json();
326
- 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;
327
469
  if (uid) { tbankUidCache.set(symbol, uid); return uid; }
328
470
  }
329
471
  } catch { /* fallback */ }
@@ -394,9 +536,43 @@ async function syncTbank(acc) {
394
536
  marginEnabled = !mTxt.includes('30051') && !/margin.*disabled/i.test(mTxt) ? false : false;
395
537
  }
396
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
+ }
397
570
  const totalRub = round(qVal(p.totalAmountPortfolio), 2);
398
571
  const rawPositions = p.positions || [];
399
- 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);
400
576
  const metaMap = await resolveTbankMetaMap(base, headers, lookupIds);
401
577
 
402
578
  const positions = [];
@@ -410,7 +586,7 @@ async function syncTbank(acc) {
410
586
  const qty = qVal(pos.quantity);
411
587
  const px = qVal(pos.currentPrice);
412
588
  if (isRubCashPos(pos, rawTicker)) {
413
- cashRub += qty;
589
+ cashRub += Math.max(0, qty);
414
590
  continue;
415
591
  }
416
592
  if (qty === 0) continue;
@@ -446,23 +622,28 @@ async function syncTbank(acc) {
446
622
  }
447
623
 
448
624
  const activeOrders = [];
449
- if (rOrd?.ok) {
450
- const jOrd = await rOrd.json();
451
- for (const o of jOrd.orders || []) {
452
- const m = metaMap[o.figi] || metaMap[o.instrumentUid] || {};
453
- activeOrders.push({
454
- orderId: o.orderId,
455
- symbol: m.ticker || o.figi || '—',
456
- name: m.name || '',
457
- direction: String(o.direction || '').includes('BUY') ? 'buy' : 'sell',
458
- lotsRequested: Number(o.lotsRequested || 0),
459
- lotsExecuted: Number(o.lotsExecuted || 0),
460
- price: round(qVal(o.initialSecurityPrice), 4),
461
- amountRub: round(qVal(o.initialOrderPrice), 2),
462
- orderDate: o.orderDate || null,
463
- });
464
- }
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
+ });
465
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);
466
647
 
467
648
  const breakdown = {
468
649
  sharesRub: round(qVal(p.totalAmountShares), 0),
@@ -667,11 +848,109 @@ async function syncBcs(acc) {
667
848
  return { accountId: acc.accountId || 'основной', totalRub: round(totalRub, 2), cashRub: round(cashRub, 2), positions };
668
849
  }
669
850
 
851
+ async function syncCifra(acc) {
852
+ const [apiKey, secKey] = String(acc.token || '').split(':').map((s) => s.trim());
853
+ if (!apiKey || !secKey) throw new Error('Для Цифра брокер (Tradernet API) укажите ключи в формате PublicKey:SecretKey');
854
+ const qObj = { cmd: 'getPositionJson', params: {} };
855
+ const qStr = `q=${JSON.stringify(qObj)}`;
856
+ const sig = crypto.createHmac('sha256', secKey).update(qStr).digest('hex');
857
+ const r = await fetch('https://tradernet.ru/api/v2/cmd/getPositionJson', {
858
+ method: 'POST',
859
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-NtApi-PublicKey': apiKey, 'X-NtApi-Sig': sig },
860
+ body: qStr,
861
+ signal: AbortSignal.timeout(15000),
862
+ });
863
+ if (!r.ok) throw new Error(`Tradernet API: HTTP ${r.status}`);
864
+ const j = await r.json();
865
+ const ps = j?.result?.ps || {};
866
+ const accList = Array.isArray(ps.acc) ? ps.acc : [];
867
+ const rubAcc = accList.find((c) => c.curr === 'RUR' || c.curr === 'RUB') || accList[0] || {};
868
+ const cashRub = round(Number(rubAcc.s || 0), 2);
869
+ const posRaw = Array.isArray(ps.pos) ? ps.pos : [];
870
+ let posSum = 0, pnlSum = 0;
871
+ const positions = posRaw.map((p) => {
872
+ const qty = Number(p.q || 0);
873
+ const price = round(Number(p.mkt_price || p.price_a || 0), 4);
874
+ const val = round(Number(p.s || qty * price), 2);
875
+ const pnl = round(Number(p.vm || 0), 2);
876
+ posSum += val;
877
+ pnlSum += pnl;
878
+ return { symbol: String(p.i || '').replace(/\..*$/, ''), name: p.name || p.i || '', qty, price, valueRub: val, pnlRub: pnl };
879
+ });
880
+ return { accountId: acc.accountId || 'Tradernet', marginEnabled: true, totalRub: round(cashRub + posSum, 2), cashRub, totalPnlRub: round(pnlSum, 2), positions };
881
+ }
882
+
883
+ async function syncItiCapital(acc) {
884
+ if (!acc.accountId) throw new Error('Укажите портфель ITI Capital (например BP12345)');
885
+ const oauth = acc.sandbox ? 'https://oauthdev.iticapital.ru' : 'https://oauth.iticapital.ru';
886
+ const api = acc.sandbox ? 'https://apidev.iticapital.ru' : 'https://api.iticapital.ru';
887
+ const rTok = await fetch(`${oauth}/refresh?token=${encodeURIComponent(acc.token)}`, { method: 'POST', signal: AbortSignal.timeout(15000) });
888
+ if (!rTok.ok) throw new Error(`ITI Capital OAuth: HTTP ${rTok.status}`);
889
+ const { AccessToken } = await rTok.json();
890
+ if (!AccessToken) throw new Error('ITI Capital не вернул AccessToken');
891
+ const headers = { Authorization: `Bearer ${AccessToken}` };
892
+ const [rSum, rPos] = await Promise.all([
893
+ fetch(`${api}/md/v2/Clients/MOEX/${encodeURIComponent(acc.accountId)}/summary`, { headers, signal: AbortSignal.timeout(15000) }),
894
+ fetch(`${api}/md/v2/Clients/MOEX/${encodeURIComponent(acc.accountId)}/positions`, { headers, signal: AbortSignal.timeout(15000) }),
895
+ ]);
896
+ if (!rSum.ok) throw new Error(`ITI Capital summary: HTTP ${rSum.status}`);
897
+ const sum = await rSum.json();
898
+ const posList = rPos.ok ? await rPos.json() : [];
899
+ let cashRub = round(Number(sum.buyingPower || sum.cash || 0), 2);
900
+ const positions = [];
901
+ for (const p of Array.isArray(posList) ? posList : []) {
902
+ if (p.isCurrency || p.symbol === 'RUB') { cashRub = round(Number(p.qtyUnits || p.qty || cashRub), 2); continue; }
903
+ const qty = Number(p.qtyUnits || p.qty || 0);
904
+ const val = Number(p.volume || 0);
905
+ positions.push({ symbol: p.symbol, qty, price: qty ? round(val / qty, 4) : 0, valueRub: round(val, 2), pnlRub: round(Number(p.unrealisedPl || 0), 2) });
906
+ }
907
+ return { accountId: acc.accountId, marginEnabled: true, totalRub: round(Number(sum.portfolioLiquidationValue || cashRub), 2), cashRub, positions };
908
+ }
909
+
910
+ async function syncQuikOrManual(acc) {
911
+ const tokenStr = String(acc.token || '').trim();
912
+ if (tokenStr.startsWith('http://') || tokenStr.startsWith('https://')) {
913
+ const base = tokenStr.replace(/\/+$/, '');
914
+ const r = await fetch(`${base}/portfolio?account=${encodeURIComponent(acc.accountId || '')}`, { signal: AbortSignal.timeout(12000) });
915
+ if (!r.ok) throw new Error(`QUIK HTTP-шлюз: HTTP ${r.status}`);
916
+ const j = await r.json();
917
+ const positions = (j.positions || []).map((p) => ({
918
+ symbol: p.symbol || p.secCode,
919
+ name: p.name || p.symbol || p.secCode,
920
+ qty: Number(p.qty || p.currentBal || 0),
921
+ price: round(Number(p.price || p.lastPrice || 0), 4),
922
+ valueRub: round(Number(p.valueRub || 0), 2),
923
+ pnlRub: round(Number(p.pnlRub || 0), 2),
924
+ }));
925
+ const cashRub = round(Number(j.cashRub ?? j.cash ?? acc.maxCapitalRub ?? 100000), 2);
926
+ const totalRub = round(Number(j.totalRub ?? (cashRub + positions.reduce((s, x) => s + x.valueRub, 0))), 2);
927
+ return { accountId: acc.accountId || 'QUIK', marginEnabled: Boolean(j.marginEnabled), totalRub, cashRub, positions };
928
+ }
929
+ // Режим подтверждения заявок (СберИнвестиции / ВТБ / Альфа без QUIK-шлюза): учёт позиций и лимита внутри платформы
930
+ const specs = await ensureBoardSpecs();
931
+ const prevPos = acc.lastSync?.positions || [];
932
+ let posVal = 0, pnlSum = 0;
933
+ const positions = prevPos.filter((p) => p.qty !== 0).map((p) => {
934
+ const px = specs[p.symbol]?.lastPrice || p.price || 0;
935
+ const avg = p.avgPrice || p.price || px;
936
+ const valueRub = round(p.qty * px, 2);
937
+ const pnlRub = round(p.qty * (px - avg), 2);
938
+ posVal += Math.abs(valueRub);
939
+ pnlSum += pnlRub;
940
+ return { ...p, avgPrice: avg, price: round(px, 4), valueRub, pnlRub };
941
+ });
942
+ const baseCap = acc.maxCapitalRub || 100000;
943
+ const cashRub = acc.lastSync?.cashRub !== undefined ? round(acc.lastSync.cashRub, 2) : round(Math.max(0, baseCap - posVal), 2);
944
+ const totalRub = round(cashRub + posVal, 2);
945
+ return { accountId: acc.accountId || 'Ручное подтверждение', marginEnabled: false, totalRub, cashRub, totalPnlRub: round(pnlSum, 2), positions };
946
+ }
947
+
670
948
  export async function syncBrokerAccount(id) {
671
949
  const list = loadRawAccounts();
672
950
  const acc = list.find((x) => x.id === id);
673
951
  if (!acc) throw new Error('Счёт не найден');
674
- if (!acc.token) throw new Error('У счёта не задан токен API');
952
+ const isManualCapable = ['sber', 'vtb', 'alfa'].includes(acc.broker);
953
+ if (!acc.token && !isManualCapable) throw new Error('У счёта не задан токен API');
675
954
  const today = new Date(Date.now() + MSK).toISOString().slice(0, 10);
676
955
  try {
677
956
  let data;
@@ -679,15 +958,48 @@ export async function syncBrokerAccount(id) {
679
958
  else if (acc.broker === 'finam') data = await syncFinam(acc);
680
959
  else if (acc.broker === 'alor') data = await syncAlor(acc);
681
960
  else if (acc.broker === 'bcs') data = await syncBcs(acc);
961
+ else if (acc.broker === 'cifra') data = await syncCifra(acc);
962
+ else if (acc.broker === 'iticapital') data = await syncItiCapital(acc);
963
+ else if (isManualCapable) data = await syncQuikOrManual(acc);
682
964
  else throw new Error(`Неизвестный брокер: ${acc.broker}`);
683
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
+ }
684
974
  if (data.accountName) acc.tbankAccountName = data.accountName;
685
975
  if ((!acc.name || acc.name === 'Т-Банк (T-Invest API)') && data.accountName) {
686
976
  acc.name = `Т-Банк · ${data.accountName}`;
687
977
  }
688
- // При первой синхронизации (или если старый снимок был пустым, а бумаги на счёте есть) запоминаем стартовый портфель
978
+ // При первой синхронизации запоминаем стартовый портфель и стартовую базу капитала;
979
+ // а при продаже старых бумаг (в режиме «Плавный переход» или вручную) автоматически уменьшаем baselineQty до фактического остатка,
980
+ // чтобы новые покупки стратегии по тем же тикерам никогда не путались со старым портфелем
689
981
  if (!acc.baselineQty || (!Object.keys(acc.baselineQty).length && data.positions?.length)) {
690
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;
691
1003
  }
692
1004
  const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
693
1005
  const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
@@ -695,7 +1007,13 @@ export async function syncBrokerAccount(id) {
695
1007
  if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
696
1008
  acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
697
1009
  } else if (ddPct >= acc.maxDailyDrawdownPct) {
698
- 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;
699
1017
  }
700
1018
  // История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
701
1019
  const hist = Array.isArray(acc.equityHistory) ? acc.equityHistory : [];
@@ -705,6 +1023,17 @@ export async function syncBrokerAccount(id) {
705
1023
  if (hist.length > 500) hist.splice(0, hist.length - 500);
706
1024
  }
707
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;
708
1037
  acc.lastSync = { at: nowTs, ok: true, error: null, day: today, dayStartRub, dayDrawdownPct: ddPct, ...data };
709
1038
  } catch (e) {
710
1039
  acc.lastSync = { ...(acc.lastSync || {}), at: Date.now(), ok: false, error: e.message };
@@ -715,6 +1044,7 @@ export async function syncBrokerAccount(id) {
715
1044
  saveRawAccounts(list);
716
1045
  const st = brokersState();
717
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 });
718
1048
  return st;
719
1049
  }
720
1050
 
@@ -753,51 +1083,106 @@ function sessionEdgeWarning(now = Date.now()) {
753
1083
 
754
1084
  // ---------- формирование и исполнение пакета лимитных заявок ----------
755
1085
  export async function prepareBrokerOrders(id) {
756
- const list = loadRawAccounts();
757
- const acc = list.find((x) => x.id === id);
1086
+ let list = loadRawAccounts();
1087
+ let acc = list.find((x) => x.id === id);
758
1088
  if (!acc) throw new Error('Счёт не найден');
759
1089
  if (acc.haltedReason) throw new Error(acc.haltedReason);
760
1090
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
761
1091
 
762
1092
  const plan = effectivePlan(currentUser());
763
1093
  if ((plan.maxBrokerAccounts ?? 0) < 1) {
764
- throw new Error(`На бесплатном тарифе «${plan.name}» работа с реальными брокерскими счетами недоступна (доступно тестирование в Экспериментах и виртуальный счёт). Оформите тариф «Инвестор», «Профи» или «VIP».`);
1094
+ throw new Error('Для работы с брокерскими счетами оформите подписку «Инвестор» (3 900 ₽/мес), «Профи» (9 900 ₽/мес) или «VIP» в разделе «Подписка и уведомления».');
765
1095
  }
766
- const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
767
- const params = normalizeParams(baseRun?.params || getSettings());
768
- const [fc, specs] = await Promise.all([
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;
1106
+ }
1107
+ const params = normalizeParams(getSettings());
1108
+ const [fc, rawSpecs, shortableMap] = await Promise.all([
769
1109
  forecastNow(params),
770
1110
  ensureBoardSpecs(),
1111
+ ensureTbankShortableMap(acc),
771
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
+ }
772
1120
  const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
773
1121
  const effectiveMaxCap = Math.min(acc.maxCapitalRub, capCeiling);
774
- const budget = Math.min(effectiveMaxCap, acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : effectiveMaxCap);
775
- 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
+ };
776
1148
  // Автоматическая подстройка направления и мощности торговли под тариф и реальные данные счёта:
777
1149
  const marginAllowed = acc.lastSync?.marginEnabled !== false;
778
1150
  const planAllowsShorts = Boolean(plan.allowShorts);
779
1151
  const vipBoost = Boolean(plan.allowVipBoost);
780
1152
  const rawDir = params.modelDirection || 'long';
781
1153
  const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
782
- const autoAdapt = params.modelAutoAdapt !== false;
783
1154
  const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
784
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);
785
1165
  const slots = dir === 'long' ? K : 2 * K;
786
1166
  // Фильтр мощности слота по тарифу:
787
- // • Инвестор (до 50% годовых): только покупки (без шортов), консервативный слот 1 / (K + 1) (до 22% капитала)
788
- // • Профи (до 100% годовых): покупки + шорты, стандартный равный слот 1 / (2K) (до 25% капитала, шаг раз в полный горизонт H)
789
- // • 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);
790
1172
  let slotRub;
791
- if (vipBoost && autoAdapt) {
792
- 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));
793
1178
  slotRub = budget * maxSlotWeight;
794
1179
  } else if (plan.id === 'pro') {
795
- slotRub = budget * Math.min(0.25, 1 / Math.max(2, slots));
1180
+ slotRub = budget * Math.min(0.28, 1.15 / Math.max(2, slots));
796
1181
  } else {
797
- 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));
798
1183
  }
799
- // Дополнительный мягкий дроссель при опережении годового потолка доходности тарифа (50% для Инвестора, 100% для Профи)
800
- 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) {
801
1186
  const firstPt = acc.equityHistory[0];
802
1187
  const lastPt = acc.equityHistory[acc.equityHistory.length - 1];
803
1188
  const elapsedDays = Math.max(7, (lastPt.ts - firstPt.ts) / DAY);
@@ -810,14 +1195,29 @@ export async function prepareBrokerOrders(id) {
810
1195
  }
811
1196
  }
812
1197
  const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
813
- 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
+ };
814
1204
  const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
815
1205
  const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
816
- const quarantined = new Set(baseRun?.extra?.adaptation?.quarantined || []);
1206
+ const quarantined = new Set();
817
1207
  const nb = params.newsFilter ? blockingNews(6) : null;
818
1208
 
819
- 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);
820
- const rawShortCands = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct).slice(0, K) : [];
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) : [];
1211
+ const nonShortableSkipped = [];
1212
+ const rawShortCands = [];
1213
+ for (const r of allShortSignals) {
1214
+ if (shortableMap && shortableMap[r.symbol] === false) {
1215
+ nonShortableSkipped.push(r);
1216
+ continue;
1217
+ }
1218
+ rawShortCands.push(r);
1219
+ if (rawShortCands.length >= K) break;
1220
+ }
821
1221
  const shortCands = (marginAllowed && planAllowsShorts) ? rawShortCands : [];
822
1222
 
823
1223
  const legacyMode = acc.legacyMode || 'isolate';
@@ -843,38 +1243,42 @@ export async function prepareBrokerOrders(id) {
843
1243
  const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
844
1244
  const aQty = algoQtyOf(sym, pos.qty);
845
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;
846
1250
  if (aQty > 0) {
847
1251
  const lots = Math.floor(aQty / sp.lotSize);
848
- const keep = r.liquid && r.expectedPct > exitThresholdPct;
1252
+ const keep = r.liquid && r.expectedPct > curExitThr;
849
1253
  if (lots >= 1 && !keep) {
850
1254
  const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
851
1255
  orders.push({
852
1256
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
853
1257
  refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
854
- reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${exitThresholdPct}%`,
1258
+ reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`,
855
1259
  });
856
1260
  closing.add(sym);
857
1261
  }
858
1262
  } else if (aQty < 0) {
859
1263
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
860
- const keep = dir !== 'long' && r.liquid && r.expectedPct < -exitThresholdPct;
1264
+ const keep = dir !== 'long' && r.liquid && r.expectedPct < -curExitThr;
861
1265
  if (lots >= 1 && !keep) {
862
1266
  const limitPrice = roundToStep(pos.price * (1 + acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
863
1267
  orders.push({
864
1268
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
865
1269
  refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
866
- reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${exitThresholdPct}%`,
1270
+ reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`,
867
1271
  });
868
1272
  closing.add(sym);
869
1273
  }
870
1274
  }
871
1275
  // В режиме «Плавный переход» (smooth) проверяем начальные акции:
872
- // если по старой акции вышел сильный сигнал падения (shortSignal), защищаем счёт и закрываем её;
873
- // иначе сохраняем в список кандидатов на плавное высвобождение капитала под новые покупки
1276
+ // 1) если по старой акции вышел сильный медвежий сигнал (ожидание ниже −needPct), сразу закрываем всю позицию для защиты счёта;
1277
+ // 2) иначе добавляем бумагу в пул плавной ротации (в первую очередь высвобождаются бумаги с отрицательным/слабым прогнозом под новые лонги и шорты)
874
1278
  if (legacyMode === 'smooth' && baseQty > 0 && !closing.has(sym)) {
875
1279
  const bLots = Math.floor(baseQty / sp.lotSize);
876
1280
  if (bLots >= 1 && !(r.liquid && r.expectedPct > exitThresholdPct)) {
877
- if (r.expectedPct < -needPct) {
1281
+ if (r.expectedPct <= -needPct) {
878
1282
  const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
879
1283
  orders.push({
880
1284
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots: bLots, shares: bLots * sp.lotSize,
@@ -895,63 +1299,95 @@ export async function prepareBrokerOrders(id) {
895
1299
  const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
896
1300
  let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
897
1301
 
898
- // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи и свободного лимита)
899
- 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));
900
- 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));
901
-
902
- // В режиме «Плавный переход», если под новые покупки не хватает свободных рублей, плавно высвобождаем из начального портфеля
903
- // (в первую очередь бумаги в плюсе или с наименьшим прогнозом, не более 2 бумаг за шаг)
904
- if (legacyMode === 'smooth' && newBuys.length > 0 && smoothSellPool.length > 0) {
905
- const neededRub = newBuys.reduce((s, r) => s + (params.modelAllocMode === 'invvol' && r.weight ? slotRub * r.weight : slotRub) * convOf(r), 0);
906
- 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;
907
1333
  if (shortfall > 1000) {
908
- smoothSellPool.sort((a, b) => ((b.pos.pnlRub >= 0 ? 1 : 0) - (a.pos.pnlRub >= 0 ? 1 : 0)) || (a.r.expectedPct - b.r.expectedPct));
909
- 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) {
910
1346
  if (shortfall <= 1000) break;
911
1347
  const limitPrice = roundToStep(item.pos.price * (1 - acc.limitOffsetPct / 100), item.sp.minStep, item.sp.decimals);
912
1348
  const lotVal = limitPrice * item.sp.lotSize;
913
1349
  const lotsToSell = Math.min(item.bLots, Math.max(1, Math.ceil(shortfall / lotVal)));
914
1350
  const amountRub = round(lotsToSell * item.sp.lotSize * limitPrice, 2);
1351
+ const sigReason = activeEntries.length > 0
1352
+ ? `под сигнал стратегии (${activeEntries.map((x) => x.symbol).join(', ')})`
1353
+ : 'упреждающее освобождение кэша из слабой бумаги';
915
1354
  orders.push({
916
1355
  symbol: item.sym, action: 'sell', lotSize: item.sp.lotSize, lots: lotsToSell, shares: lotsToSell * item.sp.lotSize,
917
1356
  refPrice: item.pos.price, limitPrice, amountRub,
918
- reason: `Плавный переход: высвобождение ${fmtRubSimple(amountRub)} ₽ из старого портфеля (ожидание ${item.r.expectedPct > 0 ? '+' : ''}${item.r.expectedPct}%) под новый сигнал покупки`,
1357
+ reason: `Плавный переход: высвобождение ${fmtRubSimple(amountRub)} ₽ из старого портфеля (прогноз ${item.r.expectedPct > 0 ? '+' : ''}${item.r.expectedPct}%) ${sigReason}`,
919
1358
  });
920
1359
  availRub += amountRub;
921
1360
  shortfall -= amountRub;
922
1361
  }
923
1362
  }
924
1363
  }
925
- const totalNew = newBuys.length + newShorts.length;
926
1364
  let remNew = totalNew;
927
-
928
- for (const r of newBuys) {
929
- const rawSum = (params.modelAllocMode === 'invvol' && r.weight ? slotRub * r.weight : slotRub) * convOf(r);
930
- const sum = Math.min(rawSum, availRub / Math.max(1, remNew));
931
- remNew--;
932
- const refPx = specs[r.symbol]?.lastPrice || 0;
933
- const ord = calcLotOrder(r.symbol, sum, refPx, 'buy', acc.limitOffsetPct, specs);
934
- if (ord) {
935
- availRub = Math.max(0, availRub - ord.amountRub);
936
- orders.push({
937
- ...ord,
938
- expectedPct: r.expectedPct,
939
- reason: `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`,
940
- });
941
- }
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));
942
1371
  }
943
- for (const r of newShorts) {
944
- const rawSum = (params.modelAllocMode === 'invvol' && r.weight ? slotRub * r.weight : slotRub) * convOf(r);
945
- 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);
946
1378
  remNew--;
947
- const refPx = specs[r.symbol]?.lastPrice || 0;
948
- 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);
949
1383
  if (ord) {
950
1384
  availRub = Math.max(0, availRub - ord.amountRub);
951
1385
  orders.push({
952
1386
  ...ord,
953
1387
  expectedPct: r.expectedPct,
954
- 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} ч`,
955
1391
  });
956
1392
  }
957
1393
  }
@@ -960,9 +1396,13 @@ export async function prepareBrokerOrders(id) {
960
1396
  let planThrottleNote = '';
961
1397
  if (plan.id === 'investor') {
962
1398
  const shInfo = rawShortCands.length ? ` (пропущены сигналы на шорт: ${rawShortCands.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
963
- 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 счетов) перейдите на тариф «Профи».`;
964
1401
  } else if (plan.id === 'pro') {
965
- 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% годовых, лимиты по согласованию): активны покупки и шорты с повышенной концентрацией слота.`;
966
1406
  }
967
1407
  if (!orders.length) {
968
1408
  if ((newBuys.length || newShorts.length) && availRub < 1500) {
@@ -971,12 +1411,13 @@ export async function prepareBrokerOrders(id) {
971
1411
  diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
972
1412
  } else if (!newBuys.length && !planAllowsShorts && rawShortCands.length > 0) {
973
1413
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
974
- diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 50% годовых). Чтобы зарабатывать и на падении акций (до 100% годовых), перейдите на тариф «Профи» или «VIP».`;
1414
+ diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 60% годовых). Чтобы зарабатывать и на падении акций (до 120% годовых), перейдите на тариф «Профи» или «VIP».`;
975
1415
  } else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
976
1416
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
977
1417
  diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
978
1418
  } else if (!newBuys.length && !newShorts.length) {
979
- diagNote = `На свече ${fc.bar} ни одна бумага не превысила порог выгоды +${fc.thresholdPct}% (текущие позиции соответствуют прогнозу).`;
1419
+ const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
1420
+ diagNote = `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
980
1421
  }
981
1422
  }
982
1423
 
@@ -1004,6 +1445,7 @@ export async function prepareBrokerOrders(id) {
1004
1445
  }
1005
1446
  const st = brokersState();
1006
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 });
1007
1449
  return st;
1008
1450
  }
1009
1451
 
@@ -1043,6 +1485,25 @@ async function sendOrderToBroker(acc, ord) {
1043
1485
  const isBuy = ord.action === 'buy' || ord.action === 'cover';
1044
1486
  let lotsToOrder = Number(ord.lots) || 0;
1045
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
+
1046
1507
  // Проверяем реальный доступный лимит лотов у брокера через GetMaxLots перед отправкой заявки
1047
1508
  if (!acc.sandbox && lotsToOrder > 0) {
1048
1509
  const rMax = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetMaxLots`, {
@@ -1059,7 +1520,7 @@ async function sendOrderToBroker(acc, ord) {
1059
1520
  if (Number.isFinite(maxLots)) {
1060
1521
  if (maxLots < 1) {
1061
1522
  if (ord.action === 'short') {
1062
- throw new Error('Короткая продажа (шорт) отклонена: на счёте отключена маржинальная торговля или исчерпан лимит шорта у брокера');
1523
+ throw new Error(`Короткая продажа (шорт) по бумаге ${ord.symbol} недоступна у брокера (бумага не входит в список маржинальных для шорта или исчерпан лимит займа)`);
1063
1524
  }
1064
1525
  throw new Error(`У брокера недостаточно доступного лимита для ${isBuy ? 'покупки' : 'продажи'} ${ord.symbol} (доступно 0 лотов)`);
1065
1526
  }
@@ -1153,6 +1614,97 @@ async function sendOrderToBroker(acc, ord) {
1153
1614
  const j = await r.json();
1154
1615
  return { ok: true, orderId: j.orderId || body.clientOrderId };
1155
1616
  }
1617
+ if (acc.broker === 'cifra') {
1618
+ const [apiKey, secKey] = String(acc.token || '').split(':').map((s) => s.trim());
1619
+ if (!apiKey || !secKey) throw new Error('Для Цифра брокер укажите ключи в формате PublicKey:SecretKey');
1620
+ const qObj = {
1621
+ cmd: 'putTradeOrder',
1622
+ params: {
1623
+ instr_name: `${ord.symbol}.MOEX`,
1624
+ action_id: ord.action === 'buy' || ord.action === 'cover' ? 1 : 3,
1625
+ order_type_id: 2,
1626
+ curr: 'RUR',
1627
+ limit_price: ord.limitPrice,
1628
+ qty: ord.shares || ord.lots,
1629
+ },
1630
+ };
1631
+ const qStr = `q=${JSON.stringify(qObj)}`;
1632
+ const sig = crypto.createHmac('sha256', secKey).update(qStr).digest('hex');
1633
+ const r = await fetch('https://tradernet.ru/api/v2/cmd/putTradeOrder', {
1634
+ method: 'POST',
1635
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-NtApi-PublicKey': apiKey, 'X-NtApi-Sig': sig },
1636
+ body: qStr,
1637
+ signal: AbortSignal.timeout(15000),
1638
+ });
1639
+ if (!r.ok) throw new Error(`Tradernet PostOrder HTTP ${r.status}`);
1640
+ const j = await r.json();
1641
+ return { ok: true, orderId: String(j.order_id || j.result?.order_id || 'ok') };
1642
+ }
1643
+ if (acc.broker === 'iticapital') {
1644
+ const oauth = acc.sandbox ? 'https://oauthdev.iticapital.ru' : 'https://oauth.iticapital.ru';
1645
+ const api = acc.sandbox ? 'https://apidev.iticapital.ru' : 'https://api.iticapital.ru';
1646
+ const rTok = await fetch(`${oauth}/refresh?token=${encodeURIComponent(acc.token)}`, { method: 'POST', signal: AbortSignal.timeout(15000) });
1647
+ if (!rTok.ok) throw new Error(`ITI Capital OAuth HTTP ${rTok.status}`);
1648
+ const { AccessToken } = await rTok.json();
1649
+ const body = {
1650
+ side: ord.action === 'buy' || ord.action === 'cover' ? 'buy' : 'sell',
1651
+ type: 'limit',
1652
+ quantity: ord.lots,
1653
+ price: ord.limitPrice,
1654
+ instrument: { symbol: ord.symbol, exchange: 'MOEX', instrumentGroup: 'TQBR' },
1655
+ user: { portfolio: acc.accountId },
1656
+ };
1657
+ const r = await fetch(`${api}/commandapi/warptrans/TRADE/v2/client/orders/actions/limit`, {
1658
+ method: 'POST',
1659
+ headers: { Authorization: `Bearer ${AccessToken}`, 'Content-Type': 'application/json', 'X-ALOR-REQID': crypto.randomUUID() },
1660
+ body: JSON.stringify(body),
1661
+ signal: AbortSignal.timeout(15000),
1662
+ });
1663
+ if (!r.ok) throw new Error(`ITI Capital PostOrder HTTP ${r.status}`);
1664
+ const j = await r.json();
1665
+ return { ok: true, orderId: j.orderNumber || 'ok' };
1666
+ }
1667
+ if (['sber', 'vtb', 'alfa'].includes(acc.broker)) {
1668
+ const tokenStr = String(acc.token || '').trim();
1669
+ if (tokenStr.startsWith('http://') || tokenStr.startsWith('https://')) {
1670
+ const base = tokenStr.replace(/\/+$/, '');
1671
+ const r = await fetch(`${base}/orders`, {
1672
+ method: 'POST',
1673
+ headers: { 'Content-Type': 'application/json' },
1674
+ body: JSON.stringify({ accountId: acc.accountId, ...ord }),
1675
+ signal: AbortSignal.timeout(12000),
1676
+ });
1677
+ if (!r.ok) throw new Error(`QUIK HTTP-шлюз PostOrder: HTTP ${r.status}`);
1678
+ const j = await r.json();
1679
+ return { ok: true, orderId: j.orderId || 'quik_ok' };
1680
+ }
1681
+ // Режим ручного подтверждения (СберИнвестиции / ВТБ / Альфа без QUIK): учитываем исполнение в портфеле платформы
1682
+ const prevPos = [...(acc.lastSync?.positions || [])];
1683
+ const isBuy = ord.action === 'buy' || ord.action === 'cover';
1684
+ const deltaQty = isBuy ? (ord.shares || ord.lots) : -(ord.shares || ord.lots);
1685
+ const deltaCash = isBuy ? -ord.amountRub : ord.amountRub;
1686
+ const pIdx = prevPos.findIndex((p) => p.symbol === ord.symbol);
1687
+ if (pIdx >= 0) {
1688
+ const cur = prevPos[pIdx];
1689
+ const nextQty = cur.qty + deltaQty;
1690
+ if (nextQty === 0) prevPos.splice(pIdx, 1);
1691
+ else prevPos[pIdx] = { ...cur, qty: nextQty, price: ord.limitPrice, valueRub: round(nextQty * ord.limitPrice, 2) };
1692
+ } else if (deltaQty !== 0) {
1693
+ prevPos.push({ symbol: ord.symbol, name: ord.symbol, instrumentType: 'share', qty: deltaQty, lots: ord.lots, avgPrice: ord.limitPrice, price: ord.limitPrice, valueRub: round(deltaQty * ord.limitPrice, 2), pnlRub: 0 });
1694
+ }
1695
+ const prevCash = acc.lastSync?.cashRub ?? (acc.maxCapitalRub || 100000);
1696
+ const nextCash = round(Math.max(0, prevCash + deltaCash), 2);
1697
+ const posSum = prevPos.reduce((s, p) => s + Math.abs(p.valueRub || 0), 0);
1698
+ acc.lastSync = {
1699
+ ...(acc.lastSync || {}),
1700
+ at: Date.now(),
1701
+ ok: true,
1702
+ cashRub: nextCash,
1703
+ totalRub: round(nextCash + posSum, 2),
1704
+ positions: prevPos,
1705
+ };
1706
+ return { ok: true, orderId: 'учтено в портфеле' };
1707
+ }
1156
1708
  throw new Error(`Неизвестный брокер: ${acc.broker}`);
1157
1709
  }
1158
1710
 
@@ -1165,10 +1717,31 @@ export async function executeBrokerOrders(id) {
1165
1717
  const batch = acc.pendingBatch;
1166
1718
  if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
1167
1719
  const results = [];
1168
- 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
+ }
1169
1734
  try {
1170
1735
  const r = await sendOrderToBroker(acc, ord);
1171
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
+ }
1172
1745
  } catch (e) {
1173
1746
  results.push({ symbol: ord.symbol, ok: false, error: e.message });
1174
1747
  }
@@ -1177,11 +1750,45 @@ export async function executeBrokerOrders(id) {
1177
1750
  batch.results = results;
1178
1751
  batch.executedAt = Date.now();
1179
1752
  saveRawAccounts(list);
1753
+ await syncBrokerAccount(id).catch(() => {});
1180
1754
  const st = brokersState();
1181
1755
  hub.emit({ type: 'brokers:update', state: st });
1182
1756
  return st;
1183
1757
  }
1184
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
+
1185
1792
  export function rejectBrokerOrders(id) {
1186
1793
  const list = loadRawAccounts();
1187
1794
  const acc = list.find((x) => x.id === id);
@@ -1348,13 +1955,12 @@ async function tickBrokerSchedule() {
1348
1955
  if ((plan.maxBrokerAccounts ?? 0) < 1) return;
1349
1956
  for (const acc of list) {
1350
1957
  if (acc.autoSchedule === false || acc.haltedReason) continue;
1351
- const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
1352
- const params = normalizeParams(baseRun?.params || getSettings());
1958
+ const params = normalizeParams(getSettings());
1353
1959
  const autoAdapt = params.modelAutoAdapt !== false;
1354
1960
  const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
1355
1961
  const H = barsFromHours(effHorizon);
1356
- // Внутричасовой перехват H/5 (каждые 30 мин) активен в режиме VIP (полный форсаж); на тарифах Инвестор и Профи шаг равен полному горизонту H
1357
- 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;
1358
1964
  if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
1359
1965
  acc.lastScheduledBarTs = latest;
1360
1966
  saveRawAccounts(list);
@@ -1419,6 +2025,11 @@ async function tickBrokerAutoRefresh() {
1419
2025
  }
1420
2026
 
1421
2027
  export function startBrokerScheduler() {
2028
+ ensureBoardSpecs().catch(() => {});
2029
+ // В фоне после старта сервера сразу прогреваем прогноз, чтобы при открытии вкладки «Прогноз сейчас» он уже был в памяти
2030
+ setTimeout(() => {
2031
+ forecastNow(getSettings()).catch(() => {});
2032
+ }, 800);
1422
2033
  setTimeout(resumeBrokerAccountsOnBoot, 2500);
1423
2034
  const timer = setInterval(tickBrokerSchedule, 60000);
1424
2035
  const syncTimer = setInterval(tickBrokerAutoRefresh, 120000);