neuroalpha 0.3.16 → 0.5.1

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.
Files changed (41) hide show
  1. package/README.md +0 -1
  2. package/config/certs/russian_trusted_root_ca.pem +33 -0
  3. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
  4. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -6
  5. package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +2 -2
  6. package/package.json +60 -53
  7. package/public/activate.html +36 -0
  8. package/public/activate.js +33 -0
  9. package/public/admin.html +126 -7
  10. package/public/app.js +608 -182
  11. package/public/index.html +57 -47
  12. package/public/landing.html +106 -37
  13. package/public/legal.html +4 -4
  14. package/public/styles.css +152 -36
  15. package/server/api/routes.js +308 -37
  16. package/server/auth/index.js +79 -15
  17. package/server/brokers/index.js +1022 -172
  18. package/server/build-info.js +7 -0
  19. package/server/config.js +13 -6
  20. package/server/data/moex.js +6 -4
  21. package/server/db.js +22 -18
  22. package/server/desktop-entry.js +103 -0
  23. package/server/hub.js +28 -14
  24. package/server/index.js +82 -12
  25. package/server/instance/agent.js +259 -0
  26. package/server/instance/central.js +491 -0
  27. package/server/instance/identity.js +79 -0
  28. package/server/instance/provision.js +166 -0
  29. package/server/instance/signing.js +71 -0
  30. package/server/model/edge.js +19 -0
  31. package/server/model/forecast.js +107 -57
  32. package/server/model/live.js +1 -1
  33. package/server/model/models.js +12 -3
  34. package/server/model/research.js +218 -30
  35. package/server/notify/index.js +51 -16
  36. package/server/params-meta.js +3 -3
  37. package/server/role.js +44 -0
  38. package/server/security.js +40 -0
  39. package/server/strategist/index.js +462 -488
  40. package/server/strategist/tools.js +675 -532
  41. package/server/tls-ca.js +32 -0
@@ -8,10 +8,16 @@ import { getSettings, normalizeParams } from '../settings.js';
8
8
  import { latestBarTs } from '../data/moex.js';
9
9
  import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
10
10
  import { forecastNow } from '../model/forecast.js';
11
+ import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/edge.js';
11
12
  import { MODELS } from '../model/models.js';
12
13
  import { blockingNews } from '../news/index.js';
13
- import { round, MSK, DAY } from '../util.js';
14
+ import { round, MSK, DAY, fmtBarMsk } from '../util.js';
14
15
  import { hub } from '../hub.js';
16
+ import { TRUSTED_CA } from '../tls-ca.js';
17
+ import { tradingGate } from '../instance/agent.js';
18
+
19
+ export const FULLY_SUPPORTED_BROKERS = ['tbank'];
20
+ const MANUAL_BROKERS = ['sber', 'vtb', 'alfa'];
15
21
 
