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