neuroalpha 0.3.17 → 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/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 +1 -4
- package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +2 -2
- 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 +573 -66
- package/public/index.html +48 -21
- 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 +1014 -168
- 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/role.js +44 -0
- package/server/security.js +40 -0
- package/server/strategist/index.js +86 -47
- package/server/strategist/tools.js +231 -42
- 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) {
|
|
@@ -422,7 +667,7 @@ async function ensureTbankShortableMap(acc) {
|
|
|
422
667
|
}
|
|
423
668
|
}
|
|
424
669
|
}
|
|
425
|
-
kvSet('tbankShortableMap', { v:
|
|
670
|
+
kvSet('tbankShortableMap', { v: 5, at: Date.now(), map, uidMap, specMap });
|
|
426
671
|
return map;
|
|
427
672
|
} catch {
|
|
428
673
|
return cached?.map || null;
|
|
@@ -462,7 +707,7 @@ async function resolveTbankInstrumentId(base, headers, symbol) {
|
|
|
462
707
|
});
|
|
463
708
|
if (r.ok) {
|
|
464
709
|
const j = await r.json();
|
|
465
|
-
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'));
|
|
466
711
|
const uid = hit?.uid || hit?.figi;
|
|
467
712
|
if (uid) { tbankUidCache.set(symbol, uid); return uid; }
|
|
468
713
|
}
|
|
@@ -1004,10 +1249,11 @@ export async function syncBrokerAccount(id) {
|
|
|
1004
1249
|
const ddPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
|
|
1005
1250
|
if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
|
|
1006
1251
|
acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
|
|
1007
|
-
} else if (ddPct >= acc.maxDailyDrawdownPct) {
|
|
1252
|
+
} else if (ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
|
|
1008
1253
|
// Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
|
|
1009
1254
|
acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
|
|
1010
1255
|
if (acc.ddBreachCount >= 2) {
|
|
1256
|
+
if (!acc.haltedReason) logBrokerEvent(acc, 'daily_stop', `просадка −${ddPct}% при пороге ${acc.maxDailyDrawdownPct}%`);
|
|
1011
1257
|
acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
|
|
1012
1258
|
}
|
|
1013
1259
|
} else {
|
|
@@ -1022,16 +1268,46 @@ export async function syncBrokerAccount(id) {
|
|
|
1022
1268
|
}
|
|
1023
1269
|
acc.equityHistory = hist;
|
|
1024
1270
|
const entryMap = { ...(acc.entryTsBySymbol || {}) };
|
|
1025
|
-
const
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
+
}
|
|
1030
1297
|
}
|
|
1031
1298
|
for (const s of Object.keys(entryMap)) {
|
|
1032
|
-
|
|
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];
|
|
1033
1308
|
}
|
|
1034
1309
|
acc.entryTsBySymbol = entryMap;
|
|
1310
|
+
acc.peakPriceBySymbol = peakMap;
|
|
1035
1311
|
acc.lastSync = { at: nowTs, ok: true, error: null, day: today, dayStartRub, dayDrawdownPct: ddPct, ...data };
|
|
1036
1312
|
} catch (e) {
|
|
1037
1313
|
acc.lastSync = { ...(acc.lastSync || {}), at: Date.now(), ok: false, error: e.message };
|
|
@@ -1080,7 +1356,12 @@ function sessionEdgeWarning(now = Date.now()) {
|
|
|
1080
1356
|
}
|
|
1081
1357
|
|
|
1082
1358
|
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
1083
|
-
|
|
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 {
|
|
1084
1365
|
let list = loadRawAccounts();
|
|
1085
1366
|
let acc = list.find((x) => x.id === id);
|
|
1086
1367
|
if (!acc) throw new Error('Счёт не найден');
|
|
@@ -1092,8 +1373,8 @@ export async function prepareBrokerOrders(id) {
|
|
|
1092
1373
|
throw new Error('Для работы с брокерскими счетами оформите подписку «Инвестор» (3 900 ₽/мес), «Профи» (9 900 ₽/мес) или «VIP» в разделе «Подписка и уведомления».');
|
|
1093
1374
|
}
|
|
1094
1375
|
hub.emit({ type: 'trading:pulse', kind: 'broker_calc', ts: Date.now(), accountId: acc.id, name: acc.name, text: `Пересчёт заявок по счёту «${acc.name}»…` });
|
|
1095
|
-
//
|
|
1096
|
-
if (acc.token && acc.mode !== 'readonly' && acc.broker === 'tbank' && acc.lastSync?.activeOrders?.length > 0) {
|
|
1376
|
+
// При плановом шаге снимаем старые неисполненные заявки со стакана, а при быстром доборе (onlyEntries) не трогаем уже стоящие свежие заявки!
|
|
1377
|
+
if (!onlyEntries && acc.token && acc.mode !== 'readonly' && acc.broker === 'tbank' && acc.lastSync?.activeOrders?.length > 0) {
|
|
1097
1378
|
await cancelBrokerOrder(id, 'all').catch(() => {});
|
|
1098
1379
|
list = loadRawAccounts();
|
|
1099
1380
|
acc = list.find((x) => x.id === id) || acc;
|
|
@@ -1149,24 +1430,59 @@ export async function prepareBrokerOrders(id) {
|
|
|
1149
1430
|
const vipBoost = Boolean(plan.allowVipBoost);
|
|
1150
1431
|
const rawDir = params.modelDirection || 'long';
|
|
1151
1432
|
const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
|
|
1152
|
-
|
|
1433
|
+
// В реальном стакане брокера (где каждый вход-выход стоит 0.08% комиссии + спред) используем гистерезис удержания 0.58
|
|
1434
|
+
// (около +0.145%), чтобы не закрывать открытую позицию преждевременно при положительном прогнозе +0.16%…+0.23%!
|
|
1435
|
+
const exitRatio = autoAdapt ? 0.58 : Number(params.modelExitRatio ?? 0.5);
|
|
1153
1436
|
const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
|
|
1154
|
-
const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.
|
|
1437
|
+
const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.25, 3);
|
|
1155
1438
|
const nowTs = Date.now();
|
|
1156
1439
|
const entryTsMap = acc.entryTsBySymbol || {};
|
|
1157
1440
|
const exitCooldownMap = acc.exitCooldownBySymbol || {};
|
|
1158
1441
|
const mskDate = new Date(nowTs + MSK);
|
|
1159
1442
|
const isEveningSession = (mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes()) >= 19 * 60;
|
|
1160
1443
|
const eveningBoost = (autoAdapt && isEveningSession) ? 1.25 : 1.0;
|
|
1161
|
-
|
|
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);
|
|
1162
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
|
+
}
|
|
1163
1479
|
const slots = dir === 'long' ? K : 2 * K;
|
|
1164
1480
|
// Фильтр мощности слота по тарифу:
|
|
1165
|
-
// •
|
|
1166
|
-
// •
|
|
1167
|
-
// • VIP
|
|
1168
|
-
// •
|
|
1169
|
-
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');
|
|
1170
1486
|
let slotRub;
|
|
1171
1487
|
if (isOwnerPlan && autoAdapt) {
|
|
1172
1488
|
const maxSlotWeight = Math.min(0.45, Math.max(1 / K, 1.8 / K));
|
|
@@ -1179,19 +1495,8 @@ export async function prepareBrokerOrders(id) {
|
|
|
1179
1495
|
} else {
|
|
1180
1496
|
slotRub = budget * Math.min(0.24, 1.05 / Math.max(3, K + 1));
|
|
1181
1497
|
}
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1184
|
-
const firstPt = acc.equityHistory[0];
|
|
1185
|
-
const lastPt = acc.equityHistory[acc.equityHistory.length - 1];
|
|
1186
|
-
const elapsedDays = Math.max(7, (lastPt.ts - firstPt.ts) / DAY);
|
|
1187
|
-
if (firstPt.totalRub > 0 && lastPt.totalRub > firstPt.totalRub) {
|
|
1188
|
-
const curGainPct = ((lastPt.totalRub - firstPt.totalRub) / firstPt.totalRub) * 100;
|
|
1189
|
-
const targetGainPct = plan.maxAnnualReturnPct * (elapsedDays / 365);
|
|
1190
|
-
if (curGainPct > targetGainPct) {
|
|
1191
|
-
slotRub *= Math.max(0.25, targetGainPct / curGainPct);
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1498
|
+
// (Потолки доходности по тарифам и «дроссель» размера позиций удалены: тарифы различаются возможностями,
|
|
1499
|
+
// а не обещанной доходностью — реалистичная доходность определяется стресс-тестом, а не тарифом.)
|
|
1195
1500
|
const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
|
|
1196
1501
|
const convOf = (r) => {
|
|
1197
1502
|
if (!autoAdapt || !r.expectedPct) return 1;
|
|
@@ -1201,11 +1506,11 @@ export async function prepareBrokerOrders(id) {
|
|
|
1201
1506
|
};
|
|
1202
1507
|
const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
|
|
1203
1508
|
const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
|
|
1204
|
-
const quarantined = new Set();
|
|
1205
1509
|
const nb = params.newsFilter ? blockingNews(6) : null;
|
|
1206
1510
|
|
|
1207
|
-
const
|
|
1208
|
-
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) : [];
|
|
1209
1514
|
const nonShortableSkipped = [];
|
|
1210
1515
|
const rawShortCands = [];
|
|
1211
1516
|
for (const r of allShortSignals) {
|
|
@@ -1232,7 +1537,11 @@ export async function prepareBrokerOrders(id) {
|
|
|
1232
1537
|
const orders = [];
|
|
1233
1538
|
const closing = new Set();
|
|
1234
1539
|
const smoothSellPool = [];
|
|
1235
|
-
|
|
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.
|
|
1236
1545
|
// Облигации, фонды (LQDT/TMOS) и бумаги вне словаря MOEXBMI (!rowMap.has(sym)) не затрагиваются никогда
|
|
1237
1546
|
for (const [sym, pos] of heldMap) {
|
|
1238
1547
|
if (!pos.qty) continue;
|
|
@@ -1241,31 +1550,50 @@ export async function prepareBrokerOrders(id) {
|
|
|
1241
1550
|
const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
|
|
1242
1551
|
const aQty = algoQtyOf(sym, pos.qty);
|
|
1243
1552
|
const baseQty = pos.qty - aQty;
|
|
1244
|
-
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;
|
|
1245
1559
|
// Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
|
|
1246
1560
|
// чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95)
|
|
1247
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);
|
|
1248
1564
|
if (aQty > 0) {
|
|
1249
1565
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1250
|
-
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;
|
|
1251
1570
|
if (lots >= 1 && !keep) {
|
|
1252
|
-
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}%`;
|
|
1253
1575
|
orders.push({
|
|
1254
1576
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1255
|
-
refPrice:
|
|
1256
|
-
reason:
|
|
1577
|
+
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
1578
|
+
reason: exitReason,
|
|
1257
1579
|
});
|
|
1258
1580
|
closing.add(sym);
|
|
1259
1581
|
}
|
|
1260
1582
|
} else if (aQty < 0) {
|
|
1261
1583
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1262
|
-
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;
|
|
1263
1588
|
if (lots >= 1 && !keep) {
|
|
1264
|
-
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}%`;
|
|
1265
1593
|
orders.push({
|
|
1266
1594
|
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1267
|
-
refPrice:
|
|
1268
|
-
reason:
|
|
1595
|
+
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
1596
|
+
reason: exitReason,
|
|
1269
1597
|
});
|
|
1270
1598
|
closing.add(sym);
|
|
1271
1599
|
}
|
|
@@ -1277,7 +1605,7 @@ export async function prepareBrokerOrders(id) {
|
|
|
1277
1605
|
const bLots = Math.floor(baseQty / sp.lotSize);
|
|
1278
1606
|
if (bLots >= 1 && !(r.liquid && r.expectedPct > exitThresholdPct)) {
|
|
1279
1607
|
if (r.expectedPct <= -needPct) {
|
|
1280
|
-
const limitPrice =
|
|
1608
|
+
const limitPrice = sniperLimitPrice(pos.price, -1, acc.limitOffsetPct, sp);
|
|
1281
1609
|
orders.push({
|
|
1282
1610
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots: bLots, shares: bLots * sp.lotSize,
|
|
1283
1611
|
refPrice: pos.price, limitPrice, amountRub: round(bLots * sp.lotSize * limitPrice, 2),
|
|
@@ -1290,25 +1618,82 @@ export async function prepareBrokerOrders(id) {
|
|
|
1290
1618
|
}
|
|
1291
1619
|
}
|
|
1292
1620
|
}
|
|
1293
|
-
const
|
|
1294
|
-
const
|
|
1295
|
-
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
|
+
}
|
|
1296
1680
|
const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
|
|
1297
1681
|
const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
|
|
1298
1682
|
let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
|
|
1299
1683
|
|
|
1300
1684
|
// 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
|
|
1301
|
-
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)));
|
|
1302
1686
|
// Не открываем шорт по тикеру, в котором ещё лежит непроданный остаток старого портфеля (baseQty > 0), чтобы брокер не превратил шорт в продажу старой позиции
|
|
1303
1687
|
const rawNewShorts = shortCands.filter((r) => {
|
|
1688
|
+
if (onlyEntries && activeOrderSymbols.has(r.symbol)) return false;
|
|
1304
1689
|
const curQ = heldMap.get(r.symbol)?.qty || 0;
|
|
1305
1690
|
const aQ = algoQtyOf(r.symbol, curQ);
|
|
1306
1691
|
const bQ = curQ - aQ;
|
|
1307
1692
|
if (bQ > 0 && !closing.has(r.symbol)) return false;
|
|
1308
1693
|
return !(aQ < 0 && !closing.has(r.symbol));
|
|
1309
1694
|
});
|
|
1310
|
-
const newBuys = filterBrokerDynamicTopK(rawNewBuys,
|
|
1311
|
-
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));
|
|
1312
1697
|
const totalNew = newBuys.length + newShorts.length;
|
|
1313
1698
|
const activeTotal = Math.max(2, activeLongs + activeShorts + totalNew);
|
|
1314
1699
|
const dynSlotRub = (isOwnerPlan && autoAdapt)
|
|
@@ -1342,7 +1727,7 @@ export async function prepareBrokerOrders(id) {
|
|
|
1342
1727
|
: smoothSellPool.filter((x) => (x.r.expectedPct || 0) <= -0.02).slice(0, maxSteps);
|
|
1343
1728
|
for (const item of poolToUse) {
|
|
1344
1729
|
if (shortfall <= 1000) break;
|
|
1345
|
-
const limitPrice =
|
|
1730
|
+
const limitPrice = sniperLimitPrice(item.pos.price, -1, acc.limitOffsetPct, item.sp);
|
|
1346
1731
|
const lotVal = limitPrice * item.sp.lotSize;
|
|
1347
1732
|
const lotsToSell = Math.min(item.bLots, Math.max(1, Math.ceil(shortfall / lotVal)));
|
|
1348
1733
|
const amountRub = round(lotsToSell * item.sp.lotSize * limitPrice, 2);
|
|
@@ -1364,26 +1749,63 @@ export async function prepareBrokerOrders(id) {
|
|
|
1364
1749
|
...newBuys.map((r) => ({ r, side: 'buy' })),
|
|
1365
1750
|
...newShorts.map((r) => ({ r, side: 'short' })),
|
|
1366
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
|
+
}
|
|
1367
1781
|
if (autoAdapt && combinedNew.length > 1) {
|
|
1368
1782
|
combinedNew.sort((a, b) => Math.abs(b.r.expectedPct || 0) - Math.abs(a.r.expectedPct || 0));
|
|
1369
1783
|
}
|
|
1370
1784
|
let remWeightSum = combinedNew.reduce((s, x) => s + Math.max(0.01, Math.abs(x.r.expectedPct || 0)), 0);
|
|
1371
1785
|
|
|
1372
1786
|
// Жёсткий предохранитель концентрации на одну бумагу в реальном стакане брокера:
|
|
1373
|
-
//
|
|
1374
|
-
|
|
1375
|
-
|
|
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;
|
|
1376
1794
|
|
|
1377
1795
|
for (const { r, side } of combinedNew) {
|
|
1378
1796
|
const wCur = Math.max(0.01, Math.abs(r.expectedPct || 0));
|
|
1379
1797
|
const propShare = autoAdapt && remWeightSum > 0 ? availRub * (wCur / remWeightSum) : availRub / Math.max(1, remNew);
|
|
1380
1798
|
remWeightSum = Math.max(0, remWeightSum - wCur);
|
|
1381
1799
|
remNew--;
|
|
1382
|
-
const
|
|
1800
|
+
const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
|
|
1801
|
+
const sideCapRub = side === 'short'
|
|
1802
|
+
? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
|
|
1803
|
+
: (isTier1 ? maxLongTier1Rub : maxLongTier2Rub);
|
|
1383
1804
|
const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
|
|
1384
1805
|
const sum = Math.min(rawSum, propShare, availRub);
|
|
1385
1806
|
const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
|
|
1386
|
-
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);
|
|
1387
1809
|
if (ord) {
|
|
1388
1810
|
availRub = Math.max(0, availRub - ord.amountRub);
|
|
1389
1811
|
orders.push({
|
|
@@ -1396,17 +1818,36 @@ export async function prepareBrokerOrders(id) {
|
|
|
1396
1818
|
}
|
|
1397
1819
|
}
|
|
1398
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
|
+
|
|
1399
1840
|
let diagNote = '';
|
|
1400
1841
|
let planThrottleNote = '';
|
|
1401
1842
|
if (plan.id === 'investor') {
|
|
1402
1843
|
const shInfo = rawShortCands.length ? ` (пропущены сигналы на шорт: ${rawShortCands.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
1403
1844
|
const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
|
|
1404
|
-
planThrottleNote = `Тариф «Инвестор» (
|
|
1845
|
+
planThrottleNote = `Тариф «Инвестор» (1 счёт со стартовым лимитом до 1 млн ₽${reinvNote}): активен режим «Только в рост (без шорта)»${shInfo}. Сделки на падении акций (шорты) и до 5 счетов — на тарифе «Профи».`;
|
|
1405
1846
|
} else if (plan.id === 'pro') {
|
|
1406
1847
|
const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
|
|
1407
|
-
planThrottleNote = `Тариф «Профи» (
|
|
1848
|
+
planThrottleNote = `Тариф «Профи» (до 5 счетов со стартовым лимитом до 1 млн ₽/счёт${reinvNote}): активны покупки и шорты. Индивидуальные лимиты — на тарифе «VIP».`;
|
|
1408
1849
|
} else if (plan.id === 'vip') {
|
|
1409
|
-
planThrottleNote =
|
|
1850
|
+
planThrottleNote = 'Тариф «VIP» (лимиты по договору): активны покупки и шорты с повышенной концентрацией слота.';
|
|
1410
1851
|
}
|
|
1411
1852
|
if (!orders.length) {
|
|
1412
1853
|
if ((newBuys.length || newShorts.length) && availRub < 1500) {
|
|
@@ -1415,16 +1856,23 @@ export async function prepareBrokerOrders(id) {
|
|
|
1415
1856
|
diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
|
|
1416
1857
|
} else if (!newBuys.length && !planAllowsShorts && rawShortCands.length > 0) {
|
|
1417
1858
|
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
1418
|
-
diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без
|
|
1859
|
+
diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта). Сделки на падении акций доступны на тарифах «Профи» и «VIP».`;
|
|
1419
1860
|
} else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
|
|
1420
1861
|
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
1421
1862
|
diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
|
|
1422
1863
|
} else if (!newBuys.length && !newShorts.length) {
|
|
1423
1864
|
const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
1424
|
-
|
|
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}.`;
|
|
1425
1869
|
}
|
|
1426
1870
|
}
|
|
1427
1871
|
|
|
1872
|
+
if (onlyEntries && !orders.length) {
|
|
1873
|
+
return brokersState();
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1428
1876
|
acc.pendingBatch = {
|
|
1429
1877
|
id: `batch_${Date.now()}`,
|
|
1430
1878
|
createdAt: Date.now(),
|
|
@@ -1445,12 +1893,15 @@ export async function prepareBrokerOrders(id) {
|
|
|
1445
1893
|
saveRawAccounts(list);
|
|
1446
1894
|
notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
|
|
1447
1895
|
if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
|
|
1448
|
-
return executeBrokerOrders(id);
|
|
1896
|
+
return await executeBrokerOrders(id, { _lockHeld: true });
|
|
1449
1897
|
}
|
|
1450
1898
|
const st = brokersState();
|
|
1451
1899
|
hub.emit({ type: 'brokers:update', state: st });
|
|
1452
1900
|
hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
|
|
1453
1901
|
return st;
|
|
1902
|
+
} finally {
|
|
1903
|
+
accountBusyLocks.delete(id);
|
|
1904
|
+
}
|
|
1454
1905
|
}
|
|
1455
1906
|
|
|
1456
1907
|
function formatTbankOrderError(status, rawText = '', ord = {}) {
|
|
@@ -1484,14 +1935,46 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1484
1935
|
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
1485
1936
|
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
1486
1937
|
const instrumentId = await resolveTbankInstrumentId(base, headers, ord.symbol);
|
|
1487
|
-
const units = Math.trunc(ord.limitPrice);
|
|
1488
|
-
const nano = Math.round((ord.limitPrice - units) * 1e9);
|
|
1489
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);
|
|
1490
1972
|
let lotsToOrder = Number(ord.lots) || 0;
|
|
1491
1973
|
|
|
1492
1974
|
// Перед выставлением новой лимитной заявки автоматически снимаем старые неисполненные заявки по этой же бумаге и направлению,
|
|
1493
1975
|
// чтобы в стакане никогда не копились дублирующиеся заявки и не блокировали рублёвый лимит счёта!
|
|
1494
|
-
|
|
1976
|
+
// При повторе после таймаута не снимаем: среди «старых» может оказаться эта же заявка, дошедшая до брокера.
|
|
1977
|
+
const rActive = ord.uncertainSend ? null : await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
|
|
1495
1978
|
method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId }), timeoutMs: 8000,
|
|
1496
1979
|
}).catch(() => null);
|
|
1497
1980
|
if (rActive?.ok) {
|
|
@@ -1524,9 +2007,9 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1524
2007
|
if (Number.isFinite(maxLots)) {
|
|
1525
2008
|
if (maxLots < 1) {
|
|
1526
2009
|
if (ord.action === 'short') {
|
|
1527
|
-
throw new Error(`Короткая продажа (шорт) по бумаге ${ord.symbol} недоступна у брокера (бумага не входит в список маржинальных для шорта или исчерпан лимит займа)`);
|
|
2010
|
+
throw Object.assign(new Error(`Короткая продажа (шорт) по бумаге ${ord.symbol} недоступна у брокера (бумага не входит в список маржинальных для шорта или исчерпан лимит займа)`), { definitive: true });
|
|
1528
2011
|
}
|
|
1529
|
-
throw new Error(`У брокера недостаточно доступного лимита для ${isBuy ? 'покупки' : 'продажи'} ${ord.symbol} (доступно 0 лотов)`);
|
|
2012
|
+
throw Object.assign(new Error(`У брокера недостаточно доступного лимита для ${isBuy ? 'покупки' : 'продажи'} ${ord.symbol} (доступно 0 лотов)`), { definitive: true });
|
|
1530
2013
|
}
|
|
1531
2014
|
if (maxLots < lotsToOrder) lotsToOrder = Math.floor(maxLots);
|
|
1532
2015
|
}
|
|
@@ -1540,7 +2023,8 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1540
2023
|
price: { units: String(units), nano },
|
|
1541
2024
|
direction: isBuy ? 'ORDER_DIRECTION_BUY' : 'ORDER_DIRECTION_SELL',
|
|
1542
2025
|
orderType: 'ORDER_TYPE_LIMIT',
|
|
1543
|
-
|
|
2026
|
+
// ключ идемпотентности T-Invest: повтор с тем же orderId не создаёт вторую заявку
|
|
2027
|
+
orderId: ord.clientOrderId || crypto.randomUUID(),
|
|
1544
2028
|
};
|
|
1545
2029
|
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/PostOrder`, {
|
|
1546
2030
|
method: 'POST',
|
|
@@ -1550,10 +2034,12 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1550
2034
|
});
|
|
1551
2035
|
if (!r.ok) {
|
|
1552
2036
|
const errText = await r.text().catch(() => '');
|
|
1553
|
-
|
|
2037
|
+
// 5xx — исход не известен (заявка могла быть принята), 4xx — брокер точно отклонил
|
|
2038
|
+
throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
|
|
1554
2039
|
}
|
|
1555
2040
|
const j = await r.json();
|
|
1556
|
-
|
|
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 };
|
|
1557
2043
|
}
|
|
1558
2044
|
if (acc.broker === 'finam') {
|
|
1559
2045
|
const body = {
|
|
@@ -1712,52 +2198,98 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1712
2198
|
throw new Error(`Неизвестный брокер: ${acc.broker}`);
|
|
1713
2199
|
}
|
|
1714
2200
|
|
|
1715
|
-
export async function executeBrokerOrders(id) {
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
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();
|
|
1737
2224
|
}
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
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}`);
|
|
1748
2277
|
}
|
|
1749
|
-
} catch (e) {
|
|
1750
|
-
results.push({ symbol: ord.symbol, ok: false, error: e.message });
|
|
1751
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);
|
|
1752
2292
|
}
|
|
1753
|
-
batch.status = results.every((r) => r.ok) ? 'executed' : 'partial';
|
|
1754
|
-
batch.results = results;
|
|
1755
|
-
batch.executedAt = Date.now();
|
|
1756
|
-
saveRawAccounts(list);
|
|
1757
|
-
await syncBrokerAccount(id).catch(() => {});
|
|
1758
|
-
const st = brokersState();
|
|
1759
|
-
hub.emit({ type: 'brokers:update', state: st });
|
|
1760
|
-
return st;
|
|
1761
2293
|
}
|
|
1762
2294
|
|
|
1763
2295
|
export async function cancelBrokerOrder(id, orderId = null) {
|
|
@@ -1873,6 +2405,19 @@ export async function fetchBrokerHistory(id, opts = {}) {
|
|
|
1873
2405
|
const fetchedOps = rawOps.map((op) => {
|
|
1874
2406
|
const m = metaMap[op.figi] || metaMap[op.instrumentUid] || {};
|
|
1875
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);
|
|
1876
2421
|
return {
|
|
1877
2422
|
id: op.id || '',
|
|
1878
2423
|
date: op.date ? new Date(op.date).getTime() : 0,
|
|
@@ -1881,11 +2426,11 @@ export async function fetchBrokerHistory(id, opts = {}) {
|
|
|
1881
2426
|
title: op.type || kindLabel,
|
|
1882
2427
|
symbol: m.ticker || op.figi || '',
|
|
1883
2428
|
name: m.name || '',
|
|
1884
|
-
qty:
|
|
1885
|
-
price:
|
|
1886
|
-
paymentRub
|
|
2429
|
+
qty: execQty,
|
|
2430
|
+
price: effPx,
|
|
2431
|
+
paymentRub,
|
|
1887
2432
|
};
|
|
1888
|
-
});
|
|
2433
|
+
}).filter((op) => !((op.kind === 'buy' || op.kind === 'sell') && op.qty === 0 && op.paymentRub === 0));
|
|
1889
2434
|
|
|
1890
2435
|
// Сохраняем операции других годов, которые уже были загружены ранее, и заменяем только targetYear
|
|
1891
2436
|
const prevOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
|
|
@@ -1941,6 +2486,7 @@ let schedulerBusy = false;
|
|
|
1941
2486
|
|
|
1942
2487
|
async function tickBrokerSchedule() {
|
|
1943
2488
|
if (schedulerBusy) return;
|
|
2489
|
+
if (!tradingGate().allowNew) return;
|
|
1944
2490
|
const nowMsk = new Date(Date.now() + MSK);
|
|
1945
2491
|
const dow = nowMsk.getUTCDay();
|
|
1946
2492
|
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
@@ -1948,8 +2494,8 @@ async function tickBrokerSchedule() {
|
|
|
1948
2494
|
schedulerBusy = true;
|
|
1949
2495
|
try {
|
|
1950
2496
|
const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
|
|
1951
|
-
|
|
1952
|
-
lastSeenBar = latest;
|
|
2497
|
+
const isNewBar = Boolean(latest && latest !== lastSeenBar);
|
|
2498
|
+
if (isNewBar) lastSeenBar = latest;
|
|
1953
2499
|
const users = db.prepare('SELECT * FROM users').all();
|
|
1954
2500
|
for (const u of users) {
|
|
1955
2501
|
await runWithUser(u, async () => {
|
|
@@ -1965,11 +2511,28 @@ async function tickBrokerSchedule() {
|
|
|
1965
2511
|
const H = barsFromHours(effHorizon);
|
|
1966
2512
|
// Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
|
|
1967
2513
|
const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
1968
|
-
|
|
1969
|
-
acc.
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
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
|
+
}
|
|
1973
2536
|
}
|
|
1974
2537
|
});
|
|
1975
2538
|
}
|
|
@@ -1987,15 +2550,10 @@ async function resumeBrokerAccountsOnBoot() {
|
|
|
1987
2550
|
const list = loadRawAccounts();
|
|
1988
2551
|
for (const acc of list) {
|
|
1989
2552
|
if (!acc.token) continue;
|
|
1990
|
-
await syncBrokerAccount(acc.id).catch(() => {});
|
|
1991
2553
|
if (!acc.lastHistory && acc.broker === 'tbank') {
|
|
1992
2554
|
await fetchBrokerHistory(acc.id).catch(() => {});
|
|
1993
2555
|
}
|
|
1994
|
-
|
|
1995
|
-
const fresh = loadRawAccounts().find((x) => x.id === acc.id);
|
|
1996
|
-
if (fresh?.pendingBatch?.results?.some((r) => !r.ok && /HTTP 400|margin/i.test(String(r.error || '')))) {
|
|
1997
|
-
await prepareBrokerOrders(acc.id).catch(() => {});
|
|
1998
|
-
}
|
|
2556
|
+
await syncBrokerAccount(acc.id).catch(() => {});
|
|
1999
2557
|
}
|
|
2000
2558
|
});
|
|
2001
2559
|
}
|
|
@@ -2003,7 +2561,284 @@ async function resumeBrokerAccountsOnBoot() {
|
|
|
2003
2561
|
} catch { /* ignore startup sync errors */ }
|
|
2004
2562
|
}
|
|
2005
2563
|
|
|
2006
|
-
//
|
|
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 минуты)
|
|
2007
2842
|
let autoRefreshBusy = false;
|
|
2008
2843
|
async function tickBrokerAutoRefresh() {
|
|
2009
2844
|
if (autoRefreshBusy || schedulerBusy) return;
|
|
@@ -2017,7 +2852,11 @@ async function tickBrokerAutoRefresh() {
|
|
|
2017
2852
|
for (const acc of list) {
|
|
2018
2853
|
if (!acc.token) continue;
|
|
2019
2854
|
await syncBrokerAccount(acc.id).catch(() => {});
|
|
2020
|
-
|
|
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)) {
|
|
2021
2860
|
await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
|
|
2022
2861
|
}
|
|
2023
2862
|
}
|
|
@@ -2028,6 +2867,13 @@ async function tickBrokerAutoRefresh() {
|
|
|
2028
2867
|
}
|
|
2029
2868
|
}
|
|
2030
2869
|
|
|
2870
|
+
export function triggerBrokerFastEntryCheck() {
|
|
2871
|
+
if (schedulerBusy || autoRefreshBusy) return;
|
|
2872
|
+
setImmediate(() => {
|
|
2873
|
+
tickBrokerSchedule().catch(() => {});
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2031
2877
|
export function startBrokerScheduler() {
|
|
2032
2878
|
ensureBoardSpecs().catch(() => {});
|
|
2033
2879
|
// В фоне после старта сервера сразу прогреваем прогноз, чтобы при открытии вкладки «Прогноз сейчас» он уже был в памяти
|
|
@@ -2035,8 +2881,8 @@ export function startBrokerScheduler() {
|
|
|
2035
2881
|
forecastNow(getSettings()).catch(() => {});
|
|
2036
2882
|
}, 800);
|
|
2037
2883
|
setTimeout(resumeBrokerAccountsOnBoot, 2500);
|
|
2038
|
-
const timer = setInterval(tickBrokerSchedule,
|
|
2039
|
-
const syncTimer = setInterval(tickBrokerAutoRefresh,
|
|
2884
|
+
const timer = setInterval(tickBrokerSchedule, 20000);
|
|
2885
|
+
const syncTimer = setInterval(tickBrokerAutoRefresh, 25000);
|
|
2040
2886
|
timer.unref?.();
|
|
2041
2887
|
syncTimer.unref?.();
|
|
2042
2888
|
}
|