16
22
  export const BROKER_META = [
17
23
  {
@@ -111,13 +117,24 @@ export function roundToStep(price, minStep = 0.01, decimals = 2) {
111
117
  return Number(v.toFixed(Math.max(0, Math.min(6, decimals))));
112
118
  }
113
119
 
114
- export function calcLotOrder(symbol, targetSumRub, refPrice, action, offsetPct = 0.1, specs = {}) {
120
+ // Снайперский расчёт лимитной цены (1-tick Limit):
121
+ // отталкивается от живой секундной цены basePx и сдвигает её ровно на 1 минимальный шаг цены (minStep),
122
+ // но не более заданного offsetPct, чтобы не переплачивать маркетмейкерам за пересечение спреда!
123
+ export function sniperLimitPrice(basePx, dir, offsetPct = 0.02, sp = { minStep: 0.01, decimals: 2 }) {
124
+ const step = sp.minStep > 0 ? sp.minStep : 0.01;
125
+ const maxOffRatio = Math.max(0, Math.min(0.0005, (Number(offsetPct) || 0.02) / 100));
126
+ const byPct = basePx * (1 + dir * maxOffRatio);
127
+ const byOneTick = basePx + dir * step;
128
+ const rawLimit = dir > 0 ? Math.min(byPct, byOneTick) : Math.max(byPct, byOneTick);
129
+ return roundToStep(rawLimit, step, sp.decimals);
130
+ }
131
+
132
+ export function calcLotOrder(symbol, targetSumRub, refPrice, action, offsetPct = 0.02, specs = {}) {
115
133
  const sp = specs[symbol] || { lotSize: 1, minStep: 0.01, decimals: 2, lastPrice: 0 };
116
134
  const basePx = refPrice > 0 ? refPrice : sp.lastPrice;
117
135
  if (!(basePx > 0)) return null;
118
136
  const dir = action === 'buy' || action === 'cover' ? 1 : -1;
119
- const rawLimit = basePx * (1 + dir * (Number(offsetPct) || 0) / 100);
120
- const limitPrice = roundToStep(rawLimit, sp.minStep, sp.decimals);
137
+ const limitPrice = sniperLimitPrice(basePx, dir, offsetPct, sp);
121
138
  const lotCost = limitPrice * sp.lotSize;
122
139
  const lots = Math.floor(targetSumRub / lotCost);
123
140
  if (lots < 1) return null;
@@ -134,14 +151,132 @@ export function calcLotOrder(symbol, targetSumRub, refPrice, action, offsetPct =
134
151
  };
135
152
  }
136
153
 
137
- // ---------- хранение счетов (с изоляцией по user_id и AES-256-GCM шифрованием токенов) ----------
154
+ // ---------- журнал исполнения: заявки, ошибки, стопы, ручные действия (по владельцу счёта) ----------
155
+ db.exec(`CREATE TABLE IF NOT EXISTS broker_events (
156
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
157
+ ts INTEGER NOT NULL,
158
+ user_id INTEGER NOT NULL,
159
+ account_id TEXT NOT NULL,
160
+ kind TEXT NOT NULL,
161
+ text TEXT NOT NULL
162
+ );
163
+ CREATE INDEX IF NOT EXISTS broker_events_acc ON broker_events(user_id, account_id, ts);`);
164
+
165
+ function logBrokerEvent(acc, kind, text) {
166
+ try {
167
+ const uid = currentUserId();
168
+ db.prepare('INSERT INTO broker_events (ts, user_id, account_id, kind, text) VALUES (?, ?, ?, ?, ?)')
169
+ .run(Date.now(), uid, String(acc?.id || ''), String(kind), String(text || '').slice(0, 500));
170
+ if (/error|stop|halt/.test(kind)) console.warn(`[брокер ${acc?.name || acc?.id}] ${kind}: ${text}`);
171
+ // храним последние 5000 событий на счёт
172
+ if (Math.random() < 0.02) {
173
+ db.prepare(`DELETE FROM broker_events WHERE user_id = ? AND account_id = ? AND id NOT IN
174
+ (SELECT id FROM broker_events WHERE user_id = ? AND account_id = ? ORDER BY id DESC LIMIT 5000)`).run(uid, String(acc?.id || ''), uid, String(acc?.id || ''));
175
+ }
176
+ } catch { /* журнал не должен ломать торговлю */ }
177
+ }
178
+
179
+ export function listBrokerEvents(accountId, limit = 200) {
180
+ return db.prepare('SELECT ts, kind, text FROM broker_events WHERE user_id = ? AND account_id = ? ORDER BY id DESC LIMIT ?')
181
+ .all(currentUserId(), String(accountId), Math.max(1, Math.min(1000, Number(limit) || 200)));
182
+ }
183
+
184
+ // ---------- хранение счетов: строка на счёт, изоляция по user_id, AES-256-GCM для токенов ----------
185
+ // Синхронизация, пересчёт заявок, исполнение и сохранение настроек работают параллельно и между загрузкой
186
+ // и записью ждут ответа брокера. Поэтому при сохранении пишутся ТОЛЬКО поля, изменённые этим участком кода,
187
+ // поверх актуальной версии строки в транзакции — чужие изменения (дневной стоп, режим, пакет заявок) не затираются.
188
+ db.exec(`CREATE TABLE IF NOT EXISTS broker_accounts (
189
+ user_id INTEGER NOT NULL,
190
+ id TEXT NOT NULL,
191
+ pos INTEGER NOT NULL DEFAULT 0,
192
+ data TEXT NOT NULL,
193
+ updated_at INTEGER NOT NULL,
194
+ PRIMARY KEY (user_id, id)
195
+ )`);
196
+
197
+ const SNAPSHOT = Symbol('brokerAccountsSnapshot');
198
+ const fieldSnapshot = (a) => Object.fromEntries(Object.entries(a).map(([k, v]) => [k, JSON.stringify(v)]));
199
+ const encryptAccount = (a) => ({ ...a, token: a.token ? encryptSecret(a.token) : '' });
200
+
201
+ // Однократный перенос из прежнего хранения (один JSON на пользователя в kv)
202
+ function migrateKvAccounts(uid) {
203
+ if (db.prepare('SELECT 1 FROM broker_accounts WHERE user_id = ? LIMIT 1').get(uid)) return;
204
+ if (userKvGet('brokerAccountsMigrated', false, uid)) return;
205
+ const old = userKvGet('brokerAccounts', null, uid);
206
+ if (Array.isArray(old) && old.length) {
207
+ const ins = db.prepare('INSERT OR IGNORE INTO broker_accounts (user_id, id, pos, data, updated_at) VALUES (?, ?, ?, ?, ?)');
208
+ db.exec('BEGIN IMMEDIATE');
209
+ try {
210
+ // encryptSecret не трогает уже зашифрованное значение и шифрует токен, если он почему-то лежал открытым текстом
211
+ old.forEach((a, i) => { if (a?.id) ins.run(uid, String(a.id), i, JSON.stringify({ ...a, token: a.token ? encryptSecret(a.token) : '' }), Date.now()); });
212
+ db.exec('COMMIT');
213
+ } catch (e) { db.exec('ROLLBACK'); throw e; }
214
+ // прежняя запись не удаляется: при откате на версию до 0.4.1 счета останутся на месте (в состоянии на момент переноса)
215
+ console.log(`✓ Брокерские счета пользователя ${uid} перенесены в отдельные записи (${old.length})`);
216
+ }
217
+ userKvSet('brokerAccountsMigrated', true, uid);
218
+ }
219
+
138
220
  function loadRawAccounts() {
139
- const list = userKvGet('brokerAccounts');
140
- if (!Array.isArray(list)) return [];
141
- return list.map((a) => ({ ...a, token: decryptSecret(a.token) }));
221
+ const uid = currentUserId();
222
+ migrateKvAccounts(uid);
223
+ const rows = db.prepare('SELECT data FROM broker_accounts WHERE user_id = ? ORDER BY pos, rowid').all(uid);
224
+ const list = [];
225
+ for (const r of rows) {
226
+ try {
227
+ const a = JSON.parse(r.data);
228
+ list.push({ ...a, token: decryptSecret(a.token) });
229
+ } catch { /* повреждённая запись пропускается */ }
230
+ }
231
+ Object.defineProperty(list, SNAPSHOT, { value: new Map(list.map((a) => [a.id, fieldSnapshot(a)])), writable: true, configurable: true, enumerable: false });
232
+ return list;
142
233
  }
234
+
143
235
  function saveRawAccounts(list) {
144
- userKvSet('brokerAccounts', list.map((a) => ({ ...a, token: a.token ? encryptSecret(a.token) : '' })));
236
+ const uid = currentUserId();
237
+ const snap = list[SNAPSHOT] || null;
238
+ const getRow = db.prepare('SELECT data FROM broker_accounts WHERE user_id = ? AND id = ?');
239
+ const upsert = db.prepare(`INSERT INTO broker_accounts (user_id, id, pos, data, updated_at) VALUES (?, ?, ?, ?, ?)
240
+ ON CONFLICT(user_id, id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`);
241
+ db.exec('BEGIN IMMEDIATE');
242
+ try {
243
+ list.forEach((a, i) => {
244
+ if (!a?.id) return;
245
+ const before = snap?.get(a.id);
246
+ const curRow = getRow.get(uid, a.id);
247
+ if (!before || !curRow) {
248
+ upsert.run(uid, a.id, i, JSON.stringify(encryptAccount(a)), Date.now());
249
+ return;
250
+ }
251
+ const now = fieldSnapshot(a);
252
+ const changed = Object.keys(now).filter((k) => now[k] !== before[k]);
253
+ const removed = Object.keys(before).filter((k) => !(k in now));
254
+ if (!changed.length && !removed.length) return;
255
+ const merged = JSON.parse(curRow.data);
256
+ for (const k of changed) merged[k] = k === 'token' ? (a.token ? encryptSecret(a.token) : '') : a[k];
257
+ for (const k of removed) delete merged[k];
258
+ upsert.run(uid, a.id, i, JSON.stringify(merged), Date.now());
259
+ });
260
+ db.exec('COMMIT');
261
+ } catch (e) {
262
+ db.exec('ROLLBACK');
263
+ throw e;
264
+ }
265
+ // следующее сохранение этого же списка сравнивается с только что записанным состоянием
266
+ Object.defineProperty(list, SNAPSHOT, { value: new Map(list.filter((a) => a?.id).map((a) => [a.id, fieldSnapshot(a)])), writable: true, configurable: true, enumerable: false });
267
+ }
268
+
269
+ export const __brokerStorageForTests = { loadRawAccounts: () => loadRawAccounts(), saveRawAccounts: (l) => saveRawAccounts(l), reconcileBrokerStops: (id) => reconcileBrokerStops(id) };
270
+
271
+ function deleteRawAccount(id) {
272
+ db.prepare('DELETE FROM broker_accounts WHERE user_id = ? AND id = ?').run(currentUserId(), String(id));
273
+ }
274
+
275
+ // Только счета (без токенов) — для технического состояния машины
276
+ export function brokerAccountsSummary(uid = 1) {
277
+ return db.prepare('SELECT data FROM broker_accounts WHERE user_id = ?').all(uid).map((r) => {
278
+ try { const { token: _t, ...a } = JSON.parse(r.data); return a; } catch { return null; }
279
+ }).filter(Boolean);
145
280
  }
146
281
 
147
282
  function publicAccount(a) {
@@ -162,13 +297,17 @@ function publicAccount(a) {
162
297
  }),
163
298
  };
164
299
  }
165
- return { ...a, lastSync, token: a.token || '', hasToken: Boolean(a.token && String(a.token).trim()), baseRunLabel };
300
+ // Токен брокера никогда не покидает сервер: в браузер, события WebSocket и ответы ИИ уходит только маска
301
+ const { token: rawToken, ...rest } = a;
302
+ const tok = String(rawToken || '').trim();
303
+ return { ...rest, lastSync, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
166
304
  }
167
305
 
168
306
  export function brokersState() {
169
307
  return {
170
308
  brokers: BROKER_META,
171
309
  accounts: loadRawAccounts().map(publicAccount),
310
+ trading: tradingGate(),
172
311
  };
173
312
  }
174
313
 
@@ -187,8 +326,20 @@ export function saveBrokerAccount(input = {}) {
187
326
  }
188
327
  const prev = idx >= 0 ? list[idx] : {};
189
328
  const broker = BROKER_META.some((b) => b.id === input.broker) ? input.broker : (prev.broker || 'tbank');
329
+ // СберИнвестиции / ВТБ / Альфа без моста QUIK не отправляют заявку брокеру, а лишь отмечают «исполнение» в платформе —
330
+ // новые такие счета не создаются (существующие продолжают работать до перевода на поддерживаемого брокера)
331
+ if (!prev.id && MANUAL_BROKERS.includes(broker) && process.env.ENABLE_EXPERIMENTAL_BROKERS !== '1') {
332
+ throw new Error('Подключение СберИнвестиций, ВТБ и Альфа-Инвестиций временно недоступно: у этих брокеров нет открытого API для заявок. Используйте Т-Банк.');
333
+ }
190
334
  const name = String(input.name ?? prev.name ?? '').trim() || BROKER_META.find((b) => b.id === broker).name;
191
- const mode = ['readonly', 'confirm', 'auto'].includes(input.mode) ? input.mode : (prev.mode || 'confirm');
335
+ let mode = ['readonly', 'confirm', 'auto'].includes(input.mode) ? input.mode : (prev.mode || 'confirm');
336
+ // Полный автомат — только у брокера с проверенной поддержкой: средняя цена (защитные стопы), отмена заявок и история операций
337
+ if (mode === 'auto' && !FULLY_SUPPORTED_BROKERS.includes(broker)) {
338
+ if (input.mode === 'auto') {
339
+ throw new Error(`Режим «Автомат» пока доступен только для Т-Банка. У брокера «${BROKER_META.find((b) => b.id === broker)?.name || broker}» нет средней цены позиций, отмены заявок и истории — защитные стопы не сработают. Выберите «Подтверждение заявок».`);
340
+ }
341
+ mode = 'confirm';
342
+ }
192
343
  const legacyMode = ['isolate', 'smooth', 'takeover'].includes(input.legacyMode) ? input.legacyMode : (prev.legacyMode || 'isolate');
193
344
  let token = prev.token || '';
194
345
  if (input.clearToken) token = '';
@@ -199,14 +350,15 @@ export function saveBrokerAccount(input = {}) {
199
350
  const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
200
351
  const maxCapitalRub = Math.min(rawMaxCap, capCeiling);
201
352
  const nextBaseRunId = input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null);
202
- const nextLimitOffset = Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.1)));
203
- const strategyOrCapChanged = idx < 0
204
- || acctChanged
205
- || nextBaseRunId !== (prev.baseRunId || null)
206
- || maxCapitalRub !== (prev.maxCapitalRub || 100000)
207
- || legacyMode !== (prev.legacyMode || 'isolate')
208
- || mode !== (prev.mode || 'confirm')
209
- || nextLimitOffset !== (prev.limitOffsetPct ?? 0.1);
353
+ const nextLimitOffset = Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.02)));
354
+ const strategyOrCapChanged = idx < 0
355
+ || acctChanged
356
+ || nextBaseRunId !== (prev.baseRunId || null)
357
+ || maxCapitalRub !== (prev.maxCapitalRub || 100000)
358
+ || legacyMode !== (prev.legacyMode || 'isolate')
359
+ || mode !== (prev.mode || 'confirm')
360
+ || nextLimitOffset !== (prev.limitOffsetPct ?? 0.02)
361
+ || (input.cashSweep !== undefined && Boolean(input.cashSweep) !== Boolean(prev.cashSweep));
210
362
  const next = {
211
363
  id,
212
364
  name,
@@ -222,6 +374,7 @@ export function saveBrokerAccount(input = {}) {
222
374
  lastHistory: acctChanged ? null : (prev.lastHistory || null),
223
375
  entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
224
376
  exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
377
+ peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
225
378
  baseRunId: nextBaseRunId,
226
379
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
227
380
  maxCapitalRub,
@@ -230,7 +383,16 @@ export function saveBrokerAccount(input = {}) {
230
383
  skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
231
384
  autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
232
385
  lastScheduledBarTs: strategyOrCapChanged ? 0 : (prev.lastScheduledBarTs || 0),
233
- haltedReason: input.resetHalt ? null : (prev.haltedReason || null),
386
+ // дневной стоп при сохранении настроек не снимается — только отдельным действием «Снять стоп до конца дня»
387
+ haltedReason: prev.haltedReason || null,
388
+ ddOverrideDay: prev.ddOverrideDay || null,
389
+ // защитные стоп-заявки у брокера (бета, только Т-Банк); выставленные заявки сохраняются при любом изменении настроек
390
+ brokerStops: broker === 'tbank' && (input.brokerStops !== undefined ? Boolean(input.brokerStops) : Boolean(prev.brokerStops)),
391
+ brokerStopBySymbol: prev.brokerStopBySymbol || {},
392
+ brokerStopCooldown: prev.brokerStopCooldown || {},
393
+ // авто-размещение свободных рублей в фонд ликвидности LQDT и авто-продажа из него под сигналы стратегии (только Т-Банк)
394
+ cashSweep: broker === 'tbank' && (input.cashSweep !== undefined ? Boolean(input.cashSweep) : Boolean(prev.cashSweep)),
395
+ ddBreachCount: prev.ddBreachCount || 0,
234
396
  token,
235
397
  lastSync: prev.lastSync || null,
236
398
  pendingBatch: strategyOrCapChanged ? null : (prev.pendingBatch || null),
@@ -254,14 +416,53 @@ export function saveBrokerAccount(input = {}) {
254
416
  return st;
255
417
  }
256
418
 
257
- export function deleteBrokerAccount(id) {
258
- const list = loadRawAccounts().filter((x) => x.id !== id);
419
+ // Осознанное снятие дневного стопа: до конца текущего дня (МСК) стоп по просадке на этом счёте больше не срабатывает.
420
+ // Блокировку за автоследование снять нельзя — она восстанавливается при каждой синхронизации.
421
+ export function resetBrokerDailyStop(id) {
422
+ const list = loadRawAccounts();
423
+ const acc = list.find((x) => x.id === id);
424
+ if (!acc) throw new Error('Счёт не найден');
425
+ if (!acc.haltedReason) throw new Error('Дневной стоп на этом счёте не активен');
426
+ if (!acc.haltedReason.startsWith('Сработал дневной стоп')) throw new Error('Эту блокировку снять нельзя: ' + acc.haltedReason);
427
+ const today = new Date(Date.now() + MSK).toISOString().slice(0, 10);
428
+ logBrokerEvent(acc, 'daily_stop_reset', `снят вручную до конца дня (${acc.haltedReason})`);
429
+ acc.haltedReason = null;
430
+ acc.ddBreachCount = 0;
431
+ acc.ddOverrideDay = today;
259
432
  saveRawAccounts(list);
260
433
  const st = brokersState();
261
434
  hub.emit({ type: 'brokers:update', state: st });
262
435
  return st;
263
436
  }
264
437
 
438
+ // Аварийная остановка всех счетов пользователя: авто-торговля на паузу, все активные заявки у брокера снимаются.
439
+ // Защитные стопы по открытым позициям продолжают работать.
440
+ export async function stopAllBrokerTrading() {
441
+ const list = loadRawAccounts();
442
+ for (const acc of list) {
443
+ acc.autoSchedule = false;
444
+ if (acc.pendingBatch && acc.pendingBatch.status === 'pending') acc.pendingBatch = null;
445
+ logBrokerEvent(acc, 'stop_all', 'аварийная остановка: авто-торговля на паузе, активные заявки снимаются');
446
+ }
447
+ saveRawAccounts(list);
448
+ const errors = [];
449
+ for (const acc of list) {
450
+ if (acc.broker === 'tbank' && acc.token) {
451
+ await cancelBrokerOrder(acc.id, 'all').catch((e) => errors.push(`${acc.name}: ${e.message}`));
452
+ }
453
+ }
454
+ const st = brokersState();
455
+ hub.emit({ type: 'brokers:update', state: st });
456
+ return { ...st, stopErrors: errors };
457
+ }
458
+
459
+ export function deleteBrokerAccount(id) {
460
+ deleteRawAccount(id);
461
+ const st = brokersState();
462
+ hub.emit({ type: 'brokers:update', state: st });
463
+ return st;
464
+ }
465
+
265
466
  // ---------- адаптеры REST API брокеров (Т-Банк, Финам, Алор, БКС) ----------
266
467
  // HTTPS-клиент с поддержкой российских сертификатов НУЦ Минцифры РФ (invest-public-api.tinkoff.ru и др.)
267
468
  function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
@@ -278,7 +479,8 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
278
479
  path: u.pathname + u.search,
279
480
  method,
280
481
  headers: reqHeaders,
281
- rejectUnauthorized: false,
482
+ ca: TRUSTED_CA,
483
+ rejectUnauthorized: true,
282
484
  timeout: timeoutMs,
283
485
  }, (res) => {
284
486
  const chunks = [];
@@ -378,11 +580,45 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
378
580
  return cache;
379
581
  }
380
582
 
583
+ // Возвращает живые секундные цены акций из T-Invest API (GetLastPrices, 0 секунд задержки), если у любого пользователя подключён счёт Т-Банка
584
+ export async function fetchTbankRealtimePrices() {
585
+ try {
586
+ // только токен владельца текущих данных — токены других пользователей не используются никогда
587
+ const tbAcc = loadRawAccounts().find((a) => a.broker === 'tbank' && a.token);
588
+ if (!tbAcc?.token) return null;
589
+ await ensureTbankShortableMap(tbAcc);
590
+ const uidMap = kvGet('tbankShortableMap')?.uidMap || {};
591
+ const entries = Object.entries(uidMap).filter(([, uid]) => Boolean(uid));
592
+ if (!entries.length) return null;
593
+ const uidToSym = new Map(entries.map(([sym, uid]) => [uid, sym]));
594
+ const instrumentIds = entries.map(([, uid]) => uid);
595
+ const base = tbAcc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
596
+ const headers = { Authorization: `Bearer ${tbAcc.token}`, 'Content-Type': 'application/json' };
597
+ const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetLastPrices`, {
598
+ method: 'POST',
599
+ headers,
600
+ body: JSON.stringify({ instrumentId: instrumentIds }),
601
+ timeoutMs: 5000,
602
+ });
603
+ if (!r.ok) return null;
604
+ const j = await r.json();
605
+ const prices = new Map();
606
+ for (const lp of j.lastPrices || []) {
607
+ const sym = uidToSym.get(lp.instrumentUid) || uidToSym.get(lp.figi);
608
+ const px = qVal(lp.price);
609
+ if (sym && px > 0) prices.set(sym, px);
610
+ }
611
+ return prices.size > 0 ? prices : null;
612
+ } catch {
613
+ return null;
614
+ }
615
+ }
616
+
381
617
  // Загружает и кэширует на 12 часов справочник доступности шортов, UID и точных параметров лота/шага цены по тикерам в Т-Банке (TQBR + SPBRU для SPBE)
382
618
  async function ensureTbankShortableMap(acc) {
383
619
  if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
384
620
  const cached = kvGet('tbankShortableMap');
385
- if (cached?.v === 3 && cached?.map && Date.now() - cached.at < 12 * 3600000) {
621
+ if (cached?.v === 5 && cached?.map && Date.now() - cached.at < 12 * 3600000) {
386
622
  if (cached.uidMap) {
387
623
  for (const [sym, uid] of Object.entries(cached.uidMap)) tbankUidCache.set(sym, uid);
388
624
  }
@@ -391,20 +627,29 @@ async function ensureTbankShortableMap(acc) {
391
627
  try {
392
628
  const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
393
629
  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
- });
630
+ const [r, rEtfs] = await Promise.all([
631
+ brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/Shares`, {
632
+ method: 'POST',
633
+ headers,
634
+ body: JSON.stringify({ instrumentStatus: 'INSTRUMENT_STATUS_BASE' }),
635
+ timeoutMs: 15000,
636
+ }),
637
+ brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/Etfs`, {
638
+ method: 'POST',
639
+ headers,
640
+ body: JSON.stringify({ instrumentStatus: 'INSTRUMENT_STATUS_BASE' }),
641
+ timeoutMs: 15000,
642
+ }).catch(() => null),
643
+ ]);
400
644
  if (!r.ok) return cached?.map || null;
401
645
  const j = await r.json();
646
+ const jEtfs = rEtfs?.ok ? await rEtfs.json().catch(() => null) : null;
402
647
  const map = {};
403
648
  const uidMap = { ...(cached?.uidMap || {}) };
404
649
  const specMap = { ...(cached?.specMap || {}) };
405
- for (const inst of j.instruments || []) {
650
+ for (const inst of [...(j.instruments || []), ...(jEtfs?.instruments || [])]) {
406
651
  if (!inst.ticker || inst.apiTradeAvailableFlag === false) continue;
407
- if (inst.classCode === 'TQBR' || (inst.classCode === 'SPBRU' && !(inst.ticker in map))) {
652
+ if (inst.classCode === 'TQBR' || inst.classCode === 'TQTF' || (inst.classCode === 'SPBRU' && !(inst.ticker in map))) {
408
653
  map[inst.ticker] = Boolean(inst.shortEnabledFlag);
409
654
  const uid = inst.uid || inst.figi;
410
655
  if (uid) {
@@ -413,9 +658,7 @@ async function ensureTbankShortableMap(acc) {
413
658
  }
414
659
  const minStep = qVal(inst.minPriceIncrement);
415
660
  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;
661
+ const decimals = Math.max(0, Math.min(6, Math.ceil(-Math.log10(minStep) - 1e-9)));
419
662
  specMap[inst.ticker] = {
420
663
  lotSize: Math.max(1, Number(inst.lot) || 1),
421
664
  minStep,
@@ -424,7 +667,7 @@ async function ensureTbankShortableMap(acc) {
424
667
  }
425
668
  }
426
669
  }
427
- kvSet('tbankShortableMap', { v: 3, at: Date.now(), map, uidMap, specMap });
670
+ kvSet('tbankShortableMap', { v: 5, at: Date.now(), map, uidMap, specMap });
428
671
  return map;
429
672
  } catch {
430
673
  return cached?.map || null;
@@ -464,7 +707,7 @@ async function resolveTbankInstrumentId(base, headers, symbol) {
464
707
  });
465
708
  if (r.ok) {
466
709
  const j = await r.json();
467
- const hit = (j.instruments || []).find((x) => x.ticker === symbol && x.apiTradeAvailableFlag === true && x.instrumentKind === 'INSTRUMENT_TYPE_SHARE');
710
+ const hit = (j.instruments || []).find((x) => x.ticker === symbol && x.apiTradeAvailableFlag === true && (x.instrumentKind === 'INSTRUMENT_TYPE_SHARE' || x.instrumentKind === 'INSTRUMENT_TYPE_ETF'));
468
711
  const uid = hit?.uid || hit?.figi;
469
712
  if (uid) { tbankUidCache.set(symbol, uid); return uid; }
470
713
  }
@@ -1006,10 +1249,11 @@ export async function syncBrokerAccount(id) {
1006
1249
  const ddPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
1007
1250
  if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
1008
1251
  acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
1009
- } else if (ddPct >= acc.maxDailyDrawdownPct) {
1252
+ } else if (ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
1010
1253
  // Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
1011
1254
  acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
1012
1255
  if (acc.ddBreachCount >= 2) {
1256
+ if (!acc.haltedReason) logBrokerEvent(acc, 'daily_stop', `просадка −${ddPct}% при пороге ${acc.maxDailyDrawdownPct}%`);
1013
1257
  acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
1014
1258
  }
1015
1259
  } else {
@@ -1024,16 +1268,46 @@ export async function syncBrokerAccount(id) {
1024
1268
  }
1025
1269
  acc.equityHistory = hist;
1026
1270
  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;
1271
+ const peakMap = { ...(acc.peakPriceBySymbol || {}) };
1272
+ const curPositions = (data.positions || []).filter((p) => p.qty !== 0);
1273
+ const curSymbols = new Set(curPositions.map((p) => p.symbol));
1274
+ const pendingOrderSymbols = new Set((data.activeOrders || []).map((o) => o.symbol).filter(Boolean));
1275
+ const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
1276
+ for (const p of curPositions) {
1277
+ const s = p.symbol;
1278
+ if (!entryMap[s]) {
1279
+ // Ищем реальное время последней открывающей сделки по этой бумаге в истории брокера
1280
+ const wantKind = p.qty > 0 ? 'buy' : 'sell';
1281
+ const lastEntryOp = histOps.find((op) => op.symbol === s && op.kind === wantKind && op.date > 0);
1282
+ // Если бумага только что появилась в портфеле (например, исполнилась лимитная заявка),
1283
+ // ставим реальное время сделки или nowTs (возраст 0 мин), чтобы 65-минутный буфер удержания всегда работал!
1284
+ entryMap[s] = lastEntryOp?.date || nowTs;
1285
+ }
1286
+ // Отслеживаем пиковую лучшую цену позиции с момента входа (для фиксации прибыли и защиты безубытка)
1287
+ const curPx = Number(p.price) || 0;
1288
+ const avgPx = Number(p.avgPrice) || curPx;
1289
+ if (curPx > 0) {
1290
+ if (p.qty > 0) {
1291
+ peakMap[s] = Math.max(Number(peakMap[s]) || avgPx || curPx, curPx);
1292
+ } else if (p.qty < 0) {
1293
+ const prevBest = Number(peakMap[s]) > 0 ? Number(peakMap[s]) : (avgPx || curPx);
1294
+ peakMap[s] = Math.min(prevBest, curPx);
1295
+ }
1296
+ }
1032
1297
  }
1033
1298
  for (const s of Object.keys(entryMap)) {
1034
- if (!curSymbols.has(s)) delete entryMap[s];
1299
+ // Не удаляем метку времени входа, если лимитная заявка по бумаге ещё стоит в стакане
1300
+ // или была отправлена менее 20 минут назад (чтобы не сбросить 65-минутный таймер до исполнения лимитки!)
1301
+ const isRecentEntryOrder = nowTs - entryMap[s] < 20 * 60000;
1302
+ if (!curSymbols.has(s) && !pendingOrderSymbols.has(s) && !isRecentEntryOrder) {
1303
+ delete entryMap[s];
1304
+ }
1305
+ }
1306
+ for (const s of Object.keys(peakMap)) {
1307
+ if (!curSymbols.has(s)) delete peakMap[s];
1035
1308
  }
1036
1309
  acc.entryTsBySymbol = entryMap;
1310
+ acc.peakPriceBySymbol = peakMap;
1037
1311
  acc.lastSync = { at: nowTs, ok: true, error: null, day: today, dayStartRub, dayDrawdownPct: ddPct, ...data };
1038
1312
  } catch (e) {
1039
1313
  acc.lastSync = { ...(acc.lastSync || {}), at: Date.now(), ok: false, error: e.message };
@@ -1082,7 +1356,12 @@ function sessionEdgeWarning(now = Date.now()) {
1082
1356
  }
1083
1357
 
1084
1358
  // ---------- формирование и исполнение пакета лимитных заявок ----------
1085
- export async function prepareBrokerOrders(id) {
1359
+ const accountBusyLocks = new Set();
1360
+
1361
+ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1362
+ if (accountBusyLocks.has(id)) return brokersState();
1363
+ accountBusyLocks.add(id);
1364
+ try {
1086
1365
  let list = loadRawAccounts();
1087
1366
  let acc = list.find((x) => x.id === id);
1088
1367
  if (!acc) throw new Error('Счёт не найден');
@@ -1094,8 +1373,8 @@ export async function prepareBrokerOrders(id) {
1094
1373
  throw new Error('Для работы с брокерскими счетами оформите подписку «Инвестор» (3 900 ₽/мес), «Профи» (9 900 ₽/мес) или «VIP» в разделе «Подписка и уведомления».');
1095
1374
  }
1096
1375
  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) {
1376
+ // При плановом шаге снимаем старые неисполненные заявки со стакана, а при быстром доборе (onlyEntries) не трогаем уже стоящие свежие заявки!
1377
+ if (!onlyEntries && acc.token && acc.mode !== 'readonly' && acc.broker === 'tbank' && acc.lastSync?.activeOrders?.length > 0) {
1099
1378
  await cancelBrokerOrder(id, 'all').catch(() => {});
1100
1379
  list = loadRawAccounts();
1101
1380
  acc = list.find((x) => x.id === id) || acc;
@@ -1151,49 +1430,73 @@ export async function prepareBrokerOrders(id) {
1151
1430
  const vipBoost = Boolean(plan.allowVipBoost);
1152
1431
  const rawDir = params.modelDirection || 'long';
1153
1432
  const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
1154
- const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
1433
+ // В реальном стакане брокера (где каждый вход-выход стоит 0.08% комиссии + спред) используем гистерезис удержания 0.58
1434
+ // (около +0.145%), чтобы не закрывать открытую позицию преждевременно при положительном прогнозе +0.16%…+0.23%!
1435
+ const exitRatio = autoAdapt ? 0.58 : Number(params.modelExitRatio ?? 0.5);
1155
1436
  const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
1156
- const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.35, 3);
1437
+ const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.25, 3);
1157
1438
  const nowTs = Date.now();
1158
1439
  const entryTsMap = acc.entryTsBySymbol || {};
1159
1440
  const exitCooldownMap = acc.exitCooldownBySymbol || {};
1160
1441
  const mskDate = new Date(nowTs + MSK);
1161
1442
  const isEveningSession = (mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes()) >= 19 * 60;
1162
1443
  const eveningBoost = (autoAdapt && isEveningSession) ? 1.25 : 1.0;
1163
- const minEntryEdgePct = round((fc.thresholdPct || 0) * eveningBoost, 3);
1444
+ // Спред-фильтр реального стакана по эшелонам ликвидности:
1445
+ // для 1-го эшелона требуем минимум базовый порог (≥ 0.50%), а для 2–3 эшелона (широкий спред стакана) — в 1.35× выше (≥ 0.675%)
1446
+ const baseBuyNeedPct = Number(fc.buyThresholdPct ?? fc.thresholdPct ?? 0.50);
1447
+ const baseShortNeedPct = Number(fc.shortThresholdPct ?? fc.thresholdPct ?? 0.50);
1448
+ const minBuyEdgePct = round(baseBuyNeedPct * eveningBoost, 3);
1449
+ const minShortEdgePct = round(baseShortNeedPct * eveningBoost, 3);
1450
+ const reqBuyEdgeOf = (sym) => round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30 * eveningBoost), 3);
1451
+ const reqShortEdgeOf = (sym) => round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31 * eveningBoost), 3);
1164
1452
  const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
1453
+ // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, как в research.js):
1454
+ // если за последние 14 дней по бумаге было >= 3 закрытых сделок на счёте и их суммарный итог отрицательный (< -1.0%),
1455
+ // временно исключаем такую бумагу из новых входов, чтобы не наступать на одни и те же грабли в неликвиде!
1456
+ const quarantined = new Set();
1457
+ if (autoAdapt && Array.isArray(acc.lastHistory?.operations)) {
1458
+ const cutoffTs = nowTs - 14 * DAY;
1459
+ const recentOps = acc.lastHistory.operations
1460
+ .filter((op) => op.date >= cutoffTs && op.symbol && (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'fee'))
1461
+ .sort((a, b) => a.date - b.date);
1462
+ const heldNowSet = new Set((acc.lastSync?.positions || []).filter((p) => p.qty !== 0).map((p) => p.symbol));
1463
+ const statBySym = {};
1464
+ for (const op of recentOps) {
1465
+ if (heldNowSet.has(op.symbol)) continue; // считаем только полностью закрытые цепочки сделок
1466
+ const st = statBySym[op.symbol] || (statBySym[op.symbol] = { buys: 0, sells: 0, buyRub: 0, netRub: 0 });
1467
+ const pay = Number(op.paymentRub) || 0;
1468
+ if (op.kind === 'buy') { st.buys++; st.buyRub += Math.abs(pay); st.netRub += pay; }
1469
+ else if (op.kind === 'sell') { st.sells++; st.netRub += pay; }
1470
+ else if (op.kind === 'fee') { st.netRub += pay; }
1471
+ }
1472
+ for (const [sym, st] of Object.entries(statBySym)) {
1473
+ const closedRounds = Math.min(st.buys, st.sells);
1474
+ if (closedRounds >= 3 && st.buyRub > 10000 && (st.netRub / st.buyRub) < -0.009) {
1475
+ quarantined.add(sym);
1476
+ }
1477
+ }
1478
+ }
1165
1479
  const slots = dir === 'long' ? K : 2 * K;
1166
1480
  // Фильтр мощности слота по тарифу:
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);
1481
+ // • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
1482
+ // • Профи: до 5 счетов по 1 млн ₽, покупки + шорты, базовый слот до 28% капитала
1483
+ // • VIP: счета и сумма по договору, покупки + шорты, слот до 48% капитала
1484
+ // • Хозяин: Conviction Sizing до 75% капитала
1485
+ const isOwnerPlan = Boolean(plan.isOwner || plan.id === 'owner');
1172
1486
  let slotRub;
1173
1487
  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));
1488
+ const maxSlotWeight = Math.min(0.45, Math.max(1 / K, 1.8 / K));
1175
1489
  slotRub = budget * maxSlotWeight;
1176
1490
  } 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));
1491
+ const maxSlotWeight = Math.min(0.40, Math.max(1 / K, 1.4 / K));
1178
1492
  slotRub = budget * maxSlotWeight;
1179
1493
  } else if (plan.id === 'pro') {
1180
1494
  slotRub = budget * Math.min(0.28, 1.15 / Math.max(2, slots));
1181
1495
  } else {
1182
1496
  slotRub = budget * Math.min(0.24, 1.05 / Math.max(3, K + 1));
1183
1497
  }
1184
- // Мягкий дроссель при опережении годового потолка доходности тарифа (60% Инвестор, 120% Профи, 240% VIP; у Хозяина = 0 — без потолка)
1185
- if (!isOwnerPlan && plan.maxAnnualReturnPct > 0 && Array.isArray(acc.equityHistory) && acc.equityHistory.length >= 2) {
1186
- const firstPt = acc.equityHistory[0];
1187
- const lastPt = acc.equityHistory[acc.equityHistory.length - 1];
1188
- const elapsedDays = Math.max(7, (lastPt.ts - firstPt.ts) / DAY);
1189
- if (firstPt.totalRub > 0 && lastPt.totalRub > firstPt.totalRub) {
1190
- const curGainPct = ((lastPt.totalRub - firstPt.totalRub) / firstPt.totalRub) * 100;
1191
- const targetGainPct = plan.maxAnnualReturnPct * (elapsedDays / 365);
1192
- if (curGainPct > targetGainPct) {
1193
- slotRub *= Math.max(0.25, targetGainPct / curGainPct);
1194
- }
1195
- }
1196
- }
1498
+ // (Потолки доходности по тарифам и «дроссель» размера позиций удалены: тарифы различаются возможностями,
1499
+ // а не обещанной доходностью — реалистичная доходность определяется стресс-тестом, а не тарифом.)
1197
1500
  const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
1198
1501
  const convOf = (r) => {
1199
1502
  if (!autoAdapt || !r.expectedPct) return 1;
@@ -1203,11 +1506,11 @@ export async function prepareBrokerOrders(id) {
1203
1506
  };
1204
1507
  const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
1205
1508
  const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
1206
- const quarantined = new Set();
1207
1509
  const nb = params.newsFilter ? blockingNews(6) : null;
1208
1510
 
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) : [];
1511
+ const cooldownSkipped = fc.rows.filter((r) => ((r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))) && isInCooldown(r.symbol));
1512
+ const buyCands = fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad).sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1513
+ const allShortSignals = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct) : [];
1211
1514
  const nonShortableSkipped = [];
1212
1515
  const rawShortCands = [];
1213
1516
  for (const r of allShortSignals) {
@@ -1234,7 +1537,11 @@ export async function prepareBrokerOrders(id) {
1234
1537
  const orders = [];
1235
1538
  const closing = new Set();
1236
1539
  const smoothSellPool = [];
1237
- // 1) закрытие бумаг стратегии (покупок и шортов), чей прогноз ослаб ниже порога выхода exitThresholdPct
1540
+ const peakMap = acc.peakPriceBySymbol || {};
1541
+ const roundTripFeePct = 2 * ((Number(params.commissionPct) || 0.04) + (Number(params.slippagePct) || 0.01));
1542
+ // 1) закрытие бумаг стратегии (покупок и шортов):
1543
+ // • по фиксации реальной прибыли (Take-Profit / Trailing Stop от лучшей достигнутой цены peakPrice, чтобы не отдавать заработанный плюс в минус!),
1544
+ // • либо если прогноз ослаб ниже порога выхода curExitThr.
1238
1545
  // Облигации, фонды (LQDT/TMOS) и бумаги вне словаря MOEXBMI (!rowMap.has(sym)) не затрагиваются никогда
1239
1546
  for (const [sym, pos] of heldMap) {
1240
1547
  if (!pos.qty) continue;
@@ -1243,31 +1550,50 @@ export async function prepareBrokerOrders(id) {
1243
1550
  const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
1244
1551
  const aQty = algoQtyOf(sym, pos.qty);
1245
1552
  const baseQty = pos.qty - aQty;
1246
- const heldMs = entryTsMap[sym] ? (nowTs - entryTsMap[sym]) : (120 * 60000);
1553
+ const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
1554
+ const wantOpenKind = aQty >= 0 ? 'buy' : 'sell';
1555
+ const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenKind && op.date > 0);
1556
+ const lastHistEntryTs = lastHistEntryOp?.date || 0;
1557
+ const effEntryTs = Math.max(entryTsMap[sym] || 0, lastHistEntryTs);
1558
+ const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
1247
1559
  // Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
1248
1560
  // чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95)
1249
1561
  const curExitThr = (autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct;
1562
+ const curPx = Number(r.price || pos.price) || 0;
1563
+ const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
1250
1564
  if (aQty > 0) {
1251
1565
  const lots = Math.floor(aQty / sp.lotSize);
1252
- const keep = r.liquid && r.expectedPct > curExitThr;
1566
+ const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1567
+ const hitHardStopLoss = curGainPct <= -2.6;
1568
+ const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1569
+ const keep = keepByForecast && !hitHardStopLoss;
1253
1570
  if (lots >= 1 && !keep) {
1254
- const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
1571
+ const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
1572
+ const exitReason = hitHardStopLoss
1573
+ ? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
1574
+ : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1255
1575
  orders.push({
1256
1576
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1257
- refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1258
- reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`,
1577
+ refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1578
+ reason: exitReason,
1259
1579
  });
1260
1580
  closing.add(sym);
1261
1581
  }
1262
1582
  } else if (aQty < 0) {
1263
1583
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1264
- const keep = dir !== 'long' && r.liquid && r.expectedPct < -curExitThr;
1584
+ const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1585
+ const hitHardStopLoss = curGainPct <= -2.2;
1586
+ const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1587
+ const keep = keepByForecast && !hitHardStopLoss;
1265
1588
  if (lots >= 1 && !keep) {
1266
- const limitPrice = roundToStep(pos.price * (1 + acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
1589
+ const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
1590
+ const exitReason = hitHardStopLoss
1591
+ ? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
1592
+ : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1267
1593
  orders.push({
1268
1594
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1269
- refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1270
- reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`,
1595
+ refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1596
+ reason: exitReason,
1271
1597
  });
1272
1598
  closing.add(sym);
1273
1599
  }
@@ -1279,7 +1605,7 @@ export async function prepareBrokerOrders(id) {
1279
1605
  const bLots = Math.floor(baseQty / sp.lotSize);
1280
1606
  if (bLots >= 1 && !(r.liquid && r.expectedPct > exitThresholdPct)) {
1281
1607
  if (r.expectedPct <= -needPct) {
1282
- const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
1608
+ const limitPrice = sniperLimitPrice(pos.price, -1, acc.limitOffsetPct, sp);
1283
1609
  orders.push({
1284
1610
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots: bLots, shares: bLots * sp.lotSize,
1285
1611
  refPrice: pos.price, limitPrice, amountRub: round(bLots * sp.lotSize * limitPrice, 2),
@@ -1292,31 +1618,88 @@ export async function prepareBrokerOrders(id) {
1292
1618
  }
1293
1619
  }
1294
1620
  }
1295
- const activeLongs = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) > 0 && !closing.has(p.symbol)).length;
1296
- const activeShorts = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol)).length;
1297
- const usedRub = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) !== 0 && !closing.has(p.symbol)).reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
1621
+ const activePendingOrders = acc.lastSync?.activeOrders || [];
1622
+ const activeOrderSymbols = new Set(activePendingOrders.map((o) => o.symbol).filter(Boolean));
1623
+ const pendingBuyOrdersCount = activePendingOrders.filter((o) => o.direction === 'buy').length;
1624
+ const pendingSellOrdersCount = activePendingOrders.filter((o) => o.direction === 'sell').length;
1625
+ let activeLongs = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) > 0 && !closing.has(p.symbol)).length + (onlyEntries ? pendingBuyOrdersCount : 0);
1626
+ let activeShorts = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol)).length + (onlyEntries ? pendingSellOrdersCount : 0);
1627
+ let usedRub = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) !== 0 && !closing.has(p.symbol)).reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0)
1628
+ + (onlyEntries ? activePendingOrders.reduce((s, o) => s + Math.abs(Number(o.amountRub) || 0), 0) : 0);
1629
+
1630
+ // Ротация остывшей позиции в мощного нового лидера (Opportunity Rotation):
1631
+ // если на счёте занято >65% бюджета, а на рынке появился новый лидер с прогнозом >= +0.55% (в 2.4 раза сильнее текущей остывшей бумаги),
1632
+ // освобождаем слот под лидера, чтобы капитал не застревал в вялых позициях
1633
+ if (!onlyEntries && autoAdapt && usedRub > budget * 0.65 && (buyCands.length > 0 || shortCands.length > 0)) {
1634
+ const bestNewPct = Math.max(
1635
+ buyCands.length ? Math.abs(buyCands[0].expectedPct || 0) : 0,
1636
+ shortCands.length ? Math.abs(shortCands[0].expectedPct || 0) : 0,
1637
+ );
1638
+ if (bestNewPct >= needPct * 2.2) {
1639
+ let weakestSym = null, weakestEdge = Infinity;
1640
+ for (const [sym, pos] of heldMap) {
1641
+ if (closing.has(sym) || !rowMap.has(sym)) continue;
1642
+ const aQ = algoQtyOf(sym, pos.qty);
1643
+ if (!aQ) continue;
1644
+ const rCur = rowMap.get(sym);
1645
+ const curSignedPct = aQ > 0 ? (rCur.expectedPct || 0) : -(rCur.expectedPct || 0);
1646
+ if (curSignedPct < exitThresholdPct * 0.90 && curSignedPct < weakestEdge && bestNewPct > Math.max(needPct, curSignedPct) * 2.4) {
1647
+ weakestEdge = curSignedPct;
1648
+ weakestSym = sym;
1649
+ }
1650
+ }
1651
+ if (weakestSym) {
1652
+ const wPos = heldMap.get(weakestSym);
1653
+ const wRow = rowMap.get(weakestSym);
1654
+ const wSp = specs[weakestSym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
1655
+ const wAq = algoQtyOf(weakestSym, wPos.qty);
1656
+ const wLots = Math.floor(Math.abs(wAq) / wSp.lotSize);
1657
+ const wPx = Number(wRow?.price || wPos.price) || 0;
1658
+ if (wLots >= 1 && wPx > 0) {
1659
+ const isLongPos = wAq > 0;
1660
+ const limPx = sniperLimitPrice(wPx, isLongPos ? -1 : 1, acc.limitOffsetPct, wSp);
1661
+ orders.push({
1662
+ symbol: weakestSym,
1663
+ action: isLongPos ? 'sell' : 'cover',
1664
+ lotSize: wSp.lotSize,
1665
+ lots: wLots,
1666
+ shares: wLots * wSp.lotSize,
1667
+ refPrice: wPx,
1668
+ limitPrice: limPx,
1669
+ amountRub: round(wLots * wSp.lotSize * limPx, 2),
1670
+ reason: `Ротация капитала из остывшей бумаги (${wRow?.expectedPct > 0 ? '+' : ''}${wRow?.expectedPct}%) в нового сильного лидера (${bestNewPct}%)`,
1671
+ });
1672
+ closing.add(weakestSym);
1673
+ if (isLongPos) activeLongs = Math.max(0, activeLongs - 1);
1674
+ else activeShorts = Math.max(0, activeShorts - 1);
1675
+ usedRub = Math.max(0, usedRub - Math.abs(wAq * wPos.price));
1676
+ }
1677
+ }
1678
+ }
1679
+ }
1298
1680
  const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
1299
1681
  const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
1300
1682
  let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
1301
1683
 
1302
1684
  // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
1303
- const rawNewBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol)));
1685
+ const rawNewBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol)) && !(onlyEntries && activeOrderSymbols.has(r.symbol)));
1304
1686
  // Не открываем шорт по тикеру, в котором ещё лежит непроданный остаток старого портфеля (baseQty > 0), чтобы брокер не превратил шорт в продажу старой позиции
1305
1687
  const rawNewShorts = shortCands.filter((r) => {
1688
+ if (onlyEntries && activeOrderSymbols.has(r.symbol)) return false;
1306
1689
  const curQ = heldMap.get(r.symbol)?.qty || 0;
1307
1690
  const aQ = algoQtyOf(r.symbol, curQ);
1308
1691
  const bQ = curQ - aQ;
1309
1692
  if (bQ > 0 && !closing.has(r.symbol)) return false;
1310
1693
  return !(aQ < 0 && !closing.has(r.symbol));
1311
1694
  });
1312
- const newBuys = filterBrokerDynamicTopK(rawNewBuys, minEntryEdgePct, Math.max(0, K - activeLongs));
1313
- const newShorts = filterBrokerDynamicTopK(rawNewShorts, minEntryEdgePct, Math.max(0, K - activeShorts));
1695
+ const newBuys = filterBrokerDynamicTopK(rawNewBuys, minBuyEdgePct, Math.max(0, K - activeLongs));
1696
+ const newShorts = filterBrokerDynamicTopK(rawNewShorts, minShortEdgePct, Math.max(0, K - activeShorts));
1314
1697
  const totalNew = newBuys.length + newShorts.length;
1315
1698
  const activeTotal = Math.max(2, activeLongs + activeShorts + totalNew);
1316
1699
  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)))
1700
+ ? budget * Math.min(0.45, Math.max(slotRub / Math.max(1, budget), 1.8 / Math.min(slots, activeTotal + 1)))
1318
1701
  : (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)))
1702
+ ? budget * Math.min(0.40, Math.max(slotRub / Math.max(1, budget), 1.5 / Math.min(slots, activeTotal + 1)))
1320
1703
  : slotRub;
1321
1704
 
1322
1705
  // В режиме «Плавный переход» (smooth):
@@ -1344,7 +1727,7 @@ export async function prepareBrokerOrders(id) {
1344
1727
  : smoothSellPool.filter((x) => (x.r.expectedPct || 0) <= -0.02).slice(0, maxSteps);
1345
1728
  for (const item of poolToUse) {
1346
1729
  if (shortfall <= 1000) break;
1347
- const limitPrice = roundToStep(item.pos.price * (1 - acc.limitOffsetPct / 100), item.sp.minStep, item.sp.decimals);
1730
+ const limitPrice = sniperLimitPrice(item.pos.price, -1, acc.limitOffsetPct, item.sp);
1348
1731
  const lotVal = limitPrice * item.sp.lotSize;
1349
1732
  const lotsToSell = Math.min(item.bLots, Math.max(1, Math.ceil(shortfall / lotVal)));
1350
1733
  const amountRub = round(lotsToSell * item.sp.lotSize * limitPrice, 2);
@@ -1366,20 +1749,63 @@ export async function prepareBrokerOrders(id) {
1366
1749
  ...newBuys.map((r) => ({ r, side: 'buy' })),
1367
1750
  ...newShorts.map((r) => ({ r, side: 'short' })),
1368
1751
  ];
1752
+ // Если включено «Свободные рубли в LQDT» (cashSweep) и для новых сигналов не хватает свободных рублей,
1753
+ // сначала автоматически продаём нужное число паёв фонда ликвидности LQDT, которые открыла стратегия (сверх неприкосновенного baselineQty)
1754
+ if (acc.cashSweep && acc.broker === 'tbank' && combinedNew.length > 0 && !closing.has('LQDT') && !activeOrderSymbols.has('LQDT')) {
1755
+ const lqdtPos = heldMap.get('LQDT');
1756
+ const lqdtAlgoQty = lqdtPos ? algoQtyOf('LQDT', lqdtPos.qty) : 0;
1757
+ if (lqdtAlgoQty > 0 && lqdtPos.price > 0) {
1758
+ const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.001, decimals: 3 };
1759
+ const lqdtLots = Math.floor(lqdtAlgoQty / lqdtSp.lotSize);
1760
+ const neededForNewRub = combinedNew.reduce((s, { r, side }) => {
1761
+ const isT1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
1762
+ const cap = budget * (side === 'short' ? (isT1 ? 0.35 : 0.18) : (isT1 ? 0.45 : 0.22));
1763
+ return s + Math.min(cap, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
1764
+ }, 0);
1765
+ const needFromLqdtRub = Math.min(Math.max(0, budget * 0.99 - usedRub), neededForNewRub) - availRub;
1766
+ if (lqdtLots >= 1 && needFromLqdtRub > 1500) {
1767
+ const limPx = sniperLimitPrice(lqdtPos.price, -1, 0.03, lqdtSp);
1768
+ const lotVal = limPx * lqdtSp.lotSize;
1769
+ const sellLots = Math.min(lqdtLots, Math.max(1, Math.ceil((needFromLqdtRub * 1.005) / lotVal)));
1770
+ const amountRub = round(sellLots * lqdtSp.lotSize * limPx, 2);
1771
+ orders.push({
1772
+ symbol: 'LQDT', action: 'sell', lotSize: lqdtSp.lotSize, lots: sellLots, shares: sellLots * lqdtSp.lotSize,
1773
+ refPrice: lqdtPos.price, limitPrice: limPx, amountRub,
1774
+ reason: `Высвобождение ${fmtRubSimple(amountRub)} ₽ из фонда денежного рынка LQDT под сигнал стратегии (${combinedNew.map((x) => x.r.symbol).join(', ')})`,
1775
+ });
1776
+ closing.add('LQDT');
1777
+ availRub += amountRub;
1778
+ }
1779
+ }
1780
+ }
1369
1781
  if (autoAdapt && combinedNew.length > 1) {
1370
1782
  combinedNew.sort((a, b) => Math.abs(b.r.expectedPct || 0) - Math.abs(a.r.expectedPct || 0));
1371
1783
  }
1372
1784
  let remWeightSum = combinedNew.reduce((s, x) => s + Math.max(0.01, Math.abs(x.r.expectedPct || 0)), 0);
1373
1785
 
1786
+ // Жёсткий предохранитель концентрации на одну бумагу в реальном стакане брокера:
1787
+ // • для ликвидных бумаг 1-го эшелона: до 45% счёта на один лонг и до 35% счёта на один шорт;
1788
+ // • для бумаг 2–3 эшелона (MRKC, MRKZ, NKHP, AQUA, FESH, FEES и др.): до 22% счёта на один лонг и до 18% на один шорт,
1789
+ // чтобы крупная заявка не выкупала тонкий стакан против самой себя и всегда оставляла капитал на 3–4 бумаги!
1790
+ const maxLongTier1Rub = budget * 0.45;
1791
+ const maxShortTier1Rub = budget * 0.35;
1792
+ const maxLongTier2Rub = budget * 0.22;
1793
+ const maxShortTier2Rub = budget * 0.18;
1794
+
1374
1795
  for (const { r, side } of combinedNew) {
1375
1796
  const wCur = Math.max(0.01, Math.abs(r.expectedPct || 0));
1376
1797
  const propShare = autoAdapt && remWeightSum > 0 ? availRub * (wCur / remWeightSum) : availRub / Math.max(1, remNew);
1377
1798
  remWeightSum = Math.max(0, remWeightSum - wCur);
1378
1799
  remNew--;
1379
- const rawSum = (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r);
1800
+ const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
1801
+ const sideCapRub = side === 'short'
1802
+ ? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
1803
+ : (isTier1 ? maxLongTier1Rub : maxLongTier2Rub);
1804
+ const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
1380
1805
  const sum = Math.min(rawSum, propShare, availRub);
1381
1806
  const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
1382
- const ord = calcLotOrder(r.symbol, sum, refPx, side, acc.limitOffsetPct, specs);
1807
+ const effOffsetPct = isTier1 ? Math.min(Number(acc.limitOffsetPct) || 0.05, 0.06) : Math.min(Number(acc.limitOffsetPct) || 0.04, 0.04);
1808
+ const ord = calcLotOrder(r.symbol, sum, refPx, side, effOffsetPct, specs);
1383
1809
  if (ord) {
1384
1810
  availRub = Math.max(0, availRub - ord.amountRub);
1385
1811
  orders.push({
@@ -1392,17 +1818,36 @@ export async function prepareBrokerOrders(id) {
1392
1818
  }
1393
1819
  }
1394
1820
 
1821
+ // При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых заявок,
1822
+ // а свободных рублей выше резерва — размещаем излишек в фонд ликвидности LQDT, чтобы кэш приносил ставку денежного рынка (~16–18% годовых)
1823
+ if (!onlyEntries && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length === 0 && !activePendingOrders.length && !edgeWarn) {
1824
+ const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.001, decimals: 3 };
1825
+ const lqdtPx = Number(heldMap.get('LQDT')?.price || lqdtSp.lastPrice || 0);
1826
+ const reserveRub = Math.max(5000, round(budget * 0.03, 0));
1827
+ const sweepRub = Math.min(availRub - reserveRub, (acc.lastSync?.cashRub || 0) - reserveRub);
1828
+ if (lqdtPx > 0 && sweepRub >= 20000) {
1829
+ const ord = calcLotOrder('LQDT', sweepRub, lqdtPx, 'buy', 0.02, { ...specs, LQDT: lqdtSp });
1830
+ if (ord) {
1831
+ availRub = Math.max(0, availRub - ord.amountRub);
1832
+ orders.push({
1833
+ ...ord,
1834
+ reason: `Фонд денежного рынка LQDT: размещение свободных ${fmtRubSimple(ord.amountRub)} ₽ под дневную ставку ликвидности до появления сигнала`,
1835
+ });
1836
+ }
1837
+ }
1838
+ }
1839
+
1395
1840
  let diagNote = '';
1396
1841
  let planThrottleNote = '';
1397
1842
  if (plan.id === 'investor') {
1398
1843
  const shInfo = rawShortCands.length ? ` (пропущены сигналы на шорт: ${rawShortCands.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
1399
1844
  const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
1400
- planThrottleNote = `Тариф «Инвестор» (целевой потолок до 60% годовых, 1 счёт со стартовым лимитом до 1 млн ₽${reinvNote}): активен режим «Только в рост (без шорта)»${shInfo}. Для включения заработка на падении акций (шорты, до 120% годовых и до 5 счетов) перейдите на тариф «Профи».`;
1845
+ planThrottleNote = `Тариф «Инвестор» (1 счёт со стартовым лимитом до 1 млн ₽${reinvNote}): активен режим «Только в рост (без шорта)»${shInfo}. Сделки на падении акций (шорты) и до 5 счетов — на тарифе «Профи».`;
1401
1846
  } else if (plan.id === 'pro') {
1402
1847
  const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
1403
- planThrottleNote = `Тариф «Профи» (целевой потолок до 120% годовых, до 5 счетов со стартовым лимитом до 1 млн ₽/счёт${reinvNote}): активны покупки и шорты. Для расширения потолка доходности до 240% годовых и индивидуальных лимитов подключите тариф «VIP».`;
1848
+ planThrottleNote = `Тариф «Профи» (до 5 счетов со стартовым лимитом до 1 млн ₽/счёт${reinvNote}): активны покупки и шорты. Индивидуальные лимиты — на тарифе «VIP».`;
1404
1849
  } else if (plan.id === 'vip') {
1405
- planThrottleNote = `Тариф «VIP» (целевой потолок до 240% годовых, лимиты по согласованию): активны покупки и шорты с повышенной концентрацией слота.`;
1850
+ planThrottleNote = 'Тариф «VIP» (лимиты по договору): активны покупки и шорты с повышенной концентрацией слота.';
1406
1851
  }
1407
1852
  if (!orders.length) {
1408
1853
  if ((newBuys.length || newShorts.length) && availRub < 1500) {
@@ -1411,16 +1856,23 @@ export async function prepareBrokerOrders(id) {
1411
1856
  diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
1412
1857
  } else if (!newBuys.length && !planAllowsShorts && rawShortCands.length > 0) {
1413
1858
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
1414
- diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 60% годовых). Чтобы зарабатывать и на падении акций (до 120% годовых), перейдите на тариф «Профи» или «VIP».`;
1859
+ diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта). Сделки на падении акций доступны на тарифах «Профи» и «VIP».`;
1415
1860
  } else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
1416
1861
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
1417
1862
  diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
1418
1863
  } else if (!newBuys.length && !newShorts.length) {
1419
1864
  const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
1420
- diagNote = `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
1865
+ const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует 60-минутная пауза после недавнего выхода из позиции, чтобы не платить повторную комиссию.` : '';
1866
+ diagNote = cdNote
1867
+ ? `На свече ${fc.bar} новых входов нет.${cdNote}${noShortNote}`
1868
+ : `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
1421
1869
  }
1422
1870
  }
1423
1871
 
1872
+ if (onlyEntries && !orders.length) {
1873
+ return brokersState();
1874
+ }
1875
+
1424
1876
  acc.pendingBatch = {
1425
1877
  id: `batch_${Date.now()}`,
1426
1878
  createdAt: Date.now(),
@@ -1441,12 +1893,15 @@ export async function prepareBrokerOrders(id) {
1441
1893
  saveRawAccounts(list);
1442
1894
  notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
1443
1895
  if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
1444
- return executeBrokerOrders(id);
1896
+ return await executeBrokerOrders(id, { _lockHeld: true });
1445
1897
  }
1446
1898
  const st = brokersState();
1447
1899
  hub.emit({ type: 'brokers:update', state: st });
1448
1900
  hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
1449
1901
  return st;
1902
+ } finally {
1903
+ accountBusyLocks.delete(id);
1904
+ }
1450
1905
  }
1451
1906
 
1452
1907
  function formatTbankOrderError(status, rawText = '', ord = {}) {
@@ -1480,14 +1935,46 @@ async function sendOrderToBroker(acc, ord) {
1480
1935
  const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
1481
1936
  const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
1482
1937
  const instrumentId = await resolveTbankInstrumentId(base, headers, ord.symbol);
1483
- const units = Math.trunc(ord.limitPrice);
1484
- const nano = Math.round((ord.limitPrice - units) * 1e9);
1485
1938
  const isBuy = ord.action === 'buy' || ord.action === 'cover';
1939
+ const isEntry = ord.action === 'buy' || ord.action === 'short';
1940
+ // Подтягиваем живой стакан 1×1 (GetOrderBook depth=1) в секунду отправки:
1941
+ // • для входа (buy/short) и для LQDT ставим лимитку по лучшей цене своей стороны или середине спреда (не переплачиваем за пересечение спреда!);
1942
+ // • если спред в стакане в момент входа разъехался шире половины ожидаемого хода сигнала (или >0,35%) — отклоняем вход в тонкий стакан!
1943
+ let finalLimitPx = Number(ord.limitPrice) || 0;
1944
+ const sp = kvGet('tbankShortableMap')?.specMap?.[ord.symbol] || { minStep: 0.01, decimals: 2 };
1945
+ const rBook = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetOrderBook`, {
1946
+ method: 'POST', headers, body: JSON.stringify({ instrumentId, depth: 1 }), timeoutMs: 3500,
1947
+ }).catch(() => null);
1948
+ if (rBook?.ok) {
1949
+ const jBook = await rBook.json().catch(() => null);
1950
+ const bestBid = qVal(jBook?.bids?.[0]?.price);
1951
+ const bestAsk = qVal(jBook?.asks?.[0]?.price);
1952
+ if (bestBid > 0 && bestAsk >= bestBid) {
1953
+ const midPx = (bestBid + bestAsk) / 2;
1954
+ const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
1955
+ const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
1956
+ if (isEntry && spreadPct > maxSpreadPct) {
1957
+ throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
1958
+ }
1959
+ if (isEntry && ord.symbol !== 'LQDT') {
1960
+ // Пассивный вход на середине спреда (или на 1 шаг внутри спреда от своей стороны), но не хуже расчётной limitPrice
1961
+ const passivePx = isBuy
1962
+ ? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + (sp.minStep || 0.01)), sp.minStep, sp.decimals))
1963
+ : Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - (sp.minStep || 0.01)), sp.minStep, sp.decimals));
1964
+ if (passivePx > 0) finalLimitPx = passivePx;
1965
+ } else if (ord.symbol === 'LQDT') {
1966
+ finalLimitPx = roundToStep(isBuy ? bestAsk : bestBid, sp.minStep || 0.001, sp.decimals ?? 3);
1967
+ }
1968
+ }
1969
+ }
1970
+ const units = Math.trunc(finalLimitPx);
1971
+ const nano = Math.round((finalLimitPx - units) * 1e9);
1486
1972
  let lotsToOrder = Number(ord.lots) || 0;
1487
1973
 
1488
1974
  // Перед выставлением новой лимитной заявки автоматически снимаем старые неисполненные заявки по этой же бумаге и направлению,
1489
1975
  // чтобы в стакане никогда не копились дублирующиеся заявки и не блокировали рублёвый лимит счёта!
1490
- const rActive = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
1976
+ // При повторе после таймаута не снимаем: среди «старых» может оказаться эта же заявка, дошедшая до брокера.
1977
+ const rActive = ord.uncertainSend ? null : await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
1491
1978
  method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId }), timeoutMs: 8000,
