neuroalpha 0.3.17 → 0.5.2

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 (39) hide show
  1. package/config/certs/russian_trusted_root_ca.pem +33 -0
  2. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -4
  4. 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
  5. package/package.json +60 -53
  6. package/public/activate.html +36 -0
  7. package/public/activate.js +33 -0
  8. package/public/admin.html +126 -7
  9. package/public/app.js +589 -66
  10. package/public/index.html +48 -21
  11. package/public/landing.html +106 -37
  12. package/public/legal.html +4 -4
  13. package/public/styles.css +152 -36
  14. package/server/api/routes.js +308 -37
  15. package/server/auth/index.js +79 -15
  16. package/server/brokers/index.js +1095 -168
  17. package/server/build-info.js +7 -0
  18. package/server/config.js +13 -6
  19. package/server/data/moex.js +6 -4
  20. package/server/db.js +22 -18
  21. package/server/desktop-entry.js +103 -0
  22. package/server/hub.js +28 -14
  23. package/server/index.js +82 -12
  24. package/server/instance/agent.js +259 -0
  25. package/server/instance/central.js +491 -0
  26. package/server/instance/identity.js +79 -0
  27. package/server/instance/provision.js +166 -0
  28. package/server/instance/signing.js +71 -0
  29. package/server/model/edge.js +19 -0
  30. package/server/model/forecast.js +107 -57
  31. package/server/model/live.js +1 -1
  32. package/server/model/models.js +12 -3
  33. package/server/model/research.js +218 -30
  34. package/server/notify/index.js +51 -16
  35. package/server/role.js +44 -0
  36. package/server/security.js +40 -0
  37. package/server/strategist/index.js +86 -47
  38. package/server/strategist/tools.js +231 -42
  39. 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,210 @@ 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);
280
+ }
281
+
282
+ const candlePrevStmt = db.prepare('SELECT ts, open, close FROM candles WHERE symbol = ? AND interval = 10 AND ts <= ? ORDER BY ts DESC LIMIT 3');
283
+
284
+ // Телеметрия качества исполнения и сверка реальных сделок счёта с ориентиром стресс-теста
285
+ function computeExecutionAudit(a) {
286
+ const ops = Array.isArray(a.lastHistory?.operations) ? a.lastHistory.operations : [];
287
+ if (!ops.length) return null;
288
+ const sorted = [...ops].sort((x, y) => x.date - y.date);
289
+ // отсекаем старую историю автоследования (до последнего удержания комиссии автоследования)
290
+ const lastFollowTs = Math.max(0, ...sorted.filter((o) => o.kind === 'fee' && /автослед/i.test(o.title || '')).map((o) => o.date));
291
+ const algoOps = sorted.filter((o) => o.date > lastFollowTs);
292
+ const trades = algoOps.filter((o) => (o.kind === 'buy' || o.kind === 'sell') && o.qty > 0 && o.price > 0 && o.symbol && o.symbol !== 'LQDT');
293
+ if (!trades.length) return null;
294
+
295
+ let turnoverRub = 0;
296
+ let slipW1 = 0, rubW1 = 0, slipW2 = 0, rubW2 = 0;
297
+ for (const t of trades) {
298
+ const rub = t.qty * t.price;
299
+ turnoverRub += rub;
300
+ const rows = candlePrevStmt.all(t.symbol, t.date);
301
+ const prev = rows.find((r) => r.ts + BAR_MS <= t.date);
302
+ if (prev && prev.close > 0) {
303
+ const sign = t.kind === 'buy' ? 1 : -1;
304
+ const slipPct = sign * (t.price / prev.close - 1) * 100;
305
+ if (Math.abs(slipPct) < 5) {
306
+ if (TIER1_LIQUID_SYMBOLS.has(t.symbol)) { slipW1 += slipPct * rub; rubW1 += rub; }
307
+ else { slipW2 += slipPct * rub; rubW2 += rub; }
308
+ }
309
+ }
310
+ }
311
+ const feesRub = algoOps.filter((o) => o.kind === 'fee').reduce((s, o) => s + Math.abs(o.paymentRub || 0), 0);
312
+ const commPct = turnoverRub > 0 ? round((feesRub / turnoverRub) * 100, 3) : 0.04;
313
+ const slipAllPct = (rubW1 + rubW2) > 0 ? round((slipW1 + slipW2) / (rubW1 + rubW2), 3) : null;
314
+ const slipTier1Pct = rubW1 > 0 ? round(slipW1 / rubW1, 3) : null;
315
+ const slipTier2Pct = rubW2 > 0 ? round(slipW2 / rubW2, 3) : null;
316
+ const totalCostPerSidePct = slipAllPct !== null ? round(commPct + Math.max(0, slipAllPct), 3) : commPct;
317
+
318
+ // FIFO закрытых позиций (без старого портфеля baselineQty)
319
+ const book = new Map();
320
+ let winRub = 0, lossRub = 0, closedCount = 0;
321
+ for (const t of trades) {
322
+ const q = t.kind === 'buy' ? t.qty : -t.qty;
323
+ const lots = book.get(t.symbol) || [];
324
+ if (!lots.length && t.kind === 'sell' && (a.baselineQty?.[t.symbol] || 0) > 0) continue;
325
+ let rem = q;
326
+ while (rem !== 0 && lots.length && Math.sign(lots[0].qty) !== Math.sign(rem)) {
327
+ const take = Math.min(Math.abs(rem), Math.abs(lots[0].qty));
328
+ const pnl = lots[0].qty > 0 ? take * (t.price - lots[0].price) : take * (lots[0].price - t.price);
329
+ closedCount++;
330
+ if (pnl >= 0) winRub += pnl; else lossRub += -pnl;
331
+ lots[0].qty -= Math.sign(lots[0].qty) * take;
332
+ rem -= Math.sign(rem) * take;
333
+ if (lots[0].qty === 0) lots.shift();
334
+ }
335
+ if (rem !== 0) lots.push({ qty: rem, price: t.price });
336
+ book.set(t.symbol, lots);
337
+ }
338
+ const grossRub = round(winRub - lossRub, 0);
339
+ const netRub = round(grossRub - feesRub, 0);
340
+ const profitFactorGross = lossRub > 0 ? round(winRub / lossRub, 2) : (winRub > 0 ? 3 : null);
341
+
342
+ return {
343
+ fromTs: algoOps[0]?.date || null,
344
+ tradesCount: trades.length,
345
+ closedCount,
346
+ turnoverRub: round(turnoverRub, 0),
347
+ commPct,
348
+ slipAllPct,
349
+ slipTier1Pct,
350
+ slipTier2Pct,
351
+ totalCostPerSidePct,
352
+ breakEvenTargetPct: 0.255,
353
+ grossRub,
354
+ feesRub: round(feesRub, 0),
355
+ netRub,
356
+ profitFactorGross,
357
+ };
145
358
  }
146
359
 
147
360
  function publicAccount(a) {
@@ -162,13 +375,18 @@ function publicAccount(a) {
162
375
  }),
163
376
  };
164
377
  }
165
- return { ...a, lastSync, token: a.token || '', hasToken: Boolean(a.token && String(a.token).trim()), baseRunLabel };
378
+ const executionAudit = computeExecutionAudit(a);
379
+ // Токен брокера никогда не покидает сервер: в браузер, события WebSocket и ответы ИИ уходит только маска
380
+ const { token: rawToken, ...rest } = a;
381
+ const tok = String(rawToken || '').trim();
382
+ return { ...rest, lastSync, executionAudit, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
166
383
  }
167
384
 
168
385
  export function brokersState() {
169
386
  return {
170
387
  brokers: BROKER_META,
171
388
  accounts: loadRawAccounts().map(publicAccount),
389
+ trading: tradingGate(),
172
390
  };
173
391
  }
174
392
 
@@ -187,8 +405,20 @@ export function saveBrokerAccount(input = {}) {
187
405
  }
188
406
  const prev = idx >= 0 ? list[idx] : {};
189
407
  const broker = BROKER_META.some((b) => b.id === input.broker) ? input.broker : (prev.broker || 'tbank');
408
+ // СберИнвестиции / ВТБ / Альфа без моста QUIK не отправляют заявку брокеру, а лишь отмечают «исполнение» в платформе —
409
+ // новые такие счета не создаются (существующие продолжают работать до перевода на поддерживаемого брокера)
410
+ if (!prev.id && MANUAL_BROKERS.includes(broker) && process.env.ENABLE_EXPERIMENTAL_BROKERS !== '1') {
411
+ throw new Error('Подключение СберИнвестиций, ВТБ и Альфа-Инвестиций временно недоступно: у этих брокеров нет открытого API для заявок. Используйте Т-Банк.');
412
+ }
190
413
  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');
414
+ let mode = ['readonly', 'confirm', 'auto'].includes(input.mode) ? input.mode : (prev.mode || 'confirm');
415
+ // Полный автомат — только у брокера с проверенной поддержкой: средняя цена (защитные стопы), отмена заявок и история операций
416
+ if (mode === 'auto' && !FULLY_SUPPORTED_BROKERS.includes(broker)) {
417
+ if (input.mode === 'auto') {
418
+ throw new Error(`Режим «Автомат» пока доступен только для Т-Банка. У брокера «${BROKER_META.find((b) => b.id === broker)?.name || broker}» нет средней цены позиций, отмены заявок и истории — защитные стопы не сработают. Выберите «Подтверждение заявок».`);
419
+ }
420
+ mode = 'confirm';
421
+ }
192
422
  const legacyMode = ['isolate', 'smooth', 'takeover'].includes(input.legacyMode) ? input.legacyMode : (prev.legacyMode || 'isolate');
193
423
  let token = prev.token || '';
194
424
  if (input.clearToken) token = '';
@@ -199,14 +429,15 @@ export function saveBrokerAccount(input = {}) {
199
429
  const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
200
430
  const maxCapitalRub = Math.min(rawMaxCap, capCeiling);
201
431
  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);
