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