1492
1979
  }).catch(() => null);
1493
1980
  if (rActive?.ok) {
@@ -1520,9 +2007,9 @@ async function sendOrderToBroker(acc, ord) {
1520
2007
  if (Number.isFinite(maxLots)) {
1521
2008
  if (maxLots < 1) {
1522
2009
  if (ord.action === 'short') {
1523
- throw new Error(`Короткая продажа (шорт) по бумаге ${ord.symbol} недоступна у брокера (бумага не входит в список маржинальных для шорта или исчерпан лимит займа)`);
2010
+ throw Object.assign(new Error(`Короткая продажа (шорт) по бумаге ${ord.symbol} недоступна у брокера (бумага не входит в список маржинальных для шорта или исчерпан лимит займа)`), { definitive: true });
1524
2011
  }
1525
- throw new Error(`У брокера недостаточно доступного лимита для ${isBuy ? 'покупки' : 'продажи'} ${ord.symbol} (доступно 0 лотов)`);
2012
+ throw Object.assign(new Error(`У брокера недостаточно доступного лимита для ${isBuy ? 'покупки' : 'продажи'} ${ord.symbol} (доступно 0 лотов)`), { definitive: true });
1526
2013
  }
1527
2014
  if (maxLots < lotsToOrder) lotsToOrder = Math.floor(maxLots);
1528
2015
  }
@@ -1536,7 +2023,8 @@ async function sendOrderToBroker(acc, ord) {
1536
2023
  price: { units: String(units), nano },
1537
2024
  direction: isBuy ? 'ORDER_DIRECTION_BUY' : 'ORDER_DIRECTION_SELL',
1538
2025
  orderType: 'ORDER_TYPE_LIMIT',
1539
- orderId: crypto.randomUUID(),
2026
+ // ключ идемпотентности T-Invest: повтор с тем же orderId не создаёт вторую заявку
2027
+ orderId: ord.clientOrderId || crypto.randomUUID(),
1540
2028
  };
1541
2029
  const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/PostOrder`, {
1542
2030
  method: 'POST',
@@ -1546,10 +2034,12 @@ async function sendOrderToBroker(acc, ord) {
1546
2034
  });
1547
2035
  if (!r.ok) {
1548
2036
  const errText = await r.text().catch(() => '');
1549
- throw new Error(formatTbankOrderError(r.status, errText, ord));
2037
+ // 5xx — исход не известен (заявка могла быть принята), 4xx — брокер точно отклонил
2038
+ throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
1550
2039
  }
1551
2040
  const j = await r.json();
1552
- return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder };
2041
+ logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ · ${body.orderId}`);
2042
+ return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder, limitPrice: finalLimitPx };
1553
2043
  }