432
+ const nextLimitOffset = Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.02)));
433
+ const strategyOrCapChanged = idx < 0
434
+ || acctChanged
435
+ || nextBaseRunId !== (prev.baseRunId || null)
436
+ || maxCapitalRub !== (prev.maxCapitalRub || 100000)
437
+ || legacyMode !== (prev.legacyMode || 'isolate')
438
+ || mode !== (prev.mode || 'confirm')
439
+ || nextLimitOffset !== (prev.limitOffsetPct ?? 0.02)
440
+ || (input.cashSweep !== undefined && Boolean(input.cashSweep) !== Boolean(prev.cashSweep));
210
441
  const next = {
211
442
  id,
212
443
  name,
@@ -222,6 +453,7 @@ export function saveBrokerAccount(input = {}) {
222
453
  lastHistory: acctChanged ? null : (prev.lastHistory || null),
223
454
  entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
224
455
  exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
456
+ peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
225
457
  baseRunId: nextBaseRunId,
226
458
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
227
459
  maxCapitalRub,
@@ -230,7 +462,16 @@ export function saveBrokerAccount(input = {}) {
230
462
  skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
231
463
  autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
232
464
  lastScheduledBarTs: strategyOrCapChanged ? 0 : (prev.lastScheduledBarTs || 0),
233
- haltedReason: input.resetHalt ? null : (prev.haltedReason || null),
465
+ // дневной стоп при сохранении настроек не снимается — только отдельным действием «Снять стоп до конца дня»
466
+ haltedReason: prev.haltedReason || null,
467
+ ddOverrideDay: prev.ddOverrideDay || null,
468
+ // защитные стоп-заявки у брокера (бета, только Т-Банк); выставленные заявки сохраняются при любом изменении настроек
469
+ brokerStops: broker === 'tbank' && (input.brokerStops !== undefined ? Boolean(input.brokerStops) : Boolean(prev.brokerStops)),
470
+ brokerStopBySymbol: prev.brokerStopBySymbol || {},
471
+ brokerStopCooldown: prev.brokerStopCooldown || {},
472
+ // авто-размещение свободных рублей в фонд ликвидности LQDT и авто-продажа из него под сигналы стратегии (только Т-Банк)
473
+ cashSweep: broker === 'tbank' && (input.cashSweep !== undefined ? Boolean(input.cashSweep) : Boolean(prev.cashSweep)),
474
+ ddBreachCount: prev.ddBreachCount || 0,
234
475
  token,
235
476
  lastSync: prev.lastSync || null,
236
477
  pendingBatch: strategyOrCapChanged ? null : (prev.pendingBatch || null),
@@ -254,9 +495,48 @@ export function saveBrokerAccount(input = {}) {
254
495
  return st;
255
496
  }
256
497
 
257
- export function deleteBrokerAccount(id) {
258
- const list = loadRawAccounts().filter((x) => x.id !== id);
498
+ // Осознанное снятие дневного стопа: до конца текущего дня (МСК) стоп по просадке на этом счёте больше не срабатывает.
499
+ // Блокировку за автоследование снять нельзя — она восстанавливается при каждой синхронизации.
500
+ export function resetBrokerDailyStop(id) {
501
+ const list = loadRawAccounts();
502
+ const acc = list.find((x) => x.id === id);
503
+ if (!acc) throw new Error('Счёт не найден');
504
+ if (!acc.haltedReason) throw new Error('Дневной стоп на этом счёте не активен');
505
+ if (!acc.haltedReason.startsWith('Сработал дневной стоп')) throw new Error('Эту блокировку снять нельзя: ' + acc.haltedReason);
506
+ const today = new Date(Date.now() + MSK).toISOString().slice(0, 10);
507
+ logBrokerEvent(acc, 'daily_stop_reset', `снят вручную до конца дня (${acc.haltedReason})`);
508
+ acc.haltedReason = null;
509
+ acc.ddBreachCount = 0;
510
+ acc.ddOverrideDay = today;
511
+ saveRawAccounts(list);
512
+ const st = brokersState();
513
+ hub.emit({ type: 'brokers:update', state: st });
514
+ return st;
515
+ }
516
+
517
+ // Аварийная остановка всех счетов пользователя: авто-торговля на паузу, все активные заявки у брокера снимаются.
518
+ // Защитные стопы по открытым позициям продолжают работать.
519
+ export async function stopAllBrokerTrading() {
520
+ const list = loadRawAccounts();
521
+ for (const acc of list) {
522
+ acc.autoSchedule = false;
523
+ if (acc.pendingBatch && acc.pendingBatch.status === 'pending') acc.pendingBatch = null;
524
+ logBrokerEvent(acc, 'stop_all', 'аварийная остановка: авто-торговля на паузе, активные заявки снимаются');
525
+ }
259
526
  saveRawAccounts(list);
527
+ const errors = [];
528
+ for (const acc of list) {
529
+ if (acc.broker === 'tbank' && acc.token) {
530
+ await cancelBrokerOrder(acc.id, 'all').catch((e) => errors.push(`${acc.name}: ${e.message}`));
531
+ }
532
+ }
533
+ const st = brokersState();
534
+ hub.emit({ type: 'brokers:update', state: st });
535
+ return { ...st, stopErrors: errors };
536
+ }
537
+
538
+ export function deleteBrokerAccount(id) {
539
+ deleteRawAccount(id);
260
540
  const st = brokersState();
261
541
  hub.emit({ type: 'brokers:update', state: st });
262
542
  return st;
@@ -278,7 +558,8 @@ function brokerFetch(url, { method = 'GET', headers = {}, body = null, timeoutMs
278
558
  path: u.pathname + u.search,
279
559
  method,
280
560
  headers: reqHeaders,
281
- rejectUnauthorized: false,
561
+ ca: TRUSTED_CA,
562
+ rejectUnauthorized: true,
282
563
  timeout: timeoutMs,
283
564
  }, (res) => {
284
565
  const chunks = [];
@@ -378,11 +659,45 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
378
659
  return cache;
379
660
  }
380
661
 
662
+ // Возвращает живые секундные цены акций из T-Invest API (GetLastPrices, 0 секунд задержки), если у любого пользователя подключён счёт Т-Банка
663
+ export async function fetchTbankRealtimePrices() {
664
+ try {
665
+ // только токен владельца текущих данных — токены других пользователей не используются никогда
666
+ const tbAcc = loadRawAccounts().find((a) => a.broker === 'tbank' && a.token);
667
+ if (!tbAcc?.token) return null;
668
+ await ensureTbankShortableMap(tbAcc);
669
+ const uidMap = kvGet('tbankShortableMap')?.uidMap || {};
670
+ const entries = Object.entries(uidMap).filter(([, uid]) => Boolean(uid));
671
+ if (!entries.length) return null;
672
+ const uidToSym = new Map(entries.map(([sym, uid]) => [uid, sym]));
673
+ const instrumentIds = entries.map(([, uid]) => uid);
674
+ const base = tbAcc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
675
+ const headers = { Authorization: `Bearer ${tbAcc.token}`, 'Content-Type': 'application/json' };
676
+ const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetLastPrices`, {
677
+ method: 'POST',
678
+ headers,
679
+ body: JSON.stringify({ instrumentId: instrumentIds }),
680
+ timeoutMs: 5000,
681
+ });
682
+ if (!r.ok) return null;
683
+ const j = await r.json();
684
+ const prices = new Map();
685
+ for (const lp of j.lastPrices || []) {
686
+ const sym = uidToSym.get(lp.instrumentUid) || uidToSym.get(lp.figi);
687
+ const px = qVal(lp.price);
688
+ if (sym && px > 0) prices.set(sym, px);
689
+ }
690
+ return prices.size > 0 ? prices : null;
691
+ } catch {
692
+ return null;
693
+ }
694
+ }
695
+
381
696
  // Загружает и кэширует на 12 часов справочник доступности шортов, UID и точных параметров лота/шага цены по тикерам в Т-Банке (TQBR + SPBRU для SPBE)
382
697
  async function ensureTbankShortableMap(acc) {
383
698
  if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
384
699
  const cached = kvGet('tbankShortableMap');
385
- if (cached?.v === 4 && cached?.map && Date.now() - cached.at < 12 * 3600000) {
700
+ if (cached?.v === 5 && cached?.map && Date.now() - cached.at < 12 * 3600000) {
386
701
  if (cached.uidMap) {
387
702
  for (const [sym, uid] of Object.entries(cached.uidMap)) tbankUidCache.set(sym, uid);
388
703
  }
@@ -391,20 +706,29 @@ async function ensureTbankShortableMap(acc) {
391
706
  try {
392
707
  const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
393
708
  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
- });
709
+ const [r, rEtfs] = await Promise.all([
710
+ brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/Shares`, {
711
+ method: 'POST',
712
+ headers,
713
+ body: JSON.stringify({ instrumentStatus: 'INSTRUMENT_STATUS_BASE' }),
714
+ timeoutMs: 15000,
715
+ }),
716
+ brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/Etfs`, {
717
+ method: 'POST',
718
+ headers,
719
+ body: JSON.stringify({ instrumentStatus: 'INSTRUMENT_STATUS_BASE' }),
720
+ timeoutMs: 15000,
721
+ }).catch(() => null),
722
+ ]);
400
723
  if (!r.ok) return cached?.map || null;
401
724
  const j = await r.json();
725
+ const jEtfs = rEtfs?.ok ? await rEtfs.json().catch(() => null) : null;
402
726
  const map = {};
403
727
  const uidMap = { ...(cached?.uidMap || {}) };
404
728
  const specMap = { ...(cached?.specMap || {}) };
405
- for (const inst of j.instruments || []) {
729
+ for (const inst of [...(j.instruments || []), ...(jEtfs?.instruments || [])]) {
406
730
  if (!inst.ticker || inst.apiTradeAvailableFlag === false) continue;
407
- if (inst.classCode === 'TQBR' || (inst.classCode === 'SPBRU' && !(inst.ticker in map))) {
731
+ if (inst.classCode === 'TQBR' || inst.classCode === 'TQTF' || (inst.classCode === 'SPBRU' && !(inst.ticker in map))) {
408
732
  map[inst.ticker] = Boolean(inst.shortEnabledFlag);
409
733
  const uid = inst.uid || inst.figi;
410
734
  if (uid) {
@@ -422,7 +746,7 @@ async function ensureTbankShortableMap(acc) {
422
746
  }
423
747
  }
424
748
  }
425
- kvSet('tbankShortableMap', { v: 4, at: Date.now(), map, uidMap, specMap });
749
+ kvSet('tbankShortableMap', { v: 5, at: Date.now(), map, uidMap, specMap });
426
750
  return map;
427
751
  } catch {
428
752
  return cached?.map || null;
@@ -462,7 +786,7 @@ async function resolveTbankInstrumentId(base, headers, symbol) {
462
786
  });
463
787
  if (r.ok) {
464
788
  const j = await r.json();
465
- const hit = (j.instruments || []).find((x) => x.ticker === symbol && x.apiTradeAvailableFlag === true && x.instrumentKind === 'INSTRUMENT_TYPE_SHARE');
789
+ const hit = (j.instruments || []).find((x) => x.ticker === symbol && x.apiTradeAvailableFlag === true && (x.instrumentKind === 'INSTRUMENT_TYPE_SHARE' || x.instrumentKind === 'INSTRUMENT_TYPE_ETF'));
466
790
  const uid = hit?.uid || hit?.figi;
467
791
  if (uid) { tbankUidCache.set(symbol, uid); return uid; }
468
792
  }
@@ -562,6 +886,7 @@ async function syncTbank(acc) {
562
886
  method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 8000,
563
887
  }).catch(() => null)
564
888
  )));
889
+ logBrokerEvent(acc, 'order_expired', `авто-снятие ${toCancelIds.length} неисполненных/дублирующих заявок (>12 мин в стакане)`);
565
890
  rawOrders = keptOrders;
566
891
  }
567
892
  }
@@ -1004,10 +1329,11 @@ export async function syncBrokerAccount(id) {
1004
1329
  const ddPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
1005
1330
  if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
1006
1331
  acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
1007
- } else if (ddPct >= acc.maxDailyDrawdownPct) {
1332
+ } else if (ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
1008
1333
  // Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
1009
1334
  acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
1010
1335
  if (acc.ddBreachCount >= 2) {
1336
+ if (!acc.haltedReason) logBrokerEvent(acc, 'daily_stop', `просадка −${ddPct}% при пороге ${acc.maxDailyDrawdownPct}%`);
1011
1337
  acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
1012
1338
  }
1013
1339
  } else {
@@ -1022,16 +1348,46 @@ export async function syncBrokerAccount(id) {
1022
1348
  }
1023
1349
  acc.equityHistory = hist;
1024
1350
  const entryMap = { ...(acc.entryTsBySymbol || {}) };
1025
- const curSymbols = new Set((data.positions || []).filter((p) => p.qty !== 0).map((p) => p.symbol));
1026
- for (const s of curSymbols) {
1027
- // Если позиция обнаружена при синхронизации без записи в executeBrokerOrders (например, перешла со вчерашнего дня),
1028
- // помечаем её как зрелую (120 мин), чтобы для неё сразу действовал штатный порог выхода 0.95, а не стартовый 65-минутный буфер
1029
- if (!entryMap[s]) entryMap[s] = nowTs - 120 * 60000;
1351
+ const peakMap = { ...(acc.peakPriceBySymbol || {}) };
1352
+ const curPositions = (data.positions || []).filter((p) => p.qty !== 0);
1353
+ const curSymbols = new Set(curPositions.map((p) => p.symbol));
1354
+ const pendingOrderSymbols = new Set((data.activeOrders || []).map((o) => o.symbol).filter(Boolean));
1355
+ const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
1356
+ for (const p of curPositions) {
1357
+ const s = p.symbol;
1358
+ if (!entryMap[s]) {
1359
+ // Ищем реальное время последней открывающей сделки по этой бумаге в истории брокера
1360
+ const wantKind = p.qty > 0 ? 'buy' : 'sell';
1361
+ const lastEntryOp = histOps.find((op) => op.symbol === s && op.kind === wantKind && op.date > 0);
1362
+ // Если бумага только что появилась в портфеле (например, исполнилась лимитная заявка),
1363
+ // ставим реальное время сделки или nowTs (возраст 0 мин), чтобы 65-минутный буфер удержания всегда работал!
1364
+ entryMap[s] = lastEntryOp?.date || nowTs;
1365
+ }
1366
+ // Отслеживаем пиковую лучшую цену позиции с момента входа (для фиксации прибыли и защиты безубытка)
1367
+ const curPx = Number(p.price) || 0;
1368
+ const avgPx = Number(p.avgPrice) || curPx;
1369
+ if (curPx > 0) {
1370
+ if (p.qty > 0) {
1371
+ peakMap[s] = Math.max(Number(peakMap[s]) || avgPx || curPx, curPx);
1372
+ } else if (p.qty < 0) {
1373
+ const prevBest = Number(peakMap[s]) > 0 ? Number(peakMap[s]) : (avgPx || curPx);
1374
+ peakMap[s] = Math.min(prevBest, curPx);
1375
+ }
1376
+ }
1030
1377
  }
1031
1378
  for (const s of Object.keys(entryMap)) {
1032
- if (!curSymbols.has(s)) delete entryMap[s];
1379
+ // Не удаляем метку времени входа, если лимитная заявка по бумаге ещё стоит в стакане
1380
+ // или была отправлена менее 20 минут назад (чтобы не сбросить 65-минутный таймер до исполнения лимитки!)
1381
+ const isRecentEntryOrder = nowTs - entryMap[s] < 20 * 60000;
1382
+ if (!curSymbols.has(s) && !pendingOrderSymbols.has(s) && !isRecentEntryOrder) {
1383
+ delete entryMap[s];
1384
+ }
1385
+ }
1386
+ for (const s of Object.keys(peakMap)) {
1387
+ if (!curSymbols.has(s)) delete peakMap[s];
1033
1388
  }
1034
1389
  acc.entryTsBySymbol = entryMap;
1390
+ acc.peakPriceBySymbol = peakMap;
1035
1391
  acc.lastSync = { at: nowTs, ok: true, error: null, day: today, dayStartRub, dayDrawdownPct: ddPct, ...data };
1036
1392
  } catch (e) {
1037
1393
  acc.lastSync = { ...(acc.lastSync || {}), at: Date.now(), ok: false, error: e.message };
@@ -1080,7 +1436,12 @@ function sessionEdgeWarning(now = Date.now()) {
1080
1436
  }
1081
1437
 
1082
1438
  // ---------- формирование и исполнение пакета лимитных заявок ----------
1083
- export async function prepareBrokerOrders(id) {
1439
+ const accountBusyLocks = new Set();
1440
+
1441
+ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1442
+ if (accountBusyLocks.has(id)) return brokersState();
1443
+ accountBusyLocks.add(id);
1444
+ try {
1084
1445
  let list = loadRawAccounts();
1085
1446
  let acc = list.find((x) => x.id === id);
1086
1447
  if (!acc) throw new Error('Счёт не найден');
@@ -1092,8 +1453,8 @@ export async function prepareBrokerOrders(id) {
1092
1453
  throw new Error('Для работы с брокерскими счетами оформите подписку «Инвестор» (3 900 ₽/мес), «Профи» (9 900 ₽/мес) или «VIP» в разделе «Подписка и уведомления».');
1093
1454
  }
1094
1455
  hub.emit({ type: 'trading:pulse', kind: 'broker_calc', ts: Date.now(), accountId: acc.id, name: acc.name, text: `Пересчёт заявок по счёту «${acc.name}»…` });
1095
- // Перед расчётом заявок автоматически снимаем старые неисполненные заявки со стакана и синхронизируем свежий баланс!
1096
- if (acc.token && acc.mode !== 'readonly' && acc.broker === 'tbank' && acc.lastSync?.activeOrders?.length > 0) {
1456
+ // При плановом шаге снимаем старые неисполненные заявки со стакана, а при быстром доборе (onlyEntries) не трогаем уже стоящие свежие заявки!
1457
+ if (!onlyEntries && acc.token && acc.mode !== 'readonly' && acc.broker === 'tbank' && acc.lastSync?.activeOrders?.length > 0) {
1097
1458
  await cancelBrokerOrder(id, 'all').catch(() => {});
1098
1459
  list = loadRawAccounts();
1099
1460
  acc = list.find((x) => x.id === id) || acc;
@@ -1149,24 +1510,59 @@ export async function prepareBrokerOrders(id) {
1149
1510
  const vipBoost = Boolean(plan.allowVipBoost);
1150
1511
  const rawDir = params.modelDirection || 'long';
1151
1512
  const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
1152
- const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
1513
+ // В реальном стакане брокера (где каждый вход-выход стоит 0.08% комиссии + спред) используем гистерезис удержания 0.58
1514
+ // (около +0.145%), чтобы не закрывать открытую позицию преждевременно при положительном прогнозе +0.16%…+0.23%!
1515
+ const exitRatio = autoAdapt ? 0.58 : Number(params.modelExitRatio ?? 0.5);
1153
1516
  const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
1154
- const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.35, 3);
1517
+ const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.25, 3);
1155
1518
  const nowTs = Date.now();
1156
1519
  const entryTsMap = acc.entryTsBySymbol || {};
1157
1520
  const exitCooldownMap = acc.exitCooldownBySymbol || {};
1158
1521
  const mskDate = new Date(nowTs + MSK);
1159
1522
  const isEveningSession = (mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes()) >= 19 * 60;
1160
1523
  const eveningBoost = (autoAdapt && isEveningSession) ? 1.25 : 1.0;
1161
- const minEntryEdgePct = round((fc.thresholdPct || 0) * eveningBoost, 3);
1524
+ // Спред-фильтр реального стакана по эшелонам ликвидности:
1525
+ // для 1-го эшелона требуем минимум базовый порог (≥ 0.50%), а для 2–3 эшелона (широкий спред стакана) — в 1.35× выше (≥ 0.675%)
1526
+ const baseBuyNeedPct = Number(fc.buyThresholdPct ?? fc.thresholdPct ?? 0.50);
1527
+ const baseShortNeedPct = Number(fc.shortThresholdPct ?? fc.thresholdPct ?? 0.50);
1528
+ const minBuyEdgePct = round(baseBuyNeedPct * eveningBoost, 3);
1529
+ const minShortEdgePct = round(baseShortNeedPct * eveningBoost, 3);
1530
+ const reqBuyEdgeOf = (sym) => round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30 * eveningBoost), 3);
1531
+ const reqShortEdgeOf = (sym) => round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31 * eveningBoost), 3);
1162
1532
  const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
1533
+ // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, как в research.js):
1534
+ // если за последние 14 дней по бумаге было >= 3 закрытых сделок на счёте и их суммарный итог отрицательный (< -1.0%),
1535
+ // временно исключаем такую бумагу из новых входов, чтобы не наступать на одни и те же грабли в неликвиде!
1536
+ const quarantined = new Set();
1537
+ if (autoAdapt && Array.isArray(acc.lastHistory?.operations)) {
1538
+ const cutoffTs = nowTs - 14 * DAY;
1539
+ const recentOps = acc.lastHistory.operations
1540
+ .filter((op) => op.date >= cutoffTs && op.symbol && (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'fee'))
1541
+ .sort((a, b) => a.date - b.date);
1542
+ const heldNowSet = new Set((acc.lastSync?.positions || []).filter((p) => p.qty !== 0).map((p) => p.symbol));
1543
+ const statBySym = {};
1544
+ for (const op of recentOps) {
1545
+ if (heldNowSet.has(op.symbol)) continue; // считаем только полностью закрытые цепочки сделок
1546
+ const st = statBySym[op.symbol] || (statBySym[op.symbol] = { buys: 0, sells: 0, buyRub: 0, netRub: 0 });
1547
+ const pay = Number(op.paymentRub) || 0;
1548
+ if (op.kind === 'buy') { st.buys++; st.buyRub += Math.abs(pay); st.netRub += pay; }
1549
+ else if (op.kind === 'sell') { st.sells++; st.netRub += pay; }
1550
+ else if (op.kind === 'fee') { st.netRub += pay; }
1551
+ }
1552
+ for (const [sym, st] of Object.entries(statBySym)) {
1553
+ const closedRounds = Math.min(st.buys, st.sells);
1554
+ if (closedRounds >= 3 && st.buyRub > 10000 && (st.netRub / st.buyRub) < -0.009) {
1555
+ quarantined.add(sym);
1556
+ }
1557
+ }
1558
+ }
1163
1559
  const slots = dir === 'long' ? K : 2 * K;
1164
1560
  // Фильтр мощности слота по тарифу:
1165
- // • Инвестор (до 60% годовых): 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
1166
- // • Профи (до 120% годовых): до 5 счетов по 1 млн ₽, покупки + шорты, базовый слот до 28% капитала
1167
- // • VIP (до 240% годовых): счета и сумма по согласованию, покупки + шорты, слот до 48% капитала
1168
- // • Хозяин (без ограничений): полная мощность ядра (>400% годовых, Conviction Sizing до 75% капитала)
1169
- const isOwnerPlan = Boolean(plan.isOwner || plan.id === 'owner' || plan.maxAnnualReturnPct === 0);
1561
+ // • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
1562
+ // • Профи: до 5 счетов по 1 млн ₽, покупки + шорты, базовый слот до 28% капитала
1563
+ // • VIP: счета и сумма по договору, покупки + шорты, слот до 48% капитала
1564
+ // • Хозяин: Conviction Sizing до 75% капитала
1565
+ const isOwnerPlan = Boolean(plan.isOwner || plan.id === 'owner');
1170
1566
  let slotRub;
1171
1567
  if (isOwnerPlan && autoAdapt) {
1172
1568
  const maxSlotWeight = Math.min(0.45, Math.max(1 / K, 1.8 / K));
@@ -1179,19 +1575,8 @@ export async function prepareBrokerOrders(id) {
1179
1575
  } else {
1180
1576
  slotRub = budget * Math.min(0.24, 1.05 / Math.max(3, K + 1));
1181
1577
  }
1182
- // Мягкий дроссель при опережении годового потолка доходности тарифа (60% Инвестор, 120% Профи, 240% VIP; у Хозяина = 0 — без потолка)
1183
- if (!isOwnerPlan && plan.maxAnnualReturnPct > 0 && Array.isArray(acc.equityHistory) && acc.equityHistory.length >= 2) {
1184
- const firstPt = acc.equityHistory[0];
1185
- const lastPt = acc.equityHistory[acc.equityHistory.length - 1];
1186
- const elapsedDays = Math.max(7, (lastPt.ts - firstPt.ts) / DAY);
1187
- if (firstPt.totalRub > 0 && lastPt.totalRub > firstPt.totalRub) {
1188
- const curGainPct = ((lastPt.totalRub - firstPt.totalRub) / firstPt.totalRub) * 100;
1189
- const targetGainPct = plan.maxAnnualReturnPct * (elapsedDays / 365);
1190
- if (curGainPct > targetGainPct) {
1191
- slotRub *= Math.max(0.25, targetGainPct / curGainPct);
1192
- }
1193
- }
1194
- }
1578
+ // (Потолки доходности по тарифам и «дроссель» размера позиций удалены: тарифы различаются возможностями,
1579
+ // а не обещанной доходностью — реалистичная доходность определяется стресс-тестом, а не тарифом.)
1195
1580
  const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
1196
1581
  const convOf = (r) => {
1197
1582
  if (!autoAdapt || !r.expectedPct) return 1;
@@ -1201,11 +1586,11 @@ export async function prepareBrokerOrders(id) {
1201
1586
  };
1202
1587
  const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
1203
1588
  const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
1204
- const quarantined = new Set();
1205
1589
  const nb = params.newsFilter ? blockingNews(6) : null;
1206
1590
 
1207
- 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);
1208
- 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) : [];
1591
+ 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));
1592
+ 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);
1593
+ 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) : [];
1209
1594
  const nonShortableSkipped = [];
1210
1595
  const rawShortCands = [];
1211
1596
  for (const r of allShortSignals) {
@@ -1232,7 +1617,11 @@ export async function prepareBrokerOrders(id) {
1232
1617
  const orders = [];
1233
1618
  const closing = new Set();
1234
1619
  const smoothSellPool = [];
1235
- // 1) закрытие бумаг стратегии (покупок и шортов), чей прогноз ослаб ниже порога выхода exitThresholdPct
1620
+ const peakMap = acc.peakPriceBySymbol || {};
1621
+ const roundTripFeePct = 2 * ((Number(params.commissionPct) || 0.04) + (Number(params.slippagePct) || 0.01));
1622
+ // 1) закрытие бумаг стратегии (покупок и шортов):
1623
+ // • по фиксации реальной прибыли (Take-Profit / Trailing Stop от лучшей достигнутой цены peakPrice, чтобы не отдавать заработанный плюс в минус!),
1624
+ // • либо если прогноз ослаб ниже порога выхода curExitThr.
1236
1625
  // Облигации, фонды (LQDT/TMOS) и бумаги вне словаря MOEXBMI (!rowMap.has(sym)) не затрагиваются никогда
1237
1626
  for (const [sym, pos] of heldMap) {
1238
1627
  if (!pos.qty) continue;
@@ -1241,31 +1630,50 @@ export async function prepareBrokerOrders(id) {
1241
1630
  const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
1242
1631
  const aQty = algoQtyOf(sym, pos.qty);
1243
1632
  const baseQty = pos.qty - aQty;
1244
- const heldMs = entryTsMap[sym] ? (nowTs - entryTsMap[sym]) : (120 * 60000);
1633
+ const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
1634
+ const wantOpenKind = aQty >= 0 ? 'buy' : 'sell';
1635
+ const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenKind && op.date > 0);
1636
+ const lastHistEntryTs = lastHistEntryOp?.date || 0;
1637
+ const effEntryTs = Math.max(entryTsMap[sym] || 0, lastHistEntryTs);
1638
+ const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
1245
1639
  // Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
1246
1640
  // чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95)
1247
1641
  const curExitThr = (autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct;
1642
+ const curPx = Number(r.price || pos.price) || 0;
1643
+ const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
1248
1644
  if (aQty > 0) {
1249
1645
  const lots = Math.floor(aQty / sp.lotSize);
1250
- const keep = r.liquid && r.expectedPct > curExitThr;
1646
+ const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1647
+ const hitHardStopLoss = curGainPct <= -2.6;
1648
+ const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1649
+ const keep = keepByForecast && !hitHardStopLoss;
1251
1650
  if (lots >= 1 && !keep) {
1252
- const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
1651
+ const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
1652
+ const exitReason = hitHardStopLoss
1653
+ ? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
1654
+ : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1253
1655
  orders.push({
1254
1656
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1255
- refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1256
- reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`,
1657
+ refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1658
+ reason: exitReason,
1257
1659
  });
1258
1660
  closing.add(sym);
1259
1661
  }
1260
1662
  } else if (aQty < 0) {
1261
1663
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1262
- const keep = dir !== 'long' && r.liquid && r.expectedPct < -curExitThr;
1664
+ const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1665
+ const hitHardStopLoss = curGainPct <= -2.2;
1666
+ const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1667
+ const keep = keepByForecast && !hitHardStopLoss;
1263
1668
  if (lots >= 1 && !keep) {
1264
- const limitPrice = roundToStep(pos.price * (1 + acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
1669
+ const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
1670
+ const exitReason = hitHardStopLoss
1671
+ ? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
1672
+ : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1265
1673
  orders.push({
1266
1674
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1267
- refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1268
- reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`,
1675
+ refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1676
+ reason: exitReason,
1269
1677
  });
1270
1678
  closing.add(sym);
1271
1679
  }
@@ -1277,7 +1685,7 @@ export async function prepareBrokerOrders(id) {
1277
1685
  const bLots = Math.floor(baseQty / sp.lotSize);
1278
1686
  if (bLots >= 1 && !(r.liquid && r.expectedPct > exitThresholdPct)) {
1279
1687
  if (r.expectedPct <= -needPct) {
1280
- const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
1688
+ const limitPrice = sniperLimitPrice(pos.price, -1, acc.limitOffsetPct, sp);
1281
1689
  orders.push({
1282
1690
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots: bLots, shares: bLots * sp.lotSize,
1283
1691
  refPrice: pos.price, limitPrice, amountRub: round(bLots * sp.lotSize * limitPrice, 2),
@@ -1290,25 +1698,82 @@ export async function prepareBrokerOrders(id) {
1290
1698
  }
1291
1699
  }
1292
1700
  }
1293
- const activeLongs = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) > 0 && !closing.has(p.symbol)).length;
1294
- const activeShorts = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol)).length;
1295
- 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);
1701
+ const activePendingOrders = acc.lastSync?.activeOrders || [];
1702
+ const activeOrderSymbols = new Set(activePendingOrders.map((o) => o.symbol).filter(Boolean));
1703
+ const pendingBuyOrdersCount = activePendingOrders.filter((o) => o.direction === 'buy').length;
1704
+ const pendingSellOrdersCount = activePendingOrders.filter((o) => o.direction === 'sell').length;
1705
+ let activeLongs = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) > 0 && !closing.has(p.symbol)).length + (onlyEntries ? pendingBuyOrdersCount : 0);
1706
+ let activeShorts = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol)).length + (onlyEntries ? pendingSellOrdersCount : 0);
1707
+ 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)
1708
+ + (onlyEntries ? activePendingOrders.reduce((s, o) => s + Math.abs(Number(o.amountRub) || 0), 0) : 0);
1709
+
1710
+ // Ротация остывшей позиции в мощного нового лидера (Opportunity Rotation):
1711
+ // если на счёте занято >65% бюджета, а на рынке появился новый лидер с прогнозом >= +0.55% (в 2.4 раза сильнее текущей остывшей бумаги),
1712
+ // освобождаем слот под лидера, чтобы капитал не застревал в вялых позициях
1713
+ if (!onlyEntries && autoAdapt && usedRub > budget * 0.65 && (buyCands.length > 0 || shortCands.length > 0)) {
1714
+ const bestNewPct = Math.max(
1715
+ buyCands.length ? Math.abs(buyCands[0].expectedPct || 0) : 0,
1716
+ shortCands.length ? Math.abs(shortCands[0].expectedPct || 0) : 0,
1717
+ );
1718
+ if (bestNewPct >= needPct * 2.2) {
1719
+ let weakestSym = null, weakestEdge = Infinity;
1720
+ for (const [sym, pos] of heldMap) {
1721
+ if (closing.has(sym) || !rowMap.has(sym)) continue;
1722
+ const aQ = algoQtyOf(sym, pos.qty);
1723
+ if (!aQ) continue;
1724
+ const rCur = rowMap.get(sym);
1725
+ const curSignedPct = aQ > 0 ? (rCur.expectedPct || 0) : -(rCur.expectedPct || 0);
1726
+ if (curSignedPct < exitThresholdPct * 0.90 && curSignedPct < weakestEdge && bestNewPct > Math.max(needPct, curSignedPct) * 2.4) {
1727
+ weakestEdge = curSignedPct;
1728
+ weakestSym = sym;
1729
+ }
1730
+ }
1731
+ if (weakestSym) {
1732
+ const wPos = heldMap.get(weakestSym);
1733
+ const wRow = rowMap.get(weakestSym);
1734
+ const wSp = specs[weakestSym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
1735
+ const wAq = algoQtyOf(weakestSym, wPos.qty);
1736
+ const wLots = Math.floor(Math.abs(wAq) / wSp.lotSize);
1737
+ const wPx = Number(wRow?.price || wPos.price) || 0;
1738
+ if (wLots >= 1 && wPx > 0) {
1739
+ const isLongPos = wAq > 0;
1740
+ const limPx = sniperLimitPrice(wPx, isLongPos ? -1 : 1, acc.limitOffsetPct, wSp);
1741
+ orders.push({
1742
+ symbol: weakestSym,
1743
+ action: isLongPos ? 'sell' : 'cover',
1744
+ lotSize: wSp.lotSize,
1745
+ lots: wLots,
1746
+ shares: wLots * wSp.lotSize,
1747
+ refPrice: wPx,
1748
+ limitPrice: limPx,
1749
+ amountRub: round(wLots * wSp.lotSize * limPx, 2),
1750
+ reason: `Ротация капитала из остывшей бумаги (${wRow?.expectedPct > 0 ? '+' : ''}${wRow?.expectedPct}%) в нового сильного лидера (${bestNewPct}%)`,
1751
+ });
1752
+ closing.add(weakestSym);
1753
+ if (isLongPos) activeLongs = Math.max(0, activeLongs - 1);
1754
+ else activeShorts = Math.max(0, activeShorts - 1);
1755
+ usedRub = Math.max(0, usedRub - Math.abs(wAq * wPos.price));
1756
+ }
1757
+ }
1758
+ }
1759
+ }
1296
1760
  const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
1297
1761
  const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
1298
1762
  let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
1299
1763
 
1300
1764
  // 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
1301
- const rawNewBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol)));
1765
+ const rawNewBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol)) && !(onlyEntries && activeOrderSymbols.has(r.symbol)));
1302
1766
  // Не открываем шорт по тикеру, в котором ещё лежит непроданный остаток старого портфеля (baseQty > 0), чтобы брокер не превратил шорт в продажу старой позиции
1303
1767
  const rawNewShorts = shortCands.filter((r) => {
1768
+ if (onlyEntries && activeOrderSymbols.has(r.symbol)) return false;
1304
1769
  const curQ = heldMap.get(r.symbol)?.qty || 0;
1305
1770
  const aQ = algoQtyOf(r.symbol, curQ);
1306
1771
  const bQ = curQ - aQ;
1307
1772
  if (bQ > 0 && !closing.has(r.symbol)) return false;
1308
1773
  return !(aQ < 0 && !closing.has(r.symbol));
1309
1774
  });
1310
- const newBuys = filterBrokerDynamicTopK(rawNewBuys, minEntryEdgePct, Math.max(0, K - activeLongs));
1311
- const newShorts = filterBrokerDynamicTopK(rawNewShorts, minEntryEdgePct, Math.max(0, K - activeShorts));
1775
+ const newBuys = filterBrokerDynamicTopK(rawNewBuys, minBuyEdgePct, Math.max(0, K - activeLongs));
1776
+ const newShorts = filterBrokerDynamicTopK(rawNewShorts, minShortEdgePct, Math.max(0, K - activeShorts));
1312
1777
  const totalNew = newBuys.length + newShorts.length;
1313
1778
  const activeTotal = Math.max(2, activeLongs + activeShorts + totalNew);
1314
1779
  const dynSlotRub = (isOwnerPlan && autoAdapt)
@@ -1342,7 +1807,7 @@ export async function prepareBrokerOrders(id) {
1342
1807
  : smoothSellPool.filter((x) => (x.r.expectedPct || 0) <= -0.02).slice(0, maxSteps);
1343
1808
  for (const item of poolToUse) {
1344
1809
  if (shortfall <= 1000) break;
1345
- const limitPrice = roundToStep(item.pos.price * (1 - acc.limitOffsetPct / 100), item.sp.minStep, item.sp.decimals);
1810
+ const limitPrice = sniperLimitPrice(item.pos.price, -1, acc.limitOffsetPct, item.sp);
1346
1811
  const lotVal = limitPrice * item.sp.lotSize;
1347
1812
  const lotsToSell = Math.min(item.bLots, Math.max(1, Math.ceil(shortfall / lotVal)));
1348
1813
  const amountRub = round(lotsToSell * item.sp.lotSize * limitPrice, 2);
@@ -1364,26 +1829,63 @@ export async function prepareBrokerOrders(id) {
1364
1829
  ...newBuys.map((r) => ({ r, side: 'buy' })),
1365
1830
  ...newShorts.map((r) => ({ r, side: 'short' })),
1366
1831
  ];
1832
+ // Если включено «Свободные рубли в LQDT» (cashSweep) и для новых сигналов не хватает свободных рублей,
1833
+ // сначала автоматически продаём нужное число паёв фонда ликвидности LQDT, которые открыла стратегия (сверх неприкосновенного baselineQty)
1834
+ if (acc.cashSweep && acc.broker === 'tbank' && combinedNew.length > 0 && !closing.has('LQDT') && !activeOrderSymbols.has('LQDT')) {
1835
+ const lqdtPos = heldMap.get('LQDT');
1836
+ const lqdtAlgoQty = lqdtPos ? algoQtyOf('LQDT', lqdtPos.qty) : 0;
1837
+ if (lqdtAlgoQty > 0 && lqdtPos.price > 0) {
1838
+ const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.001, decimals: 3 };
1839
+ const lqdtLots = Math.floor(lqdtAlgoQty / lqdtSp.lotSize);
1840
+ const neededForNewRub = combinedNew.reduce((s, { r, side }) => {
1841
+ const isT1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
1842
+ const cap = budget * (side === 'short' ? (isT1 ? 0.35 : 0.18) : (isT1 ? 0.45 : 0.22));
1843
+ return s + Math.min(cap, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
1844
+ }, 0);
1845
+ const needFromLqdtRub = Math.min(Math.max(0, budget * 0.99 - usedRub), neededForNewRub) - availRub;
1846
+ if (lqdtLots >= 1 && needFromLqdtRub > 1500) {
1847
+ const limPx = sniperLimitPrice(lqdtPos.price, -1, 0.03, lqdtSp);
1848
+ const lotVal = limPx * lqdtSp.lotSize;
1849
+ const sellLots = Math.min(lqdtLots, Math.max(1, Math.ceil((needFromLqdtRub * 1.005) / lotVal)));
1850
+ const amountRub = round(sellLots * lqdtSp.lotSize * limPx, 2);
1851
+ orders.push({
1852
+ symbol: 'LQDT', action: 'sell', lotSize: lqdtSp.lotSize, lots: sellLots, shares: sellLots * lqdtSp.lotSize,
1853
+ refPrice: lqdtPos.price, limitPrice: limPx, amountRub,
1854
+ reason: `Высвобождение ${fmtRubSimple(amountRub)} ₽ из фонда денежного рынка LQDT под сигнал стратегии (${combinedNew.map((x) => x.r.symbol).join(', ')})`,
1855
+ });
1856
+ closing.add('LQDT');
1857
+ availRub += amountRub;
1858
+ }
1859
+ }
1860
+ }
1367
1861
  if (autoAdapt && combinedNew.length > 1) {
1368
1862
  combinedNew.sort((a, b) => Math.abs(b.r.expectedPct || 0) - Math.abs(a.r.expectedPct || 0));
1369
1863
  }
1370
1864
  let remWeightSum = combinedNew.reduce((s, x) => s + Math.max(0.01, Math.abs(x.r.expectedPct || 0)), 0);
1371
1865
 
1372
1866
  // Жёсткий предохранитель концентрации на одну бумагу в реальном стакане брокера:
1373
- // максимум до 45% счёта на один лонг и максимум до 35% счёта на один шорт (включая множитель силы сигнала convOf)
1374
- const maxLongPerStockRub = budget * 0.45;
1375
- const maxShortPerStockRub = budget * 0.35;
1867
+ // • для ликвидных бумаг 1-го эшелона: до 45% счёта на один лонг и до 35% счёта на один шорт;
1868
+ // • для бумаг 2–3 эшелона (MRKC, MRKZ, NKHP, AQUA, FESH, FEES и др.): до 22% счёта на один лонг и до 18% на один шорт,
1869
+ // чтобы крупная заявка не выкупала тонкий стакан против самой себя и всегда оставляла капитал на 3–4 бумаги!
1870
+ const maxLongTier1Rub = budget * 0.45;
1871
+ const maxShortTier1Rub = budget * 0.35;
1872
+ const maxLongTier2Rub = budget * 0.22;
1873
+ const maxShortTier2Rub = budget * 0.18;
1376
1874
 
1377
1875
  for (const { r, side } of combinedNew) {
1378
1876
  const wCur = Math.max(0.01, Math.abs(r.expectedPct || 0));
1379
1877
  const propShare = autoAdapt && remWeightSum > 0 ? availRub * (wCur / remWeightSum) : availRub / Math.max(1, remNew);
1380
1878
  remWeightSum = Math.max(0, remWeightSum - wCur);
1381
1879
  remNew--;
1382
- const sideCapRub = side === 'short' ? maxShortPerStockRub : maxLongPerStockRub;
1880
+ const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
1881
+ const sideCapRub = side === 'short'
1882
+ ? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
1883
+ : (isTier1 ? maxLongTier1Rub : maxLongTier2Rub);
1383
1884
  const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
1384
1885
  const sum = Math.min(rawSum, propShare, availRub);
1385
1886
  const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
1386
- const ord = calcLotOrder(r.symbol, sum, refPx, side, acc.limitOffsetPct, specs);
1887
+ const effOffsetPct = isTier1 ? Math.min(Number(acc.limitOffsetPct) || 0.05, 0.06) : Math.min(Number(acc.limitOffsetPct) || 0.04, 0.04);
1888
+ const ord = calcLotOrder(r.symbol, sum, refPx, side, effOffsetPct, specs);
1387
1889
  if (ord) {
1388
1890
  availRub = Math.max(0, availRub - ord.amountRub);
1389
1891
  orders.push({
@@ -1396,17 +1898,36 @@ export async function prepareBrokerOrders(id) {
1396
1898
  }
1397
1899
  }
1398
1900
 
1901
+ // При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых заявок,
1902
+ // а свободных рублей выше резерва — размещаем излишек в фонд ликвидности LQDT, чтобы кэш приносил ставку денежного рынка (~16–18% годовых)
1903
+ if (!onlyEntries && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length === 0 && !activePendingOrders.length && !edgeWarn) {
1904
+ const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.001, decimals: 3 };
1905
+ const lqdtPx = Number(heldMap.get('LQDT')?.price || lqdtSp.lastPrice || 0);
1906
+ const reserveRub = Math.max(5000, round(budget * 0.03, 0));
1907
+ const sweepRub = Math.min(availRub - reserveRub, (acc.lastSync?.cashRub || 0) - reserveRub);
1908
+ if (lqdtPx > 0 && sweepRub >= 20000) {
1909
+ const ord = calcLotOrder('LQDT', sweepRub, lqdtPx, 'buy', 0.02, { ...specs, LQDT: lqdtSp });
1910
+ if (ord) {
1911
+ availRub = Math.max(0, availRub - ord.amountRub);
1912
+ orders.push({
1913
+ ...ord,
1914
+ reason: `Фонд денежного рынка LQDT: размещение свободных ${fmtRubSimple(ord.amountRub)} ₽ под дневную ставку ликвидности до появления сигнала`,
1915
+ });
1916
+ }
1917
+ }
1918
+ }
1919
+
1399
1920
  let diagNote = '';
1400
1921
  let planThrottleNote = '';
1401
1922
  if (plan.id === 'investor') {
1402
1923
  const shInfo = rawShortCands.length ? ` (пропущены сигналы на шорт: ${rawShortCands.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
1403
1924
  const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
1404
- planThrottleNote = `Тариф «Инвестор» (целевой потолок до 60% годовых, 1 счёт со стартовым лимитом до 1 млн ₽${reinvNote}): активен режим «Только в рост (без шорта)»${shInfo}. Для включения заработка на падении акций (шорты, до 120% годовых и до 5 счетов) перейдите на тариф «Профи».`;
1925
+ planThrottleNote = `Тариф «Инвестор» (1 счёт со стартовым лимитом до 1 млн ₽${reinvNote}): активен режим «Только в рост (без шорта)»${shInfo}. Сделки на падении акций (шорты) и до 5 счетов — на тарифе «Профи».`;
1405
1926
  } else if (plan.id === 'pro') {
1406
1927
  const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
1407
- planThrottleNote = `Тариф «Профи» (целевой потолок до 120% годовых, до 5 счетов со стартовым лимитом до 1 млн ₽/счёт${reinvNote}): активны покупки и шорты. Для расширения потолка доходности до 240% годовых и индивидуальных лимитов подключите тариф «VIP».`;
1928
+ planThrottleNote = `Тариф «Профи» (до 5 счетов со стартовым лимитом до 1 млн ₽/счёт${reinvNote}): активны покупки и шорты. Индивидуальные лимиты — на тарифе «VIP».`;
1408
1929
  } else if (plan.id === 'vip') {
1409
- planThrottleNote = `Тариф «VIP» (целевой потолок до 240% годовых, лимиты по согласованию): активны покупки и шорты с повышенной концентрацией слота.`;
1930
+ planThrottleNote = 'Тариф «VIP» (лимиты по договору): активны покупки и шорты с повышенной концентрацией слота.';
1410
1931
  }
1411
1932
  if (!orders.length) {
1412
1933
  if ((newBuys.length || newShorts.length) && availRub < 1500) {
@@ -1415,16 +1936,23 @@ export async function prepareBrokerOrders(id) {
1415
1936
  diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
1416
1937
  } else if (!newBuys.length && !planAllowsShorts && rawShortCands.length > 0) {
1417
1938
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
1418
- diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 60% годовых). Чтобы зарабатывать и на падении акций (до 120% годовых), перейдите на тариф «Профи» или «VIP».`;
1939
+ diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта). Сделки на падении акций доступны на тарифах «Профи» и «VIP».`;
1419
1940
  } else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
1420
1941
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
1421
1942
  diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
1422
1943
  } else if (!newBuys.length && !newShorts.length) {
1423
1944
  const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
1424
- diagNote = `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
1945
+ const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует 60-минутная пауза после недавнего выхода из позиции, чтобы не платить повторную комиссию.` : '';
1946
+ diagNote = cdNote
1947
+ ? `На свече ${fc.bar} новых входов нет.${cdNote}${noShortNote}`
1948
+ : `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
1425
1949
  }
1426
1950
  }
1427
1951
 
1952
+ if (onlyEntries && !orders.length) {
1953
+ return brokersState();
1954
+ }
1955
+
1428
1956
  acc.pendingBatch = {
1429
1957
  id: `batch_${Date.now()}`,
1430
1958
  createdAt: Date.now(),
@@ -1445,12 +1973,15 @@ export async function prepareBrokerOrders(id) {
1445
1973
  saveRawAccounts(list);
1446
1974
  notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
1447
1975
  if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
1448
- return executeBrokerOrders(id);
1976
+ return await executeBrokerOrders(id, { _lockHeld: true });
1449
1977
  }
1450
1978
  const st = brokersState();
1451
1979
  hub.emit({ type: 'brokers:update', state: st });
1452
1980
  hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
1453
1981
  return st;
1982
+ } finally {
1983
+ accountBusyLocks.delete(id);
1984
+ }
1454
1985
  }
1455
1986
 
1456
1987
  function formatTbankOrderError(status, rawText = '', ord = {}) {
@@ -1484,14 +2015,47 @@ async function sendOrderToBroker(acc, ord) {
1484
2015
  const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
1485
2016
  const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
1486
2017
  const instrumentId = await resolveTbankInstrumentId(base, headers, ord.symbol);
1487
- const units = Math.trunc(ord.limitPrice);
1488
- const nano = Math.round((ord.limitPrice - units) * 1e9);
1489
2018
  const isBuy = ord.action === 'buy' || ord.action === 'cover';
2019
+ const isEntry = ord.action === 'buy' || ord.action === 'short';
2020
+ // Подтягиваем живой стакан 1×1 (GetOrderBook depth=1) в секунду отправки:
2021
+ // • для входа (buy/short) и для LQDT ставим лимитку по лучшей цене своей стороны или середине спреда (не переплачиваем за пересечение спреда!);
2022
+ // • если спред в стакане в момент входа разъехался шире половины ожидаемого хода сигнала (или >0,35%) — отклоняем вход в тонкий стакан!
2023
+ let finalLimitPx = Number(ord.limitPrice) || 0;
2024
+ const sp = kvGet('tbankShortableMap')?.specMap?.[ord.symbol] || { minStep: 0.01, decimals: 2 };
2025
+ const rBook = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetOrderBook`, {
2026
+ method: 'POST', headers, body: JSON.stringify({ instrumentId, depth: 1 }), timeoutMs: 3500,
2027
+ }).catch(() => null);
2028
+ if (rBook?.ok) {
2029
+ const jBook = await rBook.json().catch(() => null);
2030
+ const bestBid = qVal(jBook?.bids?.[0]?.price);
2031
+ const bestAsk = qVal(jBook?.asks?.[0]?.price);
2032
+ if (bestBid > 0 && bestAsk >= bestBid) {
2033
+ const midPx = (bestBid + bestAsk) / 2;
2034
+ const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
2035
+ const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
2036
+ if (isEntry && spreadPct > maxSpreadPct) {
2037
+ logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
2038
+ throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
2039
+ }
2040
+ if (isEntry && ord.symbol !== 'LQDT') {
2041
+ // Пассивный вход на середине спреда (или на 1 шаг внутри спреда от своей стороны), но не хуже расчётной limitPrice
2042
+ const passivePx = isBuy
2043
+ ? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + (sp.minStep || 0.01)), sp.minStep, sp.decimals))
2044
+ : Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - (sp.minStep || 0.01)), sp.minStep, sp.decimals));
2045
+ if (passivePx > 0) finalLimitPx = passivePx;
2046
+ } else if (ord.symbol === 'LQDT') {
2047
+ finalLimitPx = roundToStep(isBuy ? bestAsk : bestBid, sp.minStep || 0.001, sp.decimals ?? 3);
2048
+ }
2049
+ }
2050
+ }
2051
+ const units = Math.trunc(finalLimitPx);
2052
+ const nano = Math.round((finalLimitPx - units) * 1e9);
1490
2053
  let lotsToOrder = Number(ord.lots) || 0;
1491
2054
 
1492
2055
  // Перед выставлением новой лимитной заявки автоматически снимаем старые неисполненные заявки по этой же бумаге и направлению,
1493
2056
  // чтобы в стакане никогда не копились дублирующиеся заявки и не блокировали рублёвый лимит счёта!
1494
- const rActive = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
2057
+ // При повторе после таймаута не снимаем: среди «старых» может оказаться эта же заявка, дошедшая до брокера.
2058
+ const rActive = ord.uncertainSend ? null : await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
1495
2059
  method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId }), timeoutMs: 8000,
1496
2060
  }).catch(() => null);