1554
2044
  if (acc.broker === 'finam') {
1555
2045
  const body = {
@@ -1708,52 +2198,98 @@ async function sendOrderToBroker(acc, ord) {
1708
2198
  throw new Error(`Неизвестный брокер: ${acc.broker}`);
1709
2199
  }
1710
2200
 
1711
- export async function executeBrokerOrders(id) {
1712
- const list = loadRawAccounts();
1713
- const acc = list.find((x) => x.id === id);
1714
- if (!acc) throw new Error('Счёт не найден');
1715
- if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
1716
- if (acc.haltedReason) throw new Error(acc.haltedReason);
1717
- const batch = acc.pendingBatch;
1718
- if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
1719
- const results = [];
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));
2201
+ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2202
+ if (!_lockHeld) {
2203
+ // Счёт может быть занят пересчётом заявок (после перезапуска — обучением прогноза): ждём до 15 с, а не молча пропускаем
2204
+ const waitUntil = Date.now() + 15000;
2205
+ while (accountBusyLocks.has(id) && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 300));
2206
+ if (accountBusyLocks.has(id)) throw new Error('Счёт сейчас занят пересчётом заявок — повторите через минуту');
2207
+ accountBusyLocks.add(id);
2208
+ }
2209
+ try {
2210
+ const list = loadRawAccounts();
2211
+ const acc = list.find((x) => x.id === id);
2212
+ if (!acc) throw new Error('Счёт не найден');
2213
+ if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
2214
+ if (acc.haltedReason) throw new Error(acc.haltedReason);
2215
+ const gate = tradingGate();
2216
+ if (!gate.allowNew) throw new Error(gate.reason);
2217
+ const batch = acc.pendingBatch;
2218
+ if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
2219
+ if (batch.status === 'executed' || batch.status === 'executing') return brokersState();
2220
+ // Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
2221
+ // ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
2222
+ for (const ord of batch.orders) {
2223
+ if (!ord.clientOrderId) ord.clientOrderId = crypto.randomUUID();
1733
2224
  }
1734
- try {
1735
- const r = await sendOrderToBroker(acc, ord);
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;
2225
+ batch.status = 'executing';
2226
+ saveRawAccounts(list);
2227
+ const prevResults = new Map((batch.results || []).filter((r) => r.ok && r.clientOrderId).map((r) => [r.clientOrderId, r]));
2228
+ const results = [];
2229
+ acc.entryTsBySymbol = acc.entryTsBySymbol || {};
2230
+ acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
2231
+ acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
2232
+ const execNowTs = Date.now();
2233
+ const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
2234
+ const entryOrders = batch.orders.filter((o) => o.action !== 'sell' && o.action !== 'cover');
2235
+ const orderedBatch = [...exitOrders, ...entryOrders];
2236
+ let hadExits = false;
2237
+ for (const ord of orderedBatch) {
2238
+ const already = prevResults.get(ord.clientOrderId);
2239
+ if (already) {
2240
+ results.push(already);
2241
+ continue;
2242
+ }
2243
+ // Если перед открытием новых позиций мы отправили закрывающие ордера (sell/cover),
2244
+ // даём брокеру 1.2 секунды на исполнение в стакане и пересчёт свободного маржинального/рублёвого лимита перед вызовом GetMaxLots
2245
+ if (hadExits && (ord.action === 'buy' || ord.action === 'short')) {
2246
+ hadExits = false;
2247
+ await new Promise((r) => setTimeout(r, 1200));
2248
+ }
2249
+ try {
2250
+ if ((ord.action === 'sell' || ord.action === 'cover') && acc.brokerStopBySymbol?.[ord.symbol]) {
2251
+ try {
2252
+ await closeBrokerStopBeforeExit(acc, ord.symbol);
2253
+ } catch (e) {
2254
+ throw new Error(`не удалось снять стоп-заявку у брокера перед закрытием (${e.message}) — закрывающая заявка не отправлена, чтобы не продать дважды`);
2255
+ }
2256
+ }
2257
+ const r = await sendOrderToBroker(acc, ord);
2258
+ results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
2259
+ if (ord.action === 'buy' || ord.action === 'short') {
2260
+ acc.entryTsBySymbol[ord.symbol] = execNowTs;
2261
+ acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
2262
+ delete acc.exitCooldownBySymbol[ord.symbol];
2263
+ } else if (ord.action === 'sell' || ord.action === 'cover') {
2264
+ hadExits = true;
2265
+ delete acc.entryTsBySymbol[ord.symbol];
2266
+ delete acc.peakPriceBySymbol[ord.symbol];
2267
+ acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2268
+ }
2269
+ } catch (e) {
2270
+ // Брокер явно отклонил заявку — при повторе нужен новый ID. Сетевая ошибка или таймаут — ID сохраняется:
2271
+ // заявка могла дойти до брокера, и повтор с тем же ID не создаст дубль.
2272
+ ord.sendAttempts = (ord.sendAttempts || 0) + 1;
2273
+ if (e.definitive) ord.clientOrderId = crypto.randomUUID();
2274
+ else ord.uncertainSend = true;
2275
+ results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
2276
+ logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
1744
2277
  }
1745
- } catch (e) {
1746
- results.push({ symbol: ord.symbol, ok: false, error: e.message });
1747
2278
  }
2279
+ batch.status = results.every((r) => r.ok) ? 'executed' : 'partial';
2280
+ batch.results = results;
2281
+ batch.executedAt = Date.now();
2282
+ saveRawAccounts(list);
2283
+ await syncBrokerAccount(id).catch(() => {});
2284
+ if (acc.broker === 'tbank' && acc.token) {
2285
+ await fetchBrokerHistory(id).catch(() => {});
2286
+ }
2287
+ const st = brokersState();
2288
+ hub.emit({ type: 'brokers:update', state: st });
2289
+ return st;
2290
+ } finally {
2291
+ if (!_lockHeld) accountBusyLocks.delete(id);
1748
2292
  }
1749
- batch.status = results.every((r) => r.ok) ? 'executed' : 'partial';
1750
- batch.results = results;
1751
- batch.executedAt = Date.now();
1752
- saveRawAccounts(list);
1753
- await syncBrokerAccount(id).catch(() => {});
1754
- const st = brokersState();
1755
- hub.emit({ type: 'brokers:update', state: st });
1756
- return st;
1757
2293
  }
1758
2294
 
1759
2295
  export async function cancelBrokerOrder(id, orderId = null) {
@@ -1869,6 +2405,19 @@ export async function fetchBrokerHistory(id, opts = {}) {
1869
2405
  const fetchedOps = rawOps.map((op) => {
1870
2406
  const m = metaMap[op.figi] || metaMap[op.instrumentUid] || {};
1871
2407
  const [kind, kindLabel] = OP_KIND(op.operationType);
2408
+ // В T-Invest API v2 поле op.quantity — это ЗАЯВЛЕННОЕ количество в лимитном ордере, а op.quantityRest — неисполненный остаток!
2409
+ // Реально исполненное количество акций всегда равно сумме сделок в op.trades, либо (quantity - quantityRest):
2410
+ const tradesQty = Array.isArray(op.trades) && op.trades.length > 0
2411
+ ? op.trades.reduce((s, t) => s + (Number(t.quantity) || 0), 0)
2412
+ : 0;
2413
+ const rawQty = Number(op.quantityDone ?? (Number(op.quantity || 0) - Number(op.quantityRest || 0)));
2414
+ const execQty = tradesQty > 0 ? tradesQty : Math.max(0, rawQty);
2415
+ const paymentRub = round(qVal(op.payment), 2);
2416
+ // Точная средневзвешенная цена исполнения из фактической суммы сделки (чтобы Кол-во × Цена = Сумма сходились до копейки):
2417
+ const rawPx = qVal(op.price);
2418
+ const effPx = (execQty > 0 && Math.abs(paymentRub) > 0 && (kind === 'buy' || kind === 'sell'))
2419
+ ? round(Math.abs(paymentRub) / execQty, 6)
2420
+ : round(rawPx, 6);
1872
2421
  return {
1873
2422
  id: op.id || '',
1874
2423
  date: op.date ? new Date(op.date).getTime() : 0,
@@ -1877,11 +2426,11 @@ export async function fetchBrokerHistory(id, opts = {}) {
1877
2426
  title: op.type || kindLabel,
1878
2427
  symbol: m.ticker || op.figi || '',
1879
2428
  name: m.name || '',
1880
- qty: Number(op.quantityDone || op.quantity || 0),
1881
- price: round(qVal(op.price), 4),
1882
- paymentRub: round(qVal(op.payment), 2),
2429
+ qty: execQty,
2430
+ price: effPx,
2431
+ paymentRub,
1883
2432
  };
1884
- });
2433
+ }).filter((op) => !((op.kind === 'buy' || op.kind === 'sell') && op.qty === 0 && op.paymentRub === 0));
1885
2434
 
1886
2435
  // Сохраняем операции других годов, которые уже были загружены ранее, и заменяем только targetYear
1887
2436
  const prevOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
@@ -1937,6 +2486,7 @@ let schedulerBusy = false;
1937
2486
 
1938
2487
  async function tickBrokerSchedule() {
1939
2488
  if (schedulerBusy) return;
2489
+ if (!tradingGate().allowNew) return;
1940
2490
  const nowMsk = new Date(Date.now() + MSK);
1941
2491
  const dow = nowMsk.getUTCDay();
1942
2492
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
@@ -1944,8 +2494,8 @@ async function tickBrokerSchedule() {
1944
2494
  schedulerBusy = true;
1945
2495
  try {
1946
2496
  const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
1947
- if (!latest || latest === lastSeenBar) return;
1948
- lastSeenBar = latest;
2497
+ const isNewBar = Boolean(latest && latest !== lastSeenBar);
2498
+ if (isNewBar) lastSeenBar = latest;
1949
2499
  const users = db.prepare('SELECT * FROM users').all();
1950
2500
  for (const u of users) {
1951
2501
  await runWithUser(u, async () => {
@@ -1961,11 +2511,28 @@ async function tickBrokerSchedule() {
1961
2511
  const H = barsFromHours(effHorizon);
1962
2512
  // Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
1963
2513
  const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
1964
- if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
1965
- acc.lastScheduledBarTs = latest;
1966
- saveRawAccounts(list);
1967
- if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
1968
- await prepareBrokerOrders(acc.id).catch(() => {});
2514
+ const minStepMs = stepBars * BAR_MS;
2515
+ const lastRunWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
2516
+ const dueFullStep = Boolean(
2517
+ latest &&
2518
+ (latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
2519
+ !(lastRunWallTs > 0 && Date.now() - lastRunWallTs < minStepMs - 5 * 60000),
2520
+ );
2521
+ if (dueFullStep) {
2522
+ acc.lastScheduledBarTs = latest;
2523
+ saveRawAccounts(list);
2524
+ if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
2525
+ await prepareBrokerOrders(acc.id).catch(() => {});
2526
+ continue;
2527
+ }
2528
+ // Мгновенный перехват новых сигналов «купить» / «шорт» при наличии свободных рублей или резерва в LQDT (каждые 20 секунд, не дожидаясь конца 30-минутного шага!):
2529
+ const lqdtPos = acc.cashSweep ? (acc.lastSync?.positions || []).find((p) => p.symbol === 'LQDT' && p.qty > 0) : null;
2530
+ const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
2531
+ const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
2532
+ const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
2533
+ if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && Date.now() - lastRunWallTs >= 20000) {
2534
+ await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
2535
+ }
1969
2536
  }
1970
2537
  });
1971
2538
  }
@@ -1983,15 +2550,10 @@ async function resumeBrokerAccountsOnBoot() {
1983
2550
  const list = loadRawAccounts();
1984
2551
  for (const acc of list) {
1985
2552
  if (!acc.token) continue;
1986
- await syncBrokerAccount(acc.id).catch(() => {});
1987
2553
  if (!acc.lastHistory && acc.broker === 'tbank') {
1988
2554
  await fetchBrokerHistory(acc.id).catch(() => {});
1989
2555
  }
1990
- // Если в предыдущем пакете была ошибка 400 из-за шорта на безмаржинальном счёте — пересчитываем пакет под реальные настройки счёта
1991
- const fresh = loadRawAccounts().find((x) => x.id === acc.id);
1992
- if (fresh?.pendingBatch?.results?.some((r) => !r.ok && /HTTP 400|margin/i.test(String(r.error || '')))) {
1993
- await prepareBrokerOrders(acc.id).catch(() => {});
1994
- }
2556
+ await syncBrokerAccount(acc.id).catch(() => {});
1995
2557
  }
1996
2558
  });
1997
2559
  }
@@ -1999,7 +2561,284 @@ async function resumeBrokerAccountsOnBoot() {
1999
2561
  } catch { /* ignore startup sync errors */ }
2000
2562
  }
2001
2563
 
2002
- // Автоматическое периодическое фоновое обновление портфеля и истории текущего года (раз в 2 минуты)
2564
+ // ---------- защитные стоп-заявки на стороне Т-Банка (бета, включаются на счёте) ----------
2565
+ // Стоп-лосс висит у брокера и срабатывает, даже если машина клиента выключена или упала.
2566
+ // Правила безопасности (чтобы не получить непреднамеренный шорт на маржинальном счёте):
2567
+ // • стоп-заявка на каждую бумагу с позицией стратегии, ровно на её число лотов (сверка раз в 25 с);
2568
+ // • перед любой закрывающей заявкой по бумаге её стоп-заявка снимается (closeBrokerStopBeforeExit);
2569
+ // • внутренний защитный стоп по бумаге со стоп-заявкой у брокера не выставляется;
2570
+ // • трогаются только стоп-заявки, выставленные самой программой (их ID хранятся в brokerStopBySymbol).
2571
+ const BROKER_STOP_LONG_PCT = 2.6;
2572
+ const BROKER_STOP_SHORT_PCT = 2.2;
2573
+ const tbankBase = (acc) => (acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest');
2574
+ const tbankHeaders = (acc) => ({ Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' });
2575
+ const qPrice = (v) => { const units = Math.trunc(v); return { units: String(units), nano: Math.round((v - units) * 1e9) }; };
2576
+
2577
+ function algoQtyFor(acc, sym, totalQty) {
2578
+ const legacyMode = acc.legacyMode || 'isolate';
2579
+ if (legacyMode === 'takeover') return totalQty;
2580
+ const baseQ = Number((acc.baselineQty || {})[sym] || 0);
2581
+ if (totalQty > 0 && baseQ > 0) return Math.max(0, totalQty - baseQ);
2582
+ if (totalQty < 0 && baseQ < 0) return Math.min(0, totalQty - baseQ);
2583
+ return totalQty;
2584
+ }
2585
+
2586
+ async function tbankActiveStopIds(acc) {
2587
+ const r = await brokerFetch(`${tbankBase(acc)}/tinkoff.public.invest.api.contract.v1.StopOrdersService/GetStopOrders`, {
2588
+ method: 'POST', headers: tbankHeaders(acc), body: JSON.stringify({ accountId: acc.accountId, status: 'STOP_ORDER_STATUS_ACTIVE' }), timeoutMs: 10000,
2589
+ });
2590
+ if (!r.ok) throw new Error(`GetStopOrders HTTP ${r.status}`);
2591
+ const j = await r.json();
2592
+ return new Set((j.stopOrders || []).map((s) => s.stopOrderId).filter(Boolean));
2593
+ }
2594
+
2595
+ async function tbankCancelStop(acc, stopOrderId) {
2596
+ const r = await brokerFetch(`${tbankBase(acc)}/tinkoff.public.invest.api.contract.v1.StopOrdersService/CancelStopOrder`, {
2597
+ method: 'POST', headers: tbankHeaders(acc), body: JSON.stringify({ accountId: acc.accountId, stopOrderId }), timeoutMs: 10000,
2598
+ });
2599
+ // 4xx «не найдена» — стоп уже сработал или снят: считаем снятым
2600
+ if (!r.ok && r.status >= 500) throw new Error(`CancelStopOrder HTTP ${r.status}`);
2601
+ }
2602
+
2603
+ // Снять стоп-заявку у брокера перед закрытием позиции стратегией (иначе после закрытия она продаст ещё раз)
2604
+ async function closeBrokerStopBeforeExit(acc, sym) {
2605
+ const t = acc.brokerStopBySymbol?.[sym];
2606
+ if (!t?.stopOrderId || acc.broker !== 'tbank' || !acc.token) return;
2607
+ await tbankCancelStop(acc, t.stopOrderId);
2608
+ delete acc.brokerStopBySymbol[sym];
2609
+ logBrokerEvent(acc, 'broker_stop_cancel', `${sym}: снята перед закрытием позиции`);
2610
+ }
2611
+
2612
+ async function cancelAllBrokerStops(acc, why) {
2613
+ for (const [sym, t] of Object.entries(acc.brokerStopBySymbol || {})) {
2614
+ try {
2615
+ if (t?.stopOrderId) await tbankCancelStop(acc, t.stopOrderId);
2616
+ delete acc.brokerStopBySymbol[sym];
2617
+ logBrokerEvent(acc, 'broker_stop_cancel', `${sym}: ${why}`);
2618
+ } catch (e) {
2619
+ logBrokerEvent(acc, 'broker_stop_error', `${sym}: не удалось снять (${e.message})`);
2620
+ }
2621
+ }
2622
+ }
2623
+
2624
+ async function reconcileBrokerStops(accId) {
2625
+ const list = loadRawAccounts();
2626
+ const acc = list.find((x) => x.id === accId);
2627
+ if (!acc || acc.broker !== 'tbank' || !acc.token || !acc.accountId) return;
2628
+ acc.brokerStopBySymbol = acc.brokerStopBySymbol || {};
2629
+ // выключено на счёте, «Только чтение» или аварийная остановка из центра — свои стоп-заявки снимаем
2630
+ const gate = tradingGate();
2631
+ if (!acc.brokerStops || acc.mode === 'readonly' || !gate.allowProtective) {
2632
+ if (Object.keys(acc.brokerStopBySymbol).length) {
2633
+ await cancelAllBrokerStops(acc, !acc.brokerStops ? 'стоп-заявки у брокера выключены на счёте' : 'счёт в режиме «Только чтение» или торговля остановлена');
2634
+ saveRawAccounts(list);
2635
+ }
2636
+ return;
2637
+ }
2638
+ if (!acc.lastSync?.ok) return;
2639
+ if (accountBusyLocks.has(accId)) return; // идёт исполнение пакета — сверимся в следующий раз
2640
+ accountBusyLocks.add(accId);
2641
+ try {
2642
+ await reconcileBrokerStopsLocked(list, acc);
2643
+ } finally {
2644
+ accountBusyLocks.delete(accId);
2645
+ }
2646
+ }
2647
+
2648
+ async function reconcileBrokerStopsLocked(list, acc) {
2649
+ acc.brokerStopCooldown = acc.brokerStopCooldown || {};
2650
+ const active = await tbankActiveStopIds(acc);
2651
+ const rawSpecs = await ensureBoardSpecs().catch(() => ({}));
2652
+ const specs = { ...rawSpecs, ...(kvGet('tbankShortableMap')?.specMap || {}) };
2653
+ const base = tbankBase(acc);
2654
+ const headers = tbankHeaders(acc);
2655
+ const want = new Map();
2656
+ for (const pos of acc.lastSync.positions || []) {
2657
+ const sym = pos.symbol;
2658
+ const sp = specs[sym];
2659
+ if (!sym || !sp) continue;
2660
+ const aQty = algoQtyFor(acc, sym, Number(pos.qty) || 0);
2661
+ const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
2662
+ const avgPx = Number(pos.avgPrice) || 0;
2663
+ if (lots < 1 || avgPx <= 0) continue;
2664
+ const side = aQty > 0 ? 'long' : 'short';
2665
+ const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
2666
+ want.set(sym, { lots, side, stopPx, sp });
2667
+ }
2668
+ let changed = false;
2669
+ // 1) снять свои стоп-заявки без позиции, сработавшие (их нет среди активных) или с другим объёмом / ценой / стороной
2670
+ for (const [sym, t] of Object.entries(acc.brokerStopBySymbol)) {
2671
+ const w = want.get(sym);
2672
+ const alive = t?.stopOrderId && active.has(t.stopOrderId);
2673
+ const same = alive && w && w.lots === t.lots && w.side === t.side && Math.abs(w.stopPx - t.stopPx) < (w.sp.minStep || 0.01) / 2;
2674
+ if (same) continue;
2675
+ if (alive) {
2676
+ try { await tbankCancelStop(acc, t.stopOrderId); } catch (e) { logBrokerEvent(acc, 'broker_stop_error', `${sym}: ${e.message}`); continue; }
2677
+ }
2678
+ logBrokerEvent(acc, alive ? 'broker_stop_cancel' : 'broker_stop_gone', `${sym}: ${!w ? 'позиции стратегии нет' : !alive ? 'стоп-заявка сработала или снята у брокера' : 'объём или цена позиции изменились'}`);
2679
+ // стоп сработал, а позиция в данных ещё видна — не выставляем новый, пока портфель не обновится (иначе возможна повторная продажа)
2680
+ if (!alive) acc.brokerStopCooldown[sym] = Date.now();
2681
+ delete acc.brokerStopBySymbol[sym];
2682
+ changed = true;
2683
+ }
2684
+ // 2) выставить недостающие
2685
+ for (const [sym, w] of want) {
2686
+ if (acc.brokerStopBySymbol[sym]) continue;
2687
+ const cd = acc.brokerStopCooldown[sym];
2688
+ if (cd && (Date.now() - cd < 120000 || Number(acc.lastSync?.at || 0) < cd + 30000)) continue;
2689
+ if (cd) { delete acc.brokerStopCooldown[sym]; changed = true; }
2690
+ try {
2691
+ const instrumentId = await resolveTbankInstrumentId(base, headers, sym);
2692
+ const body = {
2693
+ accountId: acc.accountId,
2694
+ instrumentId,
2695
+ quantity: String(w.lots),
2696
+ stopPrice: qPrice(w.stopPx),
2697
+ direction: w.side === 'long' ? 'STOP_ORDER_DIRECTION_SELL' : 'STOP_ORDER_DIRECTION_BUY',
2698
+ expirationType: 'STOP_ORDER_EXPIRATION_TYPE_GOOD_TILL_CANCEL',
2699
+ stopOrderType: 'STOP_ORDER_TYPE_STOP_LOSS',
2700
+ exchangeOrderType: 'EXCHANGE_ORDER_TYPE_MARKET',
2701
+ orderId: crypto.randomUUID(),
2702
+ };
2703
+ const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.StopOrdersService/PostStopOrder`, { method: 'POST', headers, body: JSON.stringify(body), timeoutMs: 15000 });
2704
+ if (!r.ok) throw new Error(`PostStopOrder HTTP ${r.status} ${(await r.text().catch(() => '')).slice(0, 160)}`);
2705
+ const j = await r.json();
2706
+ if (!j.stopOrderId) throw new Error('брокер не вернул stopOrderId');
2707
+ acc.brokerStopBySymbol[sym] = { stopOrderId: j.stopOrderId, lots: w.lots, side: w.side, stopPx: w.stopPx, at: Date.now() };
2708
+ logBrokerEvent(acc, 'broker_stop_set', `${sym}: ${w.side === 'long' ? 'продажа' : 'покупка'} ${w.lots} л. при цене ${w.stopPx} ₽ (стоп ${w.side === 'long' ? '−' + BROKER_STOP_LONG_PCT : '+' + BROKER_STOP_SHORT_PCT}% от средней)`);
2709
+ changed = true;
2710
+ } catch (e) {
2711
+ logBrokerEvent(acc, 'broker_stop_error', `${sym}: ${e.message}`);
2712
+ }
2713
+ }
2714
+ if (changed) {
2715
+ saveRawAccounts(list);
2716
+ hub.emit({ type: 'brokers:update', state: brokersState() });
2717
+ }
2718
+ }
2719
+
2720
+ // Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые 2 минуты между шагами расписания
2721
+ async function checkIntradayProfitLock(accId) {
2722
+ const list = loadRawAccounts();
2723
+ const acc = list.find((x) => x.id === accId);
2724
+ if (!acc || acc.mode !== 'auto' || !acc.token || acc.haltedReason) return;
2725
+ if (!tradingGate().allowProtective) return;
2726
+ const nowMsk = new Date(Date.now() + MSK);
2727
+ const dow = nowMsk.getUTCDay();
2728
+ const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
2729
+ if (dow === 0 || dow === 6 || mins < 9 * 60 + 55 || mins > 23 * 60 + 48) return;
2730
+ if (acc.skipEdgeMinutes && sessionEdgeWarning()) return;
2731
+
2732
+ const positions = acc.lastSync?.positions || [];
2733
+ if (!positions.length) return;
2734
+ const legacyMode = acc.legacyMode || 'isolate';
2735
+ const baseline = acc.baselineQty || {};
2736
+ const algoQtyOf = (sym, totalQty) => {
2737
+ if (legacyMode === 'takeover') return totalQty;
2738
+ const baseQ = Number(baseline[sym] || 0);
2739
+ if (totalQty > 0 && baseQ > 0) return Math.max(0, totalQty - baseQ);
2740
+ if (totalQty < 0 && baseQ < 0) return Math.min(0, totalQty - baseQ);
2741
+ return totalQty;
2742
+ };
2743
+
2744
+ const rawSpecs = await ensureBoardSpecs().catch(() => ({}));
2745
+ const tbSpecs = acc.broker === 'tbank' ? (kvGet('tbankShortableMap')?.specMap || {}) : {};
2746
+ const specs = { ...rawSpecs, ...tbSpecs };
2747
+ const params = normalizeParams(getSettings());
2748
+ const roundTripFeePct = 2 * ((Number(params.commissionPct) || 0.04) + (Number(params.slippagePct) || 0.01));
2749
+ const peakMap = acc.peakPriceBySymbol || {};
2750
+ const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
2751
+
2752
+ const lockOrders = [];
2753
+ for (const pos of positions) {
2754
+ const sym = pos.symbol;
2755
+ if (!sym) continue;
2756
+ const aQty = algoQtyOf(sym, pos.qty);
2757
+ if (!aQty) continue;
2758
+ // позицию уже защищает стоп-заявка у брокера — внутренний стоп не дублируем
2759
+ if (acc.brokerStops && acc.brokerStopBySymbol?.[sym]) continue;
2760
+ // Если по этой бумаге в стакане УЖЕ висит закрывающая заявка (sell для лонга или buy для шорта) — не дублируем
2761
+ const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
2762
+ if ((acc.lastSync?.activeOrders || []).some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
2763
+ const sp = specs[sym];
2764
+ if (!sp) continue; // не трогаем облигации, фонды и бумаги вне словаря
2765
+ const curPx = Number(pos.price) || 0;
2766
+ if (curPx <= 0) continue;
2767
+ const wantOpenKind = aQty > 0 ? 'buy' : 'sell';
2768
+ const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenKind && op.date > 0);
2769
+ const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
2770
+ if (avgPx <= 0) continue;
2771
+
2772
+ if (aQty > 0) {
2773
+ const lots = Math.floor(aQty / sp.lotSize);
2774
+ if (lots < 1) continue;
2775
+ const curGainPct = ((curPx - avgPx) / avgPx) * 100;
2776
+ const hitHardStopLoss = curGainPct <= -2.6;
2777
+ if (hitHardStopLoss) {
2778
+ const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
2779
+ lockOrders.push({
2780
+ symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
2781
+ refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
2782
+ reason: `Авто-стоп позиции (${round(curGainPct, 2)}%)`,
2783
+ });
2784
+ }
2785
+ } else if (aQty < 0) {
2786
+ const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
2787
+ if (lots < 1) continue;
2788
+ const curGainPct = ((avgPx - curPx) / avgPx) * 100;
2789
+ const hitHardStopLoss = curGainPct <= -2.2;
2790
+ if (hitHardStopLoss) {
2791
+ const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
2792
+ lockOrders.push({
2793
+ symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
2794
+ refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
2795
+ reason: `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)`,
2796
+ });
2797
+ }
2798
+ }
2799
+ }
2800
+
2801
+ if (!lockOrders.length) return;
2802
+ const results = [];
2803
+ const execNowTs = Date.now();
2804
+ acc.entryTsBySymbol = acc.entryTsBySymbol || {};
2805
+ acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
2806
+ acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
2807
+ const lockSymbols = new Set(lockOrders.map((o) => o.symbol));
2808
+ const conflictingOrders = (acc.lastSync?.activeOrders || []).filter((o) => lockSymbols.has(o.symbol) && o.orderId);
2809
+ for (const co of conflictingOrders) {
2810
+ await cancelBrokerOrder(accId, co.orderId).catch(() => {});
2811
+ }
2812
+ for (const ord of lockOrders) {
2813
+ logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
2814
+ try {
2815
+ const r = await sendOrderToBroker(acc, ord);
2816
+ results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
2817
+ delete acc.entryTsBySymbol[ord.symbol];
2818
+ delete acc.peakPriceBySymbol[ord.symbol];
2819
+ acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2820
+ } catch (e) {
2821
+ results.push({ symbol: ord.symbol, ok: false, error: e.message });
2822
+ logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
2823
+ }
2824
+ }
2825
+ acc.pendingBatch = {
2826
+ ...(acc.pendingBatch || {}),
2827
+ id: `batch_tp_${execNowTs}`,
2828
+ createdAt: execNowTs,
2829
+ executedAt: execNowTs,
2830
+ bar: `${fmtBarMsk(execNowTs)} (Take-Profit)`,
2831
+ orders: lockOrders,
2832
+ status: results.every((r) => r.ok) ? 'executed' : 'partial',
2833
+ results,
2834
+ };
2835
+ saveRawAccounts(list);
2836
+ await syncBrokerAccount(accId).catch(() => {});
2837
+ const st = brokersState();
2838
+ hub.emit({ type: 'brokers:update', state: st });
2839
+ }
2840
+
2841
+ // Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
2003
2842
  let autoRefreshBusy = false;
2004
2843
  async function tickBrokerAutoRefresh() {
2005
2844
  if (autoRefreshBusy || schedulerBusy) return;
@@ -2013,7 +2852,11 @@ async function tickBrokerAutoRefresh() {
2013
2852
  for (const acc of list) {
2014
2853
  if (!acc.token) continue;
2015
2854
  await syncBrokerAccount(acc.id).catch(() => {});
2016
- if (acc.broker === 'tbank') {
2855
+ await checkIntradayProfitLock(acc.id).catch(() => {});
2856
+ if (acc.broker === 'tbank' && (acc.brokerStops || Object.keys(acc.brokerStopBySymbol || {}).length)) {
2857
+ await reconcileBrokerStops(acc.id).catch((e) => logBrokerEvent(acc, 'broker_stop_error', `сверка: ${e.message}`));
2858
+ }
2859
+ if (acc.broker === 'tbank' && (!acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 120000)) {
2017
2860
  await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
2018
2861
  }
2019
2862
  }
@@ -2024,6 +2867,13 @@ async function tickBrokerAutoRefresh() {
2024
2867
  }
2025
2868
  }
2026
2869
 
2870
+ export function triggerBrokerFastEntryCheck() {
2871
+ if (schedulerBusy || autoRefreshBusy) return;
2872
+ setImmediate(() => {
2873
+ tickBrokerSchedule().catch(() => {});
2874
+ });
2875
+ }
2876
+
2027
2877
  export function startBrokerScheduler() {
2028
2878
  ensureBoardSpecs().catch(() => {});
2029
2879
  // В фоне после старта сервера сразу прогреваем прогноз, чтобы при открытии вкладки «Прогноз сейчас» он уже был в памяти
@@ -2031,8 +2881,8 @@ export function startBrokerScheduler() {
2031
2881
  forecastNow(getSettings()).catch(() => {});
2032
2882
  }, 800);
2033
2883
  setTimeout(resumeBrokerAccountsOnBoot, 2500);
2034
- const timer = setInterval(tickBrokerSchedule, 60000);
2035
- const syncTimer = setInterval(tickBrokerAutoRefresh, 120000);
2884
+ const timer = setInterval(tickBrokerSchedule, 20000);
2885
+ const syncTimer = setInterval(tickBrokerAutoRefresh, 25000);
2036
2886
  timer.unref?.();
2037
2887
  syncTimer.unref?.();
2038
2888
  }