1497
2061
  if (rActive?.ok) {
@@ -1524,9 +2088,9 @@ async function sendOrderToBroker(acc, ord) {
1524
2088
  if (Number.isFinite(maxLots)) {
1525
2089
  if (maxLots < 1) {
1526
2090
  if (ord.action === 'short') {
1527
- throw new Error(`Короткая продажа (шорт) по бумаге ${ord.symbol} недоступна у брокера (бумага не входит в список маржинальных для шорта или исчерпан лимит займа)`);
2091
+ throw Object.assign(new Error(`Короткая продажа (шорт) по бумаге ${ord.symbol} недоступна у брокера (бумага не входит в список маржинальных для шорта или исчерпан лимит займа)`), { definitive: true });
1528
2092
  }
1529
- throw new Error(`У брокера недостаточно доступного лимита для ${isBuy ? 'покупки' : 'продажи'} ${ord.symbol} (доступно 0 лотов)`);
2093
+ throw Object.assign(new Error(`У брокера недостаточно доступного лимита для ${isBuy ? 'покупки' : 'продажи'} ${ord.symbol} (доступно 0 лотов)`), { definitive: true });
1530
2094
  }
1531
2095
  if (maxLots < lotsToOrder) lotsToOrder = Math.floor(maxLots);
1532
2096
  }
@@ -1540,7 +2104,8 @@ async function sendOrderToBroker(acc, ord) {
1540
2104
  price: { units: String(units), nano },
1541
2105
  direction: isBuy ? 'ORDER_DIRECTION_BUY' : 'ORDER_DIRECTION_SELL',
1542
2106
  orderType: 'ORDER_TYPE_LIMIT',
1543
- orderId: crypto.randomUUID(),
2107
+ // ключ идемпотентности T-Invest: повтор с тем же orderId не создаёт вторую заявку
2108
+ orderId: ord.clientOrderId || crypto.randomUUID(),
1544
2109
  };
1545
2110
  const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/PostOrder`, {
1546
2111
  method: 'POST',
@@ -1550,10 +2115,12 @@ async function sendOrderToBroker(acc, ord) {
1550
2115
  });
1551
2116
  if (!r.ok) {
1552
2117
  const errText = await r.text().catch(() => '');
1553
- throw new Error(formatTbankOrderError(r.status, errText, ord));
2118
+ // 5xx — исход не известен (заявка могла быть принята), 4xx — брокер точно отклонил
2119
+ throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
1554
2120
  }
1555
2121
  const j = await r.json();
1556
- return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder };
2122
+ logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ (сигнал ${ord.refPrice || finalLimitPx} ₽) · ${body.orderId}`);
2123
+ return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder, limitPrice: finalLimitPx };
1557
2124
  }
1558
2125
  if (acc.broker === 'finam') {
1559
2126
  const body = {
@@ -1712,52 +2279,98 @@ async function sendOrderToBroker(acc, ord) {
1712
2279
  throw new Error(`Неизвестный брокер: ${acc.broker}`);
1713
2280
  }
1714
2281
 
1715
- export async function executeBrokerOrders(id) {
1716
- const list = loadRawAccounts();
1717
- const acc = list.find((x) => x.id === id);
1718
- if (!acc) throw new Error('Счёт не найден');
1719
- if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
1720
- if (acc.haltedReason) throw new Error(acc.haltedReason);
1721
- const batch = acc.pendingBatch;
1722
- if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
1723
- const results = [];
1724
- acc.entryTsBySymbol = acc.entryTsBySymbol || {};
1725
- acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
1726
- const execNowTs = Date.now();
1727
- const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
1728
- const entryOrders = batch.orders.filter((o) => o.action !== 'sell' && o.action !== 'cover');
1729
- const orderedBatch = [...exitOrders, ...entryOrders];
1730
- let hadExits = false;
1731
- for (const ord of orderedBatch) {
1732
- // Если перед открытием новых позиций мы отправили закрывающие ордера (sell/cover),
1733
- // даём брокеру 1.2 секунды на исполнение в стакане и пересчёт свободного маржинального/рублёвого лимита перед вызовом GetMaxLots
1734
- if (hadExits && (ord.action === 'buy' || ord.action === 'short')) {
1735
- hadExits = false;
1736
- await new Promise((r) => setTimeout(r, 1200));
2282
+ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2283
+ if (!_lockHeld) {
2284
+ // Счёт может быть занят пересчётом заявок (после перезапуска — обучением прогноза): ждём до 15 с, а не молча пропускаем
2285
+ const waitUntil = Date.now() + 15000;
2286
+ while (accountBusyLocks.has(id) && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 300));
2287
+ if (accountBusyLocks.has(id)) throw new Error('Счёт сейчас занят пересчётом заявок — повторите через минуту');
2288
+ accountBusyLocks.add(id);
2289
+ }
2290
+ try {
2291
+ const list = loadRawAccounts();
2292
+ const acc = list.find((x) => x.id === id);
2293
+ if (!acc) throw new Error('Счёт не найден');
2294
+ if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
2295
+ if (acc.haltedReason) throw new Error(acc.haltedReason);
2296
+ const gate = tradingGate();
2297
+ if (!gate.allowNew) throw new Error(gate.reason);
2298
+ const batch = acc.pendingBatch;
2299
+ if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
2300
+ if (batch.status === 'executed' || batch.status === 'executing') return brokersState();
2301
+ // Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
2302
+ // ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
2303
+ for (const ord of batch.orders) {
2304
+ if (!ord.clientOrderId) ord.clientOrderId = crypto.randomUUID();
1737
2305
  }
1738
- try {
1739
- const r = await sendOrderToBroker(acc, ord);
1740
- results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
1741
- if (ord.action === 'buy' || ord.action === 'short') {
1742
- acc.entryTsBySymbol[ord.symbol] = execNowTs;
1743
- delete acc.exitCooldownBySymbol[ord.symbol];
1744
- } else if (ord.action === 'sell' || ord.action === 'cover') {
1745
- hadExits = true;
1746
- delete acc.entryTsBySymbol[ord.symbol];
1747
- acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2306
+ batch.status = 'executing';
2307
+ saveRawAccounts(list);
2308
+ const prevResults = new Map((batch.results || []).filter((r) => r.ok && r.clientOrderId).map((r) => [r.clientOrderId, r]));
2309
+ const results = [];
2310
+ acc.entryTsBySymbol = acc.entryTsBySymbol || {};
2311
+ acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
2312
+ acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
2313
+ const execNowTs = Date.now();
2314
+ const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
2315
+ const entryOrders = batch.orders.filter((o) => o.action !== 'sell' && o.action !== 'cover');
2316
+ const orderedBatch = [...exitOrders, ...entryOrders];
2317
+ let hadExits = false;
2318
+ for (const ord of orderedBatch) {
2319
+ const already = prevResults.get(ord.clientOrderId);
2320
+ if (already) {
2321
+ results.push(already);
2322
+ continue;
1748
2323
  }
1749
- } catch (e) {
1750
- results.push({ symbol: ord.symbol, ok: false, error: e.message });
2324
+ // Если перед открытием новых позиций мы отправили закрывающие ордера (sell/cover),
2325
+ // даём брокеру 1.2 секунды на исполнение в стакане и пересчёт свободного маржинального/рублёвого лимита перед вызовом GetMaxLots
2326
+ if (hadExits && (ord.action === 'buy' || ord.action === 'short')) {
2327
+ hadExits = false;
2328
+ await new Promise((r) => setTimeout(r, 1200));
2329
+ }
2330
+ try {
2331
+ if ((ord.action === 'sell' || ord.action === 'cover') && acc.brokerStopBySymbol?.[ord.symbol]) {
2332
+ try {
2333
+ await closeBrokerStopBeforeExit(acc, ord.symbol);
2334
+ } catch (e) {
2335
+ throw new Error(`не удалось снять стоп-заявку у брокера перед закрытием (${e.message}) — закрывающая заявка не отправлена, чтобы не продать дважды`);
2336
+ }
2337
+ }
2338
+ const r = await sendOrderToBroker(acc, ord);
2339
+ results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
2340
+ if (ord.action === 'buy' || ord.action === 'short') {
2341
+ acc.entryTsBySymbol[ord.symbol] = execNowTs;
2342
+ acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
2343
+ delete acc.exitCooldownBySymbol[ord.symbol];
2344
+ } else if (ord.action === 'sell' || ord.action === 'cover') {
2345
+ hadExits = true;
2346
+ delete acc.entryTsBySymbol[ord.symbol];
2347
+ delete acc.peakPriceBySymbol[ord.symbol];
2348
+ acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2349
+ }
2350
+ } catch (e) {
2351
+ // Брокер явно отклонил заявку — при повторе нужен новый ID. Сетевая ошибка или таймаут — ID сохраняется:
2352
+ // заявка могла дойти до брокера, и повтор с тем же ID не создаст дубль.
2353
+ ord.sendAttempts = (ord.sendAttempts || 0) + 1;
2354
+ if (e.definitive) ord.clientOrderId = crypto.randomUUID();
2355
+ else ord.uncertainSend = true;
2356
+ results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
2357
+ logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
2358
+ }
2359
+ }
2360
+ batch.status = results.every((r) => r.ok) ? 'executed' : 'partial';
2361
+ batch.results = results;
2362
+ batch.executedAt = Date.now();
2363
+ saveRawAccounts(list);
2364
+ await syncBrokerAccount(id).catch(() => {});
2365
+ if (acc.broker === 'tbank' && acc.token) {
2366
+ await fetchBrokerHistory(id).catch(() => {});
1751
2367
  }
2368
+ const st = brokersState();
2369
+ hub.emit({ type: 'brokers:update', state: st });
2370
+ return st;
2371
+ } finally {
2372
+ if (!_lockHeld) accountBusyLocks.delete(id);
1752
2373
  }
1753
- batch.status = results.every((r) => r.ok) ? 'executed' : 'partial';
1754
- batch.results = results;
1755
- batch.executedAt = Date.now();
1756
- saveRawAccounts(list);
1757
- await syncBrokerAccount(id).catch(() => {});
1758
- const st = brokersState();
1759
- hub.emit({ type: 'brokers:update', state: st });
1760
- return st;
1761
2374
  }
1762
2375
 
1763
2376
  export async function cancelBrokerOrder(id, orderId = null) {
@@ -1873,6 +2486,19 @@ export async function fetchBrokerHistory(id, opts = {}) {
1873
2486
  const fetchedOps = rawOps.map((op) => {
1874
2487
  const m = metaMap[op.figi] || metaMap[op.instrumentUid] || {};
1875
2488
  const [kind, kindLabel] = OP_KIND(op.operationType);
2489
+ // В T-Invest API v2 поле op.quantity — это ЗАЯВЛЕННОЕ количество в лимитном ордере, а op.quantityRest — неисполненный остаток!
2490
+ // Реально исполненное количество акций всегда равно сумме сделок в op.trades, либо (quantity - quantityRest):
2491
+ const tradesQty = Array.isArray(op.trades) && op.trades.length > 0
2492
+ ? op.trades.reduce((s, t) => s + (Number(t.quantity) || 0), 0)
2493
+ : 0;
2494
+ const rawQty = Number(op.quantityDone ?? (Number(op.quantity || 0) - Number(op.quantityRest || 0)));
2495
+ const execQty = tradesQty > 0 ? tradesQty : Math.max(0, rawQty);
2496
+ const paymentRub = round(qVal(op.payment), 2);
2497
+ // Точная средневзвешенная цена исполнения из фактической суммы сделки (чтобы Кол-во × Цена = Сумма сходились до копейки):
2498
+ const rawPx = qVal(op.price);
2499
+ const effPx = (execQty > 0 && Math.abs(paymentRub) > 0 && (kind === 'buy' || kind === 'sell'))
2500
+ ? round(Math.abs(paymentRub) / execQty, 6)
2501
+ : round(rawPx, 6);
1876
2502
  return {
1877
2503
  id: op.id || '',
1878
2504
  date: op.date ? new Date(op.date).getTime() : 0,
@@ -1881,11 +2507,11 @@ export async function fetchBrokerHistory(id, opts = {}) {
1881
2507
  title: op.type || kindLabel,
1882
2508
  symbol: m.ticker || op.figi || '',
1883
2509
  name: m.name || '',
1884
- qty: Number(op.quantityDone || op.quantity || 0),
1885
- price: round(qVal(op.price), 4),
1886
- paymentRub: round(qVal(op.payment), 2),
2510
+ qty: execQty,
2511
+ price: effPx,
2512
+ paymentRub,
1887
2513
  };
1888
- });
2514
+ }).filter((op) => !((op.kind === 'buy' || op.kind === 'sell') && op.qty === 0 && op.paymentRub === 0));
1889
2515
 
1890
2516
  // Сохраняем операции других годов, которые уже были загружены ранее, и заменяем только targetYear
1891
2517
  const prevOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
@@ -1941,6 +2567,7 @@ let schedulerBusy = false;
1941
2567
 
1942
2568
  async function tickBrokerSchedule() {
1943
2569
  if (schedulerBusy) return;
2570
+ if (!tradingGate().allowNew) return;
1944
2571
  const nowMsk = new Date(Date.now() + MSK);
1945
2572
  const dow = nowMsk.getUTCDay();
1946
2573
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
@@ -1948,8 +2575,8 @@ async function tickBrokerSchedule() {
1948
2575
  schedulerBusy = true;
1949
2576
  try {
1950
2577
  const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
1951
- if (!latest || latest === lastSeenBar) return;
1952
- lastSeenBar = latest;
2578
+ const isNewBar = Boolean(latest && latest !== lastSeenBar);
2579
+ if (isNewBar) lastSeenBar = latest;
1953
2580
  const users = db.prepare('SELECT * FROM users').all();
1954
2581
  for (const u of users) {
1955
2582
  await runWithUser(u, async () => {
@@ -1965,11 +2592,28 @@ async function tickBrokerSchedule() {
1965
2592
  const H = barsFromHours(effHorizon);
1966
2593
  // Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
1967
2594
  const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
1968
- if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
1969
- acc.lastScheduledBarTs = latest;
1970
- saveRawAccounts(list);
1971
- if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
1972
- await prepareBrokerOrders(acc.id).catch(() => {});
2595
+ const minStepMs = stepBars * BAR_MS;
2596
+ const lastRunWallTs = acc.pendingBatch?.executedAt || acc.pendingBatch?.createdAt || 0;
2597
+ const dueFullStep = Boolean(
2598
+ latest &&
2599
+ (latest - (acc.lastScheduledBarTs || 0) >= minStepMs) &&
2600
+ !(lastRunWallTs > 0 && Date.now() - lastRunWallTs < minStepMs - 5 * 60000),
2601
+ );
2602
+ if (dueFullStep) {
2603
+ acc.lastScheduledBarTs = latest;
2604
+ saveRawAccounts(list);
2605
+ if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
2606
+ await prepareBrokerOrders(acc.id).catch(() => {});
2607
+ continue;
2608
+ }
2609
+ // Мгновенный перехват новых сигналов «купить» / «шорт» при наличии свободных рублей или резерва в LQDT (каждые 20 секунд, не дожидаясь конца 30-минутного шага!):
2610
+ const lqdtPos = acc.cashSweep ? (acc.lastSync?.positions || []).find((p) => p.symbol === 'LQDT' && p.qty > 0) : null;
2611
+ const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
2612
+ const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
2613
+ const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
2614
+ if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && Date.now() - lastRunWallTs >= 20000) {
2615
+ await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
2616
+ }
1973
2617
  }
1974
2618
  });
1975
2619
  }
@@ -1987,15 +2631,10 @@ async function resumeBrokerAccountsOnBoot() {
1987
2631
  const list = loadRawAccounts();
1988
2632
  for (const acc of list) {
1989
2633
  if (!acc.token) continue;
1990
- await syncBrokerAccount(acc.id).catch(() => {});
1991
2634
  if (!acc.lastHistory && acc.broker === 'tbank') {
1992
2635
  await fetchBrokerHistory(acc.id).catch(() => {});
1993
2636
  }
1994
- // Если в предыдущем пакете была ошибка 400 из-за шорта на безмаржинальном счёте — пересчитываем пакет под реальные настройки счёта
1995
- const fresh = loadRawAccounts().find((x) => x.id === acc.id);
1996
- if (fresh?.pendingBatch?.results?.some((r) => !r.ok && /HTTP 400|margin/i.test(String(r.error || '')))) {
1997
- await prepareBrokerOrders(acc.id).catch(() => {});
1998
- }
2637
+ await syncBrokerAccount(acc.id).catch(() => {});
1999
2638
  }
2000
2639
  });
2001
2640
  }
@@ -2003,7 +2642,284 @@ async function resumeBrokerAccountsOnBoot() {
2003
2642
  } catch { /* ignore startup sync errors */ }
2004
2643
  }
2005
2644
 
2006
- // Автоматическое периодическое фоновое обновление портфеля и истории текущего года (раз в 2 минуты)
2645
+ // ---------- защитные стоп-заявки на стороне Т-Банка (бета, включаются на счёте) ----------
2646
+ // Стоп-лосс висит у брокера и срабатывает, даже если машина клиента выключена или упала.
2647
+ // Правила безопасности (чтобы не получить непреднамеренный шорт на маржинальном счёте):
2648
+ // • стоп-заявка на каждую бумагу с позицией стратегии, ровно на её число лотов (сверка раз в 25 с);
2649
+ // • перед любой закрывающей заявкой по бумаге её стоп-заявка снимается (closeBrokerStopBeforeExit);
2650
+ // • внутренний защитный стоп по бумаге со стоп-заявкой у брокера не выставляется;
2651
+ // • трогаются только стоп-заявки, выставленные самой программой (их ID хранятся в brokerStopBySymbol).
2652
+ const BROKER_STOP_LONG_PCT = 2.6;
2653
+ const BROKER_STOP_SHORT_PCT = 2.2;
2654
+ const tbankBase = (acc) => (acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest');
2655
+ const tbankHeaders = (acc) => ({ Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' });
2656
+ const qPrice = (v) => { const units = Math.trunc(v); return { units: String(units), nano: Math.round((v - units) * 1e9) }; };
2657
+
2658
+ function algoQtyFor(acc, sym, totalQty) {
2659
+ const legacyMode = acc.legacyMode || 'isolate';
2660
+ if (legacyMode === 'takeover') return totalQty;
2661
+ const baseQ = Number((acc.baselineQty || {})[sym] || 0);
2662
+ if (totalQty > 0 && baseQ > 0) return Math.max(0, totalQty - baseQ);
2663
+ if (totalQty < 0 && baseQ < 0) return Math.min(0, totalQty - baseQ);
2664
+ return totalQty;
2665
+ }
2666
+
2667
+ async function tbankActiveStopIds(acc) {
2668
+ const r = await brokerFetch(`${tbankBase(acc)}/tinkoff.public.invest.api.contract.v1.StopOrdersService/GetStopOrders`, {
2669
+ method: 'POST', headers: tbankHeaders(acc), body: JSON.stringify({ accountId: acc.accountId, status: 'STOP_ORDER_STATUS_ACTIVE' }), timeoutMs: 10000,
2670
+ });
2671
+ if (!r.ok) throw new Error(`GetStopOrders HTTP ${r.status}`);
2672
+ const j = await r.json();
2673
+ return new Set((j.stopOrders || []).map((s) => s.stopOrderId).filter(Boolean));
2674
+ }
2675
+
2676
+ async function tbankCancelStop(acc, stopOrderId) {
2677
+ const r = await brokerFetch(`${tbankBase(acc)}/tinkoff.public.invest.api.contract.v1.StopOrdersService/CancelStopOrder`, {
2678
+ method: 'POST', headers: tbankHeaders(acc), body: JSON.stringify({ accountId: acc.accountId, stopOrderId }), timeoutMs: 10000,
2679
+ });
2680
+ // 4xx «не найдена» — стоп уже сработал или снят: считаем снятым
2681
+ if (!r.ok && r.status >= 500) throw new Error(`CancelStopOrder HTTP ${r.status}`);
2682
+ }
2683
+
2684
+ // Снять стоп-заявку у брокера перед закрытием позиции стратегией (иначе после закрытия она продаст ещё раз)
2685
+ async function closeBrokerStopBeforeExit(acc, sym) {
2686
+ const t = acc.brokerStopBySymbol?.[sym];
2687
+ if (!t?.stopOrderId || acc.broker !== 'tbank' || !acc.token) return;
2688
+ await tbankCancelStop(acc, t.stopOrderId);
2689
+ delete acc.brokerStopBySymbol[sym];
2690
+ logBrokerEvent(acc, 'broker_stop_cancel', `${sym}: снята перед закрытием позиции`);
2691
+ }
2692
+
2693
+ async function cancelAllBrokerStops(acc, why) {
2694
+ for (const [sym, t] of Object.entries(acc.brokerStopBySymbol || {})) {
2695
+ try {
2696
+ if (t?.stopOrderId) await tbankCancelStop(acc, t.stopOrderId);
2697
+ delete acc.brokerStopBySymbol[sym];
2698
+ logBrokerEvent(acc, 'broker_stop_cancel', `${sym}: ${why}`);
2699
+ } catch (e) {
2700
+ logBrokerEvent(acc, 'broker_stop_error', `${sym}: не удалось снять (${e.message})`);
2701
+ }
2702
+ }
2703
+ }
2704
+
2705
+ async function reconcileBrokerStops(accId) {
2706
+ const list = loadRawAccounts();
2707
+ const acc = list.find((x) => x.id === accId);
2708
+ if (!acc || acc.broker !== 'tbank' || !acc.token || !acc.accountId) return;
2709
+ acc.brokerStopBySymbol = acc.brokerStopBySymbol || {};
2710
+ // выключено на счёте, «Только чтение» или аварийная остановка из центра — свои стоп-заявки снимаем
2711
+ const gate = tradingGate();
2712
+ if (!acc.brokerStops || acc.mode === 'readonly' || !gate.allowProtective) {
2713
+ if (Object.keys(acc.brokerStopBySymbol).length) {
2714
+ await cancelAllBrokerStops(acc, !acc.brokerStops ? 'стоп-заявки у брокера выключены на счёте' : 'счёт в режиме «Только чтение» или торговля остановлена');
2715
+ saveRawAccounts(list);
2716
+ }
2717
+ return;
2718
+ }
2719
+ if (!acc.lastSync?.ok) return;
2720
+ if (accountBusyLocks.has(accId)) return; // идёт исполнение пакета — сверимся в следующий раз
2721
+ accountBusyLocks.add(accId);
2722
+ try {
2723
+ await reconcileBrokerStopsLocked(list, acc);
2724
+ } finally {
2725
+ accountBusyLocks.delete(accId);
2726
+ }
2727
+ }
2728
+
2729
+ async function reconcileBrokerStopsLocked(list, acc) {
2730
+ acc.brokerStopCooldown = acc.brokerStopCooldown || {};
2731
+ const active = await tbankActiveStopIds(acc);
2732
+ const rawSpecs = await ensureBoardSpecs().catch(() => ({}));
2733
+ const specs = { ...rawSpecs, ...(kvGet('tbankShortableMap')?.specMap || {}) };
2734
+ const base = tbankBase(acc);
2735
+ const headers = tbankHeaders(acc);
2736
+ const want = new Map();
2737
+ for (const pos of acc.lastSync.positions || []) {
2738
+ const sym = pos.symbol;
2739
+ const sp = specs[sym];
2740
+ if (!sym || !sp) continue;
2741
+ const aQty = algoQtyFor(acc, sym, Number(pos.qty) || 0);
2742
+ const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
2743
+ const avgPx = Number(pos.avgPrice) || 0;
2744
+ if (lots < 1 || avgPx <= 0) continue;
2745
+ const side = aQty > 0 ? 'long' : 'short';
2746
+ const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
2747
+ want.set(sym, { lots, side, stopPx, sp });
2748
+ }
2749
+ let changed = false;
2750
+ // 1) снять свои стоп-заявки без позиции, сработавшие (их нет среди активных) или с другим объёмом / ценой / стороной
2751
+ for (const [sym, t] of Object.entries(acc.brokerStopBySymbol)) {
2752
+ const w = want.get(sym);
2753
+ const alive = t?.stopOrderId && active.has(t.stopOrderId);
2754
+ const same = alive && w && w.lots === t.lots && w.side === t.side && Math.abs(w.stopPx - t.stopPx) < (w.sp.minStep || 0.01) / 2;
2755
+ if (same) continue;
2756
+ if (alive) {
2757
+ try { await tbankCancelStop(acc, t.stopOrderId); } catch (e) { logBrokerEvent(acc, 'broker_stop_error', `${sym}: ${e.message}`); continue; }
2758
+ }
2759
+ logBrokerEvent(acc, alive ? 'broker_stop_cancel' : 'broker_stop_gone', `${sym}: ${!w ? 'позиции стратегии нет' : !alive ? 'стоп-заявка сработала или снята у брокера' : 'объём или цена позиции изменились'}`);
2760
+ // стоп сработал, а позиция в данных ещё видна — не выставляем новый, пока портфель не обновится (иначе возможна повторная продажа)
2761
+ if (!alive) acc.brokerStopCooldown[sym] = Date.now();
2762
+ delete acc.brokerStopBySymbol[sym];
2763
+ changed = true;
2764
+ }
2765
+ // 2) выставить недостающие
2766
+ for (const [sym, w] of want) {
2767
+ if (acc.brokerStopBySymbol[sym]) continue;
2768
+ const cd = acc.brokerStopCooldown[sym];
2769
+ if (cd && (Date.now() - cd < 120000 || Number(acc.lastSync?.at || 0) < cd + 30000)) continue;
2770
+ if (cd) { delete acc.brokerStopCooldown[sym]; changed = true; }
2771
+ try {
2772
+ const instrumentId = await resolveTbankInstrumentId(base, headers, sym);
2773
+ const body = {
2774
+ accountId: acc.accountId,
2775
+ instrumentId,
2776
+ quantity: String(w.lots),
2777
+ stopPrice: qPrice(w.stopPx),
2778
+ direction: w.side === 'long' ? 'STOP_ORDER_DIRECTION_SELL' : 'STOP_ORDER_DIRECTION_BUY',
2779
+ expirationType: 'STOP_ORDER_EXPIRATION_TYPE_GOOD_TILL_CANCEL',
2780
+ stopOrderType: 'STOP_ORDER_TYPE_STOP_LOSS',
2781
+ exchangeOrderType: 'EXCHANGE_ORDER_TYPE_MARKET',
2782
+ orderId: crypto.randomUUID(),
2783
+ };
2784
+ const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.StopOrdersService/PostStopOrder`, { method: 'POST', headers, body: JSON.stringify(body), timeoutMs: 15000 });
2785
+ if (!r.ok) throw new Error(`PostStopOrder HTTP ${r.status} ${(await r.text().catch(() => '')).slice(0, 160)}`);
2786
+ const j = await r.json();
2787
+ if (!j.stopOrderId) throw new Error('брокер не вернул stopOrderId');
2788
+ acc.brokerStopBySymbol[sym] = { stopOrderId: j.stopOrderId, lots: w.lots, side: w.side, stopPx: w.stopPx, at: Date.now() };
2789
+ logBrokerEvent(acc, 'broker_stop_set', `${sym}: ${w.side === 'long' ? 'продажа' : 'покупка'} ${w.lots} л. при цене ${w.stopPx} ₽ (стоп ${w.side === 'long' ? '−' + BROKER_STOP_LONG_PCT : '+' + BROKER_STOP_SHORT_PCT}% от средней)`);
2790
+ changed = true;
2791
+ } catch (e) {
2792
+ logBrokerEvent(acc, 'broker_stop_error', `${sym}: ${e.message}`);
2793
+ }
2794
+ }
2795
+ if (changed) {
2796
+ saveRawAccounts(list);
2797
+ hub.emit({ type: 'brokers:update', state: brokersState() });
2798
+ }
2799
+ }
2800
+
2801
+ // Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые 2 минуты между шагами расписания
2802
+ async function checkIntradayProfitLock(accId) {
2803
+ const list = loadRawAccounts();
2804
+ const acc = list.find((x) => x.id === accId);
2805
+ if (!acc || acc.mode !== 'auto' || !acc.token || acc.haltedReason) return;
2806
+ if (!tradingGate().allowProtective) return;
2807
+ const nowMsk = new Date(Date.now() + MSK);
2808
+ const dow = nowMsk.getUTCDay();
2809
+ const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
2810
+ if (dow === 0 || dow === 6 || mins < 9 * 60 + 55 || mins > 23 * 60 + 48) return;
2811
+ if (acc.skipEdgeMinutes && sessionEdgeWarning()) return;
2812
+
2813
+ const positions = acc.lastSync?.positions || [];
2814
+ if (!positions.length) return;
2815
+ const legacyMode = acc.legacyMode || 'isolate';
2816
+ const baseline = acc.baselineQty || {};
2817
+ const algoQtyOf = (sym, totalQty) => {
2818
+ if (legacyMode === 'takeover') return totalQty;
2819
+ const baseQ = Number(baseline[sym] || 0);
2820
+ if (totalQty > 0 && baseQ > 0) return Math.max(0, totalQty - baseQ);
2821
+ if (totalQty < 0 && baseQ < 0) return Math.min(0, totalQty - baseQ);
2822
+ return totalQty;
2823
+ };
2824
+
2825
+ const rawSpecs = await ensureBoardSpecs().catch(() => ({}));
2826
+ const tbSpecs = acc.broker === 'tbank' ? (kvGet('tbankShortableMap')?.specMap || {}) : {};
2827
+ const specs = { ...rawSpecs, ...tbSpecs };
2828
+ const params = normalizeParams(getSettings());
2829
+ const roundTripFeePct = 2 * ((Number(params.commissionPct) || 0.04) + (Number(params.slippagePct) || 0.01));
2830
+ const peakMap = acc.peakPriceBySymbol || {};
2831
+ const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
2832
+
2833
+ const lockOrders = [];
2834
+ for (const pos of positions) {
2835
+ const sym = pos.symbol;
2836
+ if (!sym) continue;
2837
+ const aQty = algoQtyOf(sym, pos.qty);
2838
+ if (!aQty) continue;
2839
+ // позицию уже защищает стоп-заявка у брокера — внутренний стоп не дублируем
2840
+ if (acc.brokerStops && acc.brokerStopBySymbol?.[sym]) continue;
2841
+ // Если по этой бумаге в стакане УЖЕ висит закрывающая заявка (sell для лонга или buy для шорта) — не дублируем
2842
+ const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
2843
+ if ((acc.lastSync?.activeOrders || []).some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
2844
+ const sp = specs[sym];
2845
+ if (!sp) continue; // не трогаем облигации, фонды и бумаги вне словаря
2846
+ const curPx = Number(pos.price) || 0;
2847
+ if (curPx <= 0) continue;
2848
+ const wantOpenKind = aQty > 0 ? 'buy' : 'sell';
2849
+ const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenKind && op.date > 0);
2850
+ const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
2851
+ if (avgPx <= 0) continue;
2852
+
2853
+ if (aQty > 0) {
2854
+ const lots = Math.floor(aQty / sp.lotSize);
2855
+ if (lots < 1) continue;
2856
+ const curGainPct = ((curPx - avgPx) / avgPx) * 100;
2857
+ const hitHardStopLoss = curGainPct <= -2.6;
2858
+ if (hitHardStopLoss) {
2859
+ const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
2860
+ lockOrders.push({
2861
+ symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
2862
+ refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
2863
+ reason: `Авто-стоп позиции (${round(curGainPct, 2)}%)`,
2864
+ });
2865
+ }
2866
+ } else if (aQty < 0) {
2867
+ const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
2868
+ if (lots < 1) continue;
2869
+ const curGainPct = ((avgPx - curPx) / avgPx) * 100;
2870
+ const hitHardStopLoss = curGainPct <= -2.2;
2871
+ if (hitHardStopLoss) {
2872
+ const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
2873
+ lockOrders.push({
2874
+ symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
2875
+ refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
2876
+ reason: `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)`,
2877
+ });
2878
+ }
2879
+ }
2880
+ }
2881
+
2882
+ if (!lockOrders.length) return;
2883
+ const results = [];
2884
+ const execNowTs = Date.now();
2885
+ acc.entryTsBySymbol = acc.entryTsBySymbol || {};
2886
+ acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
2887
+ acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
2888
+ const lockSymbols = new Set(lockOrders.map((o) => o.symbol));
2889
+ const conflictingOrders = (acc.lastSync?.activeOrders || []).filter((o) => lockSymbols.has(o.symbol) && o.orderId);
2890
+ for (const co of conflictingOrders) {
2891
+ await cancelBrokerOrder(accId, co.orderId).catch(() => {});
2892
+ }
2893
+ for (const ord of lockOrders) {
2894
+ logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
2895
+ try {
2896
+ const r = await sendOrderToBroker(acc, ord);
2897
+ results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
2898
+ delete acc.entryTsBySymbol[ord.symbol];
2899
+ delete acc.peakPriceBySymbol[ord.symbol];
2900
+ acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2901
+ } catch (e) {
2902
+ results.push({ symbol: ord.symbol, ok: false, error: e.message });
2903
+ logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
2904
+ }
2905
+ }
2906
+ acc.pendingBatch = {
2907
+ ...(acc.pendingBatch || {}),
2908
+ id: `batch_tp_${execNowTs}`,
2909
+ createdAt: execNowTs,
2910
+ executedAt: execNowTs,
2911
+ bar: `${fmtBarMsk(execNowTs)} (Take-Profit)`,
2912
+ orders: lockOrders,
2913
+ status: results.every((r) => r.ok) ? 'executed' : 'partial',
2914
+ results,
2915
+ };
2916
+ saveRawAccounts(list);
2917
+ await syncBrokerAccount(accId).catch(() => {});
2918
+ const st = brokersState();
2919
+ hub.emit({ type: 'brokers:update', state: st });
2920
+ }
2921
+
2922
+ // Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
2007
2923
  let autoRefreshBusy = false;
2008
2924
  async function tickBrokerAutoRefresh() {
2009
2925
  if (autoRefreshBusy || schedulerBusy) return;
@@ -2017,7 +2933,11 @@ async function tickBrokerAutoRefresh() {
2017
2933
  for (const acc of list) {
2018
2934
  if (!acc.token) continue;
2019
2935
  await syncBrokerAccount(acc.id).catch(() => {});
2020
- if (acc.broker === 'tbank') {
2936
+ await checkIntradayProfitLock(acc.id).catch(() => {});
2937
+ if (acc.broker === 'tbank' && (acc.brokerStops || Object.keys(acc.brokerStopBySymbol || {}).length)) {
2938
+ await reconcileBrokerStops(acc.id).catch((e) => logBrokerEvent(acc, 'broker_stop_error', `сверка: ${e.message}`));
2939
+ }
2940
+ if (acc.broker === 'tbank' && (!acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 120000)) {
2021
2941
  await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
2022
2942
  }
2023
2943
  }
@@ -2028,6 +2948,13 @@ async function tickBrokerAutoRefresh() {
2028
2948
  }
2029
2949
  }
2030
2950
 
2951
+ export function triggerBrokerFastEntryCheck() {
2952
+ if (schedulerBusy || autoRefreshBusy) return;
2953
+ setImmediate(() => {
2954
+ tickBrokerSchedule().catch(() => {});
2955
+ });
2956
+ }
2957
+
2031
2958
  export function startBrokerScheduler() {
2032
2959
  ensureBoardSpecs().catch(() => {});
2033
2960
  // В фоне после старта сервера сразу прогреваем прогноз, чтобы при открытии вкладки «Прогноз сейчас» он уже был в памяти
@@ -2035,8 +2962,8 @@ export function startBrokerScheduler() {
2035
2962
  forecastNow(getSettings()).catch(() => {});
2036
2963
  }, 800);
2037
2964
  setTimeout(resumeBrokerAccountsOnBoot, 2500);
2038
- const timer = setInterval(tickBrokerSchedule, 60000);
2039
- const syncTimer = setInterval(tickBrokerAutoRefresh, 120000);
2965
+ const timer = setInterval(tickBrokerSchedule, 20000);
2966
+ const syncTimer = setInterval(tickBrokerAutoRefresh, 25000);
2040
2967
  timer.unref?.();
2041
2968
  syncTimer.unref?.();
2042
2969
  }