neuroalpha 0.3.14 → 0.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -170
- package/config/strategy.json +2 -2
- package/docs//320/221/320/240/320/236/320/232/320/225/320/240/320/253_API.md +119 -0
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +10 -17
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +97 -156
- 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 +29 -45
- package/package.json +1 -1
- package/public/admin.html +9 -8
- package/public/app.js +591 -832
- package/public/index.html +142 -186
- package/public/landing.html +19 -33
- package/public/legal.html +3 -4
- package/public/styles.css +10 -4
- package/server/api/routes.js +10 -3
- package/server/auth/index.js +53 -34
- package/server/brokers/index.js +718 -107
- package/server/data/moex.js +74 -23
- package/server/db.js +3 -2
- package/server/model/dataset.js +10 -2
- package/server/model/forecast.js +56 -19
- package/server/model/live.js +30 -6
- package/server/model/models.js +113 -31
- package/server/model/research.js +100 -26
- package/server/params-meta.js +16 -16
- package/server/strategist/index.js +4 -0
- package/server/strategist/tools.js +229 -0
package/server/brokers/index.js
CHANGED
|
@@ -14,17 +14,67 @@ import { round, MSK, DAY } from '../util.js';
|
|
|
14
14
|
import { hub } from '../hub.js';
|
|
15
15
|
|
|
16
16
|
export const BROKER_META = [
|
|
17
|
-
{
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
{
|
|
18
|
+
id: 'tbank',
|
|
19
|
+
name: 'Т-Банк (T-Invest API)',
|
|
20
|
+
accountHint: 'ID счёта (или нажмите «Найти мои счета по токену»)',
|
|
21
|
+
tokenHint: 'Токен T-Invest API (t.…)',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'finam',
|
|
25
|
+
name: 'Финам (Finam Trade API)',
|
|
26
|
+
accountHint: 'Код клиента (ClientId, например 123456R)',
|
|
27
|
+
tokenHint: 'Токен Finam Trade API (CA…)',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'bcs',
|
|
31
|
+
name: 'БКС Мир инвестиций (Trade API)',
|
|
32
|
+
accountHint: 'Номер субсчёта / соглашения (необязательно)',
|
|
33
|
+
tokenHint: 'Refresh-токен БКС Trade API',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'alor',
|
|
37
|
+
name: 'Алор Брокер (Alor OpenAPI v2)',
|
|
38
|
+
accountHint: 'Фондовый портфель Мосбиржи (например D12345)',
|
|
39
|
+
tokenHint: 'Refresh-токен Alor OpenAPI',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'cifra',
|
|
43
|
+
name: 'Цифра брокер (Tradernet API)',
|
|
44
|
+
accountHint: 'Номер счёта Tradernet (необязательно)',
|
|
45
|
+
tokenHint: 'Ключи Tradernet в формате PublicKey:SecretKey',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'iticapital',
|
|
49
|
+
name: 'ITI Capital (OpenAPI)',
|
|
50
|
+
accountHint: 'Код портфеля Мосбиржи (например BP12345)',
|
|
51
|
+
tokenHint: 'Refresh-токен ITI Capital OpenAPI',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'sber',
|
|
55
|
+
name: 'СберИнвестиции (Сбербанк)',
|
|
56
|
+
accountHint: 'Номер брокерского счёта (например 400ABCD)',
|
|
57
|
+
tokenHint: 'URL моста QUIK (http://…) или оставьте пустым для режима подтверждения заявок',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'vtb',
|
|
61
|
+
name: 'ВТБ Мои Инвестиции',
|
|
62
|
+
accountHint: 'Номер брокерского счёта / субсчёта ВТБ',
|
|
63
|
+
tokenHint: 'URL моста QUIK (http://…) или оставьте пустым для режима подтверждения заявок',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'alfa',
|
|
67
|
+
name: 'Альфа-Инвестиции',
|
|
68
|
+
accountHint: 'Номер брокерского счёта Альфа-Инвестиций',
|
|
69
|
+
tokenHint: 'URL моста QUIK (http://…) или оставьте пустым для режима подтверждения заявок',
|
|
70
|
+
},
|
|
21
71
|
];
|
|
22
72
|
|
|
23
73
|
// ---------- справочник лотов и шага цены Мосбиржи (TQBR) ----------
|
|
24
74
|
const SPECS_URL = 'https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=securities,marketdata';
|
|
25
75
|
export async function ensureBoardSpecs() {
|
|
26
76
|
const cached = kvGet('moexSpecs');
|
|
27
|
-
if (cached?.specs && Date.now() - cached.at < DAY) return cached.specs;
|
|
77
|
+
if (cached?.specs && cached?.v === 2 && Date.now() - cached.at < DAY) return cached.specs;
|
|
28
78
|
try {
|
|
29
79
|
const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
|
|
30
80
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
@@ -45,9 +95,10 @@ export async function ensureBoardSpecs() {
|
|
|
45
95
|
const minStep = Number(row[is('MINSTEP')]) > 0 ? Number(row[is('MINSTEP')]) : 0.01;
|
|
46
96
|
const decimals = Math.max(0, Number(row[is('DECIMALS')]) || 0);
|
|
47
97
|
const prev = Number(row[is('PREVPRICE')] || row[is('PREVADMITTEDQUOTE')] || 0);
|
|
48
|
-
|
|
98
|
+
const issueSize = Number(row[is('ISSUESIZE')] || 0);
|
|
99
|
+
specs[sym] = { lotSize, minStep, decimals, issueSize, lastPrice: prices.get(sym) || prev || 0 };
|
|
49
100
|
}
|
|
50
|
-
kvSet('moexSpecs', { at: Date.now(), specs });
|
|
101
|
+
kvSet('moexSpecs', { v: 2, at: Date.now(), specs });
|
|
51
102
|
return specs;
|
|
52
103
|
} catch {
|
|
53
104
|
return cached?.specs || {};
|
|
@@ -94,13 +145,24 @@ function saveRawAccounts(list) {
|
|
|
94
145
|
}
|
|
95
146
|
|
|
96
147
|
function publicAccount(a) {
|
|
97
|
-
const { token, ...rest } = a;
|
|
98
148
|
let baseRunLabel = null;
|
|
99
149
|
if (a.baseRunId) {
|
|
100
150
|
const r = getRun(Number(a.baseRunId));
|
|
101
151
|
if (r) baseRunLabel = `№${r.id}${r.params?._title ? ` (${r.params._title})` : ''}`;
|
|
102
152
|
}
|
|
103
|
-
|
|
153
|
+
const specs = kvGet('moexSpecs')?.specs || {};
|
|
154
|
+
let lastSync = a.lastSync || null;
|
|
155
|
+
if (lastSync && Array.isArray(lastSync.positions)) {
|
|
156
|
+
lastSync = {
|
|
157
|
+
...lastSync,
|
|
158
|
+
positions: lastSync.positions.map((p) => {
|
|
159
|
+
const issueSize = p.issueSize || specs[p.symbol]?.issueSize || 0;
|
|
160
|
+
const ownershipPct = issueSize > 0 && p.qty > 0 ? (p.qty / issueSize) * 100 : (p.ownershipPct || null);
|
|
161
|
+
return { ...p, issueSize, ownershipPct };
|
|
162
|
+
}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return { ...a, lastSync, token: a.token || '', hasToken: Boolean(a.token && String(a.token).trim()), baseRunLabel };
|
|
104
166
|
}
|
|
105
167
|
|
|
106
168
|
export function brokersState() {
|
|
@@ -154,9 +216,12 @@ export function saveBrokerAccount(input = {}) {
|
|
|
154
216
|
mode,
|
|
155
217
|
legacyMode,
|
|
156
218
|
baselineQty: acctChanged ? null : (prev.baselineQty || null),
|
|
219
|
+
initialTotalRub: acctChanged ? 0 : (prev.initialTotalRub || 0),
|
|
157
220
|
tbankAccountName: acctChanged ? '' : (prev.tbankAccountName || ''),
|
|
158
221
|
equityHistory: acctChanged ? [] : (prev.equityHistory || []),
|
|
159
222
|
lastHistory: acctChanged ? null : (prev.lastHistory || null),
|
|
223
|
+
entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
|
|
224
|
+
exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
|
|
160
225
|
baseRunId: nextBaseRunId,
|
|
161
226
|
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
162
227
|
maxCapitalRub,
|
|
@@ -176,7 +241,8 @@ export function saveBrokerAccount(input = {}) {
|
|
|
176
241
|
const st = brokersState();
|
|
177
242
|
hub.emit({ type: 'brokers:update', state: st });
|
|
178
243
|
// При изменении стратегии, лимита капитала или режима счёта сразу пересинхронизируем счёт и пересчитываем заявки под новые настройки
|
|
179
|
-
|
|
244
|
+
const canAutoInit = Boolean(token) || ['sber', 'vtb', 'alfa'].includes(broker);
|
|
245
|
+
if (strategyOrCapChanged && canAutoInit) {
|
|
180
246
|
const activeUser = currentUser();
|
|
181
247
|
setImmediate(() => {
|
|
182
248
|
runWithUser(activeUser, async () => {
|
|
@@ -312,18 +378,94 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
|
|
|
312
378
|
return cache;
|
|
313
379
|
}
|
|
314
380
|
|
|
381
|
+
// Загружает и кэширует на 12 часов справочник доступности шортов, UID и точных параметров лота/шага цены по тикерам в Т-Банке (TQBR + SPBRU для SPBE)
|
|
382
|
+
async function ensureTbankShortableMap(acc) {
|
|
383
|
+
if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
|
|
384
|
+
const cached = kvGet('tbankShortableMap');
|
|
385
|
+
if (cached?.v === 3 && cached?.map && Date.now() - cached.at < 12 * 3600000) {
|
|
386
|
+
if (cached.uidMap) {
|
|
387
|
+
for (const [sym, uid] of Object.entries(cached.uidMap)) tbankUidCache.set(sym, uid);
|
|
388
|
+
}
|
|
389
|
+
return cached.map;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
393
|
+
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
394
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/Shares`, {
|
|
395
|
+
method: 'POST',
|
|
396
|
+
headers,
|
|
397
|
+
body: JSON.stringify({ instrumentStatus: 'INSTRUMENT_STATUS_BASE' }),
|
|
398
|
+
timeoutMs: 15000,
|
|
399
|
+
});
|
|
400
|
+
if (!r.ok) return cached?.map || null;
|
|
401
|
+
const j = await r.json();
|
|
402
|
+
const map = {};
|
|
403
|
+
const uidMap = { ...(cached?.uidMap || {}) };
|
|
404
|
+
const specMap = { ...(cached?.specMap || {}) };
|
|
405
|
+
for (const inst of j.instruments || []) {
|
|
406
|
+
if (!inst.ticker || inst.apiTradeAvailableFlag === false) continue;
|
|
407
|
+
if (inst.classCode === 'TQBR' || (inst.classCode === 'SPBRU' && !(inst.ticker in map))) {
|
|
408
|
+
map[inst.ticker] = Boolean(inst.shortEnabledFlag);
|
|
409
|
+
const uid = inst.uid || inst.figi;
|
|
410
|
+
if (uid) {
|
|
411
|
+
tbankUidCache.set(inst.ticker, uid);
|
|
412
|
+
uidMap[inst.ticker] = uid;
|
|
413
|
+
}
|
|
414
|
+
const minStep = qVal(inst.minPriceIncrement);
|
|
415
|
+
if (minStep > 0) {
|
|
416
|
+
const stepStr = String(minStep);
|
|
417
|
+
const dotIdx = stepStr.indexOf('.');
|
|
418
|
+
const decimals = dotIdx >= 0 ? Math.min(6, stepStr.length - dotIdx - 1) : 0;
|
|
419
|
+
specMap[inst.ticker] = {
|
|
420
|
+
lotSize: Math.max(1, Number(inst.lot) || 1),
|
|
421
|
+
minStep,
|
|
422
|
+
decimals,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
kvSet('tbankShortableMap', { v: 3, at: Date.now(), map, uidMap, specMap });
|
|
428
|
+
return map;
|
|
429
|
+
} catch {
|
|
430
|
+
return cached?.map || null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
315
434
|
async function resolveTbankInstrumentId(base, headers, symbol) {
|
|
316
435
|
if (tbankUidCache.has(symbol)) return tbankUidCache.get(symbol);
|
|
436
|
+
const savedUid = kvGet('tbankShortableMap')?.uidMap?.[symbol];
|
|
437
|
+
if (savedUid) {
|
|
438
|
+
tbankUidCache.set(symbol, savedUid);
|
|
439
|
+
return savedUid;
|
|
440
|
+
}
|
|
441
|
+
for (const classCode of ['TQBR', 'SPBRU']) {
|
|
442
|
+
try {
|
|
443
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/ShareBy`, {
|
|
444
|
+
method: 'POST',
|
|
445
|
+
headers,
|
|
446
|
+
body: JSON.stringify({ idType: 'INSTRUMENT_ID_TYPE_TICKER', classCode, id: symbol }),
|
|
447
|
+
timeoutMs: 10000,
|
|
448
|
+
});
|
|
449
|
+
if (r.ok) {
|
|
450
|
+
const j = await r.json();
|
|
451
|
+
if (j.instrument?.apiTradeAvailableFlag !== false) {
|
|
452
|
+
const uid = j.instrument?.uid || j.instrument?.figi;
|
|
453
|
+
if (uid) { tbankUidCache.set(symbol, uid); return uid; }
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} catch { /* next classCode */ }
|
|
457
|
+
}
|
|
317
458
|
try {
|
|
318
|
-
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/
|
|
459
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/FindInstrument`, {
|
|
319
460
|
method: 'POST',
|
|
320
461
|
headers,
|
|
321
|
-
body: JSON.stringify({
|
|
462
|
+
body: JSON.stringify({ query: symbol }),
|
|
322
463
|
timeoutMs: 10000,
|
|
323
464
|
});
|
|
324
465
|
if (r.ok) {
|
|
325
466
|
const j = await r.json();
|
|
326
|
-
const
|
|
467
|
+
const hit = (j.instruments || []).find((x) => x.ticker === symbol && x.apiTradeAvailableFlag === true && x.instrumentKind === 'INSTRUMENT_TYPE_SHARE');
|
|
468
|
+
const uid = hit?.uid || hit?.figi;
|
|
327
469
|
if (uid) { tbankUidCache.set(symbol, uid); return uid; }
|
|
328
470
|
}
|
|
329
471
|
} catch { /* fallback */ }
|
|
@@ -394,9 +536,43 @@ async function syncTbank(acc) {
|
|
|
394
536
|
marginEnabled = !mTxt.includes('30051') && !/margin.*disabled/i.test(mTxt) ? false : false;
|
|
395
537
|
}
|
|
396
538
|
const p = await rPort.json();
|
|
539
|
+
const jOrd = rOrd?.ok ? await rOrd.json().catch(() => null) : null;
|
|
540
|
+
let rawOrders = jOrd?.orders || [];
|
|
541
|
+
|
|
542
|
+
// Автоматическая уборка стакана (если счёт не в режиме «Только чтение»):
|
|
543
|
+
// 1) если по одному инструменту и направлению висит несколько дублирующих заявок — оставляем только самую свежую, остальные снимаем;
|
|
544
|
+
// 2) если лимитная заявка висит неисполненной дольше 12 минут (прошла свеча, цена ушла) — автоматически снимаем её и разблокируем рубли!
|
|
545
|
+
if (acc.mode !== 'readonly' && rawOrders.length > 0) {
|
|
546
|
+
const seenInstDir = new Set();
|
|
547
|
+
const toCancelIds = [];
|
|
548
|
+
const keptOrders = [];
|
|
549
|
+
const sortedByDate = [...rawOrders].sort((a, b) => Date.parse(b.orderDate || 0) - Date.parse(a.orderDate || 0));
|
|
550
|
+
const nowMs = Date.now();
|
|
551
|
+
for (const o of sortedByDate) {
|
|
552
|
+
const k = `${o.instrumentUid || o.figi || o.ticker}:${o.direction}`;
|
|
553
|
+
const ageMs = o.orderDate ? (nowMs - Date.parse(o.orderDate)) : 0;
|
|
554
|
+
if (seenInstDir.has(k) || ageMs > 12 * 60000) {
|
|
555
|
+
if (o.orderId) toCancelIds.push(o.orderId);
|
|
556
|
+
} else {
|
|
557
|
+
seenInstDir.add(k);
|
|
558
|
+
keptOrders.push(o);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (toCancelIds.length > 0) {
|
|
562
|
+
await Promise.all(toCancelIds.map((orderId) => (
|
|
563
|
+
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
564
|
+
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 8000,
|
|
565
|
+
}).catch(() => null)
|
|
566
|
+
)));
|
|
567
|
+
rawOrders = keptOrders;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
397
570
|
const totalRub = round(qVal(p.totalAmountPortfolio), 2);
|
|
398
571
|
const rawPositions = p.positions || [];
|
|
399
|
-
const lookupIds =
|
|
572
|
+
const lookupIds = [
|
|
573
|
+
...rawPositions.flatMap((pos) => [pos.ticker, pos.figi, pos.instrumentUid]),
|
|
574
|
+
...rawOrders.flatMap((o) => [o.ticker, o.figi, o.instrumentUid]),
|
|
575
|
+
].filter(Boolean);
|
|
400
576
|
const metaMap = await resolveTbankMetaMap(base, headers, lookupIds);
|
|
401
577
|
|
|
402
578
|
const positions = [];
|
|
@@ -410,7 +586,7 @@ async function syncTbank(acc) {
|
|
|
410
586
|
const qty = qVal(pos.quantity);
|
|
411
587
|
const px = qVal(pos.currentPrice);
|
|
412
588
|
if (isRubCashPos(pos, rawTicker)) {
|
|
413
|
-
cashRub += qty;
|
|
589
|
+
cashRub += Math.max(0, qty);
|
|
414
590
|
continue;
|
|
415
591
|
}
|
|
416
592
|
if (qty === 0) continue;
|
|
@@ -446,23 +622,28 @@ async function syncTbank(acc) {
|
|
|
446
622
|
}
|
|
447
623
|
|
|
448
624
|
const activeOrders = [];
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
625
|
+
for (const o of rawOrders) {
|
|
626
|
+
const m = metaMap[o.ticker] || metaMap[o.figi] || metaMap[o.instrumentUid] || {};
|
|
627
|
+
const sym = m.ticker || o.ticker || o.figi || '—';
|
|
628
|
+
const lotsReq = Number(o.lotsRequested || 0);
|
|
629
|
+
const lotsExec = Number(o.lotsExecuted || 0);
|
|
630
|
+
const remLots = Math.max(0, lotsReq - lotsExec);
|
|
631
|
+
const totalOrdRub = qVal(o.initialOrderPrice);
|
|
632
|
+
const remRub = lotsReq > 0 ? round(totalOrdRub * (remLots / lotsReq), 2) : round(totalOrdRub, 2);
|
|
633
|
+
activeOrders.push({
|
|
634
|
+
orderId: o.orderId,
|
|
635
|
+
symbol: sym,
|
|
636
|
+
name: m.name || '',
|
|
637
|
+
direction: String(o.direction || '').includes('BUY') ? 'buy' : 'sell',
|
|
638
|
+
lotsRequested: lotsReq,
|
|
639
|
+
lotsExecuted: lotsExec,
|
|
640
|
+
price: round(qVal(o.initialSecurityPrice), 4),
|
|
641
|
+
amountRub: remRub,
|
|
642
|
+
orderDate: o.orderDate || null,
|
|
643
|
+
});
|
|
465
644
|
}
|
|
645
|
+
const activeBuyOrdersRub = activeOrders.filter((o) => o.direction === 'buy').reduce((s, o) => s + (o.amountRub || 0), 0);
|
|
646
|
+
cashRub = Math.max(0, cashRub - activeBuyOrdersRub);
|
|
466
647
|
|
|
467
648
|
const breakdown = {
|
|
468
649
|
sharesRub: round(qVal(p.totalAmountShares), 0),
|
|
@@ -667,11 +848,109 @@ async function syncBcs(acc) {
|
|
|
667
848
|
return { accountId: acc.accountId || 'основной', totalRub: round(totalRub, 2), cashRub: round(cashRub, 2), positions };
|
|
668
849
|
}
|
|
669
850
|
|
|
851
|
+
async function syncCifra(acc) {
|
|
852
|
+
const [apiKey, secKey] = String(acc.token || '').split(':').map((s) => s.trim());
|
|
853
|
+
if (!apiKey || !secKey) throw new Error('Для Цифра брокер (Tradernet API) укажите ключи в формате PublicKey:SecretKey');
|
|
854
|
+
const qObj = { cmd: 'getPositionJson', params: {} };
|
|
855
|
+
const qStr = `q=${JSON.stringify(qObj)}`;
|
|
856
|
+
const sig = crypto.createHmac('sha256', secKey).update(qStr).digest('hex');
|
|
857
|
+
const r = await fetch('https://tradernet.ru/api/v2/cmd/getPositionJson', {
|
|
858
|
+
method: 'POST',
|
|
859
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-NtApi-PublicKey': apiKey, 'X-NtApi-Sig': sig },
|
|
860
|
+
body: qStr,
|
|
861
|
+
signal: AbortSignal.timeout(15000),
|
|
862
|
+
});
|
|
863
|
+
if (!r.ok) throw new Error(`Tradernet API: HTTP ${r.status}`);
|
|
864
|
+
const j = await r.json();
|
|
865
|
+
const ps = j?.result?.ps || {};
|
|
866
|
+
const accList = Array.isArray(ps.acc) ? ps.acc : [];
|
|
867
|
+
const rubAcc = accList.find((c) => c.curr === 'RUR' || c.curr === 'RUB') || accList[0] || {};
|
|
868
|
+
const cashRub = round(Number(rubAcc.s || 0), 2);
|
|
869
|
+
const posRaw = Array.isArray(ps.pos) ? ps.pos : [];
|
|
870
|
+
let posSum = 0, pnlSum = 0;
|
|
871
|
+
const positions = posRaw.map((p) => {
|
|
872
|
+
const qty = Number(p.q || 0);
|
|
873
|
+
const price = round(Number(p.mkt_price || p.price_a || 0), 4);
|
|
874
|
+
const val = round(Number(p.s || qty * price), 2);
|
|
875
|
+
const pnl = round(Number(p.vm || 0), 2);
|
|
876
|
+
posSum += val;
|
|
877
|
+
pnlSum += pnl;
|
|
878
|
+
return { symbol: String(p.i || '').replace(/\..*$/, ''), name: p.name || p.i || '', qty, price, valueRub: val, pnlRub: pnl };
|
|
879
|
+
});
|
|
880
|
+
return { accountId: acc.accountId || 'Tradernet', marginEnabled: true, totalRub: round(cashRub + posSum, 2), cashRub, totalPnlRub: round(pnlSum, 2), positions };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function syncItiCapital(acc) {
|
|
884
|
+
if (!acc.accountId) throw new Error('Укажите портфель ITI Capital (например BP12345)');
|
|
885
|
+
const oauth = acc.sandbox ? 'https://oauthdev.iticapital.ru' : 'https://oauth.iticapital.ru';
|
|
886
|
+
const api = acc.sandbox ? 'https://apidev.iticapital.ru' : 'https://api.iticapital.ru';
|
|
887
|
+
const rTok = await fetch(`${oauth}/refresh?token=${encodeURIComponent(acc.token)}`, { method: 'POST', signal: AbortSignal.timeout(15000) });
|
|
888
|
+
if (!rTok.ok) throw new Error(`ITI Capital OAuth: HTTP ${rTok.status}`);
|
|
889
|
+
const { AccessToken } = await rTok.json();
|
|
890
|
+
if (!AccessToken) throw new Error('ITI Capital не вернул AccessToken');
|
|
891
|
+
const headers = { Authorization: `Bearer ${AccessToken}` };
|
|
892
|
+
const [rSum, rPos] = await Promise.all([
|
|
893
|
+
fetch(`${api}/md/v2/Clients/MOEX/${encodeURIComponent(acc.accountId)}/summary`, { headers, signal: AbortSignal.timeout(15000) }),
|
|
894
|
+
fetch(`${api}/md/v2/Clients/MOEX/${encodeURIComponent(acc.accountId)}/positions`, { headers, signal: AbortSignal.timeout(15000) }),
|
|
895
|
+
]);
|
|
896
|
+
if (!rSum.ok) throw new Error(`ITI Capital summary: HTTP ${rSum.status}`);
|
|
897
|
+
const sum = await rSum.json();
|
|
898
|
+
const posList = rPos.ok ? await rPos.json() : [];
|
|
899
|
+
let cashRub = round(Number(sum.buyingPower || sum.cash || 0), 2);
|
|
900
|
+
const positions = [];
|
|
901
|
+
for (const p of Array.isArray(posList) ? posList : []) {
|
|
902
|
+
if (p.isCurrency || p.symbol === 'RUB') { cashRub = round(Number(p.qtyUnits || p.qty || cashRub), 2); continue; }
|
|
903
|
+
const qty = Number(p.qtyUnits || p.qty || 0);
|
|
904
|
+
const val = Number(p.volume || 0);
|
|
905
|
+
positions.push({ symbol: p.symbol, qty, price: qty ? round(val / qty, 4) : 0, valueRub: round(val, 2), pnlRub: round(Number(p.unrealisedPl || 0), 2) });
|
|
906
|
+
}
|
|
907
|
+
return { accountId: acc.accountId, marginEnabled: true, totalRub: round(Number(sum.portfolioLiquidationValue || cashRub), 2), cashRub, positions };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function syncQuikOrManual(acc) {
|
|
911
|
+
const tokenStr = String(acc.token || '').trim();
|
|
912
|
+
if (tokenStr.startsWith('http://') || tokenStr.startsWith('https://')) {
|
|
913
|
+
const base = tokenStr.replace(/\/+$/, '');
|
|
914
|
+
const r = await fetch(`${base}/portfolio?account=${encodeURIComponent(acc.accountId || '')}`, { signal: AbortSignal.timeout(12000) });
|
|
915
|
+
if (!r.ok) throw new Error(`QUIK HTTP-шлюз: HTTP ${r.status}`);
|
|
916
|
+
const j = await r.json();
|
|
917
|
+
const positions = (j.positions || []).map((p) => ({
|
|
918
|
+
symbol: p.symbol || p.secCode,
|
|
919
|
+
name: p.name || p.symbol || p.secCode,
|
|
920
|
+
qty: Number(p.qty || p.currentBal || 0),
|
|
921
|
+
price: round(Number(p.price || p.lastPrice || 0), 4),
|
|
922
|
+
valueRub: round(Number(p.valueRub || 0), 2),
|
|
923
|
+
pnlRub: round(Number(p.pnlRub || 0), 2),
|
|
924
|
+
}));
|
|
925
|
+
const cashRub = round(Number(j.cashRub ?? j.cash ?? acc.maxCapitalRub ?? 100000), 2);
|
|
926
|
+
const totalRub = round(Number(j.totalRub ?? (cashRub + positions.reduce((s, x) => s + x.valueRub, 0))), 2);
|
|
927
|
+
return { accountId: acc.accountId || 'QUIK', marginEnabled: Boolean(j.marginEnabled), totalRub, cashRub, positions };
|
|
928
|
+
}
|
|
929
|
+
// Режим подтверждения заявок (СберИнвестиции / ВТБ / Альфа без QUIK-шлюза): учёт позиций и лимита внутри платформы
|
|
930
|
+
const specs = await ensureBoardSpecs();
|
|
931
|
+
const prevPos = acc.lastSync?.positions || [];
|
|
932
|
+
let posVal = 0, pnlSum = 0;
|
|
933
|
+
const positions = prevPos.filter((p) => p.qty !== 0).map((p) => {
|
|
934
|
+
const px = specs[p.symbol]?.lastPrice || p.price || 0;
|
|
935
|
+
const avg = p.avgPrice || p.price || px;
|
|
936
|
+
const valueRub = round(p.qty * px, 2);
|
|
937
|
+
const pnlRub = round(p.qty * (px - avg), 2);
|
|
938
|
+
posVal += Math.abs(valueRub);
|
|
939
|
+
pnlSum += pnlRub;
|
|
940
|
+
return { ...p, avgPrice: avg, price: round(px, 4), valueRub, pnlRub };
|
|
941
|
+
});
|
|
942
|
+
const baseCap = acc.maxCapitalRub || 100000;
|
|
943
|
+
const cashRub = acc.lastSync?.cashRub !== undefined ? round(acc.lastSync.cashRub, 2) : round(Math.max(0, baseCap - posVal), 2);
|
|
944
|
+
const totalRub = round(cashRub + posVal, 2);
|
|
945
|
+
return { accountId: acc.accountId || 'Ручное подтверждение', marginEnabled: false, totalRub, cashRub, totalPnlRub: round(pnlSum, 2), positions };
|
|
946
|
+
}
|
|
947
|
+
|
|
670
948
|
export async function syncBrokerAccount(id) {
|
|
671
949
|
const list = loadRawAccounts();
|
|
672
950
|
const acc = list.find((x) => x.id === id);
|
|
673
951
|
if (!acc) throw new Error('Счёт не найден');
|
|
674
|
-
|
|
952
|
+
const isManualCapable = ['sber', 'vtb', 'alfa'].includes(acc.broker);
|
|
953
|
+
if (!acc.token && !isManualCapable) throw new Error('У счёта не задан токен API');
|
|
675
954
|
const today = new Date(Date.now() + MSK).toISOString().slice(0, 10);
|
|
676
955
|
try {
|
|
677
956
|
let data;
|
|
@@ -679,15 +958,48 @@ export async function syncBrokerAccount(id) {
|
|
|
679
958
|
else if (acc.broker === 'finam') data = await syncFinam(acc);
|
|
680
959
|
else if (acc.broker === 'alor') data = await syncAlor(acc);
|
|
681
960
|
else if (acc.broker === 'bcs') data = await syncBcs(acc);
|
|
961
|
+
else if (acc.broker === 'cifra') data = await syncCifra(acc);
|
|
962
|
+
else if (acc.broker === 'iticapital') data = await syncItiCapital(acc);
|
|
963
|
+
else if (isManualCapable) data = await syncQuikOrManual(acc);
|
|
682
964
|
else throw new Error(`Неизвестный брокер: ${acc.broker}`);
|
|
683
965
|
if (data.accountId && !acc.accountId) acc.accountId = data.accountId;
|
|
966
|
+
const specs = await ensureBoardSpecs();
|
|
967
|
+
if (Array.isArray(data.positions)) {
|
|
968
|
+
data.positions = data.positions.map((p) => {
|
|
969
|
+
const issueSize = specs[p.symbol]?.issueSize || 0;
|
|
970
|
+
const ownershipPct = issueSize > 0 && p.qty > 0 ? (p.qty / issueSize) * 100 : null;
|
|
971
|
+
return { ...p, issueSize, ownershipPct };
|
|
972
|
+
});
|
|
973
|
+
}
|
|
684
974
|
if (data.accountName) acc.tbankAccountName = data.accountName;
|
|
685
975
|
if ((!acc.name || acc.name === 'Т-Банк (T-Invest API)') && data.accountName) {
|
|
686
976
|
acc.name = `Т-Банк · ${data.accountName}`;
|
|
687
977
|
}
|
|
688
|
-
// При первой синхронизации
|
|
978
|
+
// При первой синхронизации запоминаем стартовый портфель и стартовую базу капитала;
|
|
979
|
+
// а при продаже старых бумаг (в режиме «Плавный переход» или вручную) автоматически уменьшаем baselineQty до фактического остатка,
|
|
980
|
+
// чтобы новые покупки стратегии по тем же тикерам никогда не путались со старым портфелем
|
|
689
981
|
if (!acc.baselineQty || (!Object.keys(acc.baselineQty).length && data.positions?.length)) {
|
|
690
982
|
acc.baselineQty = Object.fromEntries((data.positions || []).filter((p) => p.qty !== 0).map((p) => [p.symbol, p.qty]));
|
|
983
|
+
} else if (acc.baselineQty && Array.isArray(data.positions)) {
|
|
984
|
+
const curPosMap = new Map(data.positions.map((p) => [p.symbol, p.qty]));
|
|
985
|
+
for (const [sym, bq] of Object.entries(acc.baselineQty)) {
|
|
986
|
+
const curQ = Number(curPosMap.get(sym) || 0);
|
|
987
|
+
if (bq > 0 && curQ < bq) {
|
|
988
|
+
if (curQ <= 0) delete acc.baselineQty[sym];
|
|
989
|
+
else acc.baselineQty[sym] = curQ;
|
|
990
|
+
} else if (bq < 0 && curQ > bq) {
|
|
991
|
+
if (curQ >= 0) delete acc.baselineQty[sym];
|
|
992
|
+
else acc.baselineQty[sym] = curQ;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (!acc.initialTotalRub && data.totalRub > 0) {
|
|
997
|
+
acc.initialTotalRub = round(data.totalRub, 2);
|
|
998
|
+
}
|
|
999
|
+
// С началом нового календарного дня автоматически сбрасываем вчерашний дневной стоп по просадке
|
|
1000
|
+
if (acc.lastSync?.day && acc.lastSync.day !== today && acc.haltedReason && acc.haltedReason.startsWith('Сработал дневной стоп')) {
|
|
1001
|
+
acc.haltedReason = null;
|
|
1002
|
+
acc.ddBreachCount = 0;
|
|
691
1003
|
}
|
|
692
1004
|
const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
|
|
693
1005
|
const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
|
|
@@ -695,7 +1007,13 @@ export async function syncBrokerAccount(id) {
|
|
|
695
1007
|
if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
|
|
696
1008
|
acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
|
|
697
1009
|
} else if (ddPct >= acc.maxDailyDrawdownPct) {
|
|
698
|
-
|
|
1010
|
+
// Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
|
|
1011
|
+
acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
|
|
1012
|
+
if (acc.ddBreachCount >= 2) {
|
|
1013
|
+
acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
|
|
1014
|
+
}
|
|
1015
|
+
} else {
|
|
1016
|
+
acc.ddBreachCount = 0;
|
|
699
1017
|
}
|
|
700
1018
|
// История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
|
|
701
1019
|
const hist = Array.isArray(acc.equityHistory) ? acc.equityHistory : [];
|
|
@@ -705,6 +1023,17 @@ export async function syncBrokerAccount(id) {
|
|
|
705
1023
|
if (hist.length > 500) hist.splice(0, hist.length - 500);
|
|
706
1024
|
}
|
|
707
1025
|
acc.equityHistory = hist;
|
|
1026
|
+
const entryMap = { ...(acc.entryTsBySymbol || {}) };
|
|
1027
|
+
const curSymbols = new Set((data.positions || []).filter((p) => p.qty !== 0).map((p) => p.symbol));
|
|
1028
|
+
for (const s of curSymbols) {
|
|
1029
|
+
// Если позиция обнаружена при синхронизации без записи в executeBrokerOrders (например, перешла со вчерашнего дня),
|
|
1030
|
+
// помечаем её как зрелую (120 мин), чтобы для неё сразу действовал штатный порог выхода 0.95, а не стартовый 65-минутный буфер
|
|
1031
|
+
if (!entryMap[s]) entryMap[s] = nowTs - 120 * 60000;
|
|
1032
|
+
}
|
|
1033
|
+
for (const s of Object.keys(entryMap)) {
|
|
1034
|
+
if (!curSymbols.has(s)) delete entryMap[s];
|
|
1035
|
+
}
|
|
1036
|
+
acc.entryTsBySymbol = entryMap;
|
|
708
1037
|
acc.lastSync = { at: nowTs, ok: true, error: null, day: today, dayStartRub, dayDrawdownPct: ddPct, ...data };
|
|
709
1038
|
} catch (e) {
|
|
710
1039
|
acc.lastSync = { ...(acc.lastSync || {}), at: Date.now(), ok: false, error: e.message };
|
|
@@ -715,6 +1044,7 @@ export async function syncBrokerAccount(id) {
|
|
|
715
1044
|
saveRawAccounts(list);
|
|
716
1045
|
const st = brokersState();
|
|
717
1046
|
hub.emit({ type: 'brokers:update', state: st });
|
|
1047
|
+
hub.emit({ type: 'trading:pulse', kind: 'broker_sync', ts: Date.now(), accountId: acc.id, name: acc.name });
|
|
718
1048
|
return st;
|
|
719
1049
|
}
|
|
720
1050
|
|
|
@@ -753,51 +1083,106 @@ function sessionEdgeWarning(now = Date.now()) {
|
|
|
753
1083
|
|
|
754
1084
|
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
755
1085
|
export async function prepareBrokerOrders(id) {
|
|
756
|
-
|
|
757
|
-
|
|
1086
|
+
let list = loadRawAccounts();
|
|
1087
|
+
let acc = list.find((x) => x.id === id);
|
|
758
1088
|
if (!acc) throw new Error('Счёт не найден');
|
|
759
1089
|
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
760
1090
|
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
761
1091
|
|
|
762
1092
|
const plan = effectivePlan(currentUser());
|
|
763
1093
|
if ((plan.maxBrokerAccounts ?? 0) < 1) {
|
|
764
|
-
throw new Error(
|
|
1094
|
+
throw new Error('Для работы с брокерскими счетами оформите подписку «Инвестор» (3 900 ₽/мес), «Профи» (9 900 ₽/мес) или «VIP» в разделе «Подписка и уведомления».');
|
|
765
1095
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1096
|
+
hub.emit({ type: 'trading:pulse', kind: 'broker_calc', ts: Date.now(), accountId: acc.id, name: acc.name, text: `Пересчёт заявок по счёту «${acc.name}»…` });
|
|
1097
|
+
// Перед расчётом заявок автоматически снимаем старые неисполненные заявки со стакана и синхронизируем свежий баланс!
|
|
1098
|
+
if (acc.token && acc.mode !== 'readonly' && acc.broker === 'tbank' && acc.lastSync?.activeOrders?.length > 0) {
|
|
1099
|
+
await cancelBrokerOrder(id, 'all').catch(() => {});
|
|
1100
|
+
list = loadRawAccounts();
|
|
1101
|
+
acc = list.find((x) => x.id === id) || acc;
|
|
1102
|
+
} else if (acc.token && (!acc.lastSync || Date.now() - (acc.lastSync.at || 0) > 45000)) {
|
|
1103
|
+
await syncBrokerAccount(id).catch(() => {});
|
|
1104
|
+
list = loadRawAccounts();
|
|
1105
|
+
acc = list.find((x) => x.id === id) || acc;
|
|
1106
|
+
}
|
|
1107
|
+
const params = normalizeParams(getSettings());
|
|
1108
|
+
const [fc, rawSpecs, shortableMap] = await Promise.all([
|
|
769
1109
|
forecastNow(params),
|
|
770
1110
|
ensureBoardSpecs(),
|
|
1111
|
+
ensureTbankShortableMap(acc),
|
|
771
1112
|
]);
|
|
1113
|
+
const specs = { ...rawSpecs };
|
|
1114
|
+
if (acc.broker === 'tbank') {
|
|
1115
|
+
const tbSpecs = kvGet('tbankShortableMap')?.specMap || {};
|
|
1116
|
+
for (const [sym, ts] of Object.entries(tbSpecs)) {
|
|
1117
|
+
specs[sym] = { ...(specs[sym] || { lastPrice: 0, issueSize: 0 }), ...ts };
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
772
1120
|
const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
|
|
773
1121
|
const effectiveMaxCap = Math.min(acc.maxCapitalRub, capCeiling);
|
|
774
|
-
|
|
775
|
-
|
|
1122
|
+
// Лимит тарифа (1 млн ₽ на Инвесторе/Профи) ограничивает СТАРТОВУЮ выделенную сумму на счёте,
|
|
1123
|
+
// а вся заработанная стратегией сверху прибыль автоматически реинвестируется в рабочий бюджет (сложный процент):
|
|
1124
|
+
const curTotalRub = acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : effectiveMaxCap;
|
|
1125
|
+
const initBaseRub = acc.initialTotalRub > 0 ? acc.initialTotalRub : (acc.equityHistory?.[0]?.totalRub > 0 ? acc.equityHistory[0].totalRub : curTotalRub);
|
|
1126
|
+
const reinvestedProfitRub = round(Math.max(0, curTotalRub - Math.max(initBaseRub, effectiveMaxCap)), 0);
|
|
1127
|
+
const allowedTotalCapRub = effectiveMaxCap + reinvestedProfitRub;
|
|
1128
|
+
const budget = round(Math.min(allowedTotalCapRub, curTotalRub), 0);
|
|
1129
|
+
const autoAdapt = params.modelAutoAdapt !== false;
|
|
1130
|
+
const rawTopK = Number(params.modelTopK ?? 2);
|
|
1131
|
+
const autoK = rawTopK === 0 || autoAdapt;
|
|
1132
|
+
const K = rawTopK > 0 ? rawTopK : 4;
|
|
1133
|
+
const filterBrokerDynamicTopK = (cands, needVal, maxCount) => {
|
|
1134
|
+
if (!cands.length || maxCount <= 0) return [];
|
|
1135
|
+
const lim = Math.min(cands.length, maxCount);
|
|
1136
|
+
if (!autoK || lim <= 1) return cands.slice(0, lim);
|
|
1137
|
+
const bestE = Math.abs(cands[0].expectedPct || 0);
|
|
1138
|
+
const out = [cands[0]];
|
|
1139
|
+
for (let m = 1; m < lim; m++) {
|
|
1140
|
+
const curE = Math.abs(cands[m].expectedPct || 0);
|
|
1141
|
+
const relMin = m === 1 ? 0.62 : 0.78;
|
|
1142
|
+
const absMin = m === 1 ? needVal * 1.15 : needVal * 1.45;
|
|
1143
|
+
if (curE >= bestE * relMin && curE >= absMin) out.push(cands[m]);
|
|
1144
|
+
else break;
|
|
1145
|
+
}
|
|
1146
|
+
return out;
|
|
1147
|
+
};
|
|
776
1148
|
// Автоматическая подстройка направления и мощности торговли под тариф и реальные данные счёта:
|
|
777
1149
|
const marginAllowed = acc.lastSync?.marginEnabled !== false;
|
|
778
1150
|
const planAllowsShorts = Boolean(plan.allowShorts);
|
|
779
1151
|
const vipBoost = Boolean(plan.allowVipBoost);
|
|
780
1152
|
const rawDir = params.modelDirection || 'long';
|
|
781
1153
|
const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
|
|
782
|
-
const autoAdapt = params.modelAutoAdapt !== false;
|
|
783
1154
|
const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
|
|
784
1155
|
const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
|
|
1156
|
+
const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.35, 3);
|
|
1157
|
+
const nowTs = Date.now();
|
|
1158
|
+
const entryTsMap = acc.entryTsBySymbol || {};
|
|
1159
|
+
const exitCooldownMap = acc.exitCooldownBySymbol || {};
|
|
1160
|
+
const mskDate = new Date(nowTs + MSK);
|
|
1161
|
+
const isEveningSession = (mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes()) >= 19 * 60;
|
|
1162
|
+
const eveningBoost = (autoAdapt && isEveningSession) ? 1.25 : 1.0;
|
|
1163
|
+
const minEntryEdgePct = round((fc.thresholdPct || 0) * eveningBoost, 3);
|
|
1164
|
+
const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
|
|
785
1165
|
const slots = dir === 'long' ? K : 2 * K;
|
|
786
1166
|
// Фильтр мощности слота по тарифу:
|
|
787
|
-
// • Инвестор (до
|
|
788
|
-
// • Профи (до
|
|
789
|
-
// • VIP (
|
|
1167
|
+
// • Инвестор (до 60% годовых): 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
|
|
1168
|
+
// • Профи (до 120% годовых): до 5 счетов по 1 млн ₽, покупки + шорты, базовый слот до 28% капитала
|
|
1169
|
+
// • VIP (до 240% годовых): счета и сумма по согласованию, покупки + шорты, слот до 48% капитала
|
|
1170
|
+
// • Хозяин (без ограничений): полная мощность ядра (>400% годовых, Conviction Sizing до 75% капитала)
|
|
1171
|
+
const isOwnerPlan = Boolean(plan.isOwner || plan.id === 'owner' || plan.maxAnnualReturnPct === 0);
|
|
790
1172
|
let slotRub;
|
|
791
|
-
if (
|
|
792
|
-
const maxSlotWeight = dir === 'long' ? Math.min(0.
|
|
1173
|
+
if (isOwnerPlan && autoAdapt) {
|
|
1174
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
|
|
1175
|
+
slotRub = budget * maxSlotWeight;
|
|
1176
|
+
} else if (vipBoost && autoAdapt) {
|
|
1177
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.4 / K)) : Math.min(0.48, 1.9 / (2 * K));
|
|
793
1178
|
slotRub = budget * maxSlotWeight;
|
|
794
1179
|
} else if (plan.id === 'pro') {
|
|
795
|
-
slotRub = budget * Math.min(0.
|
|
1180
|
+
slotRub = budget * Math.min(0.28, 1.15 / Math.max(2, slots));
|
|
796
1181
|
} else {
|
|
797
|
-
slotRub = budget * Math.min(0.
|
|
1182
|
+
slotRub = budget * Math.min(0.24, 1.05 / Math.max(3, K + 1));
|
|
798
1183
|
}
|
|
799
|
-
//
|
|
800
|
-
if (!
|
|
1184
|
+
// Мягкий дроссель при опережении годового потолка доходности тарифа (60% Инвестор, 120% Профи, 240% VIP; у Хозяина = 0 — без потолка)
|
|
1185
|
+
if (!isOwnerPlan && plan.maxAnnualReturnPct > 0 && Array.isArray(acc.equityHistory) && acc.equityHistory.length >= 2) {
|
|
801
1186
|
const firstPt = acc.equityHistory[0];
|
|
802
1187
|
const lastPt = acc.equityHistory[acc.equityHistory.length - 1];
|
|
803
1188
|
const elapsedDays = Math.max(7, (lastPt.ts - firstPt.ts) / DAY);
|
|
@@ -810,14 +1195,29 @@ export async function prepareBrokerOrders(id) {
|
|
|
810
1195
|
}
|
|
811
1196
|
}
|
|
812
1197
|
const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
|
|
813
|
-
const convOf = (r) =>
|
|
1198
|
+
const convOf = (r) => {
|
|
1199
|
+
if (!autoAdapt || !r.expectedPct) return 1;
|
|
1200
|
+
if (isOwnerPlan) return Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (Math.abs(r.expectedPct) / needPct)));
|
|
1201
|
+
if (vipBoost) return Math.min(1.30, Math.max(0.85, 0.80 + 0.25 * (Math.abs(r.expectedPct) / needPct)));
|
|
1202
|
+
return 1;
|
|
1203
|
+
};
|
|
814
1204
|
const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
|
|
815
1205
|
const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
|
|
816
|
-
const quarantined = new Set(
|
|
1206
|
+
const quarantined = new Set();
|
|
817
1207
|
const nb = params.newsFilter ? blockingNews(6) : null;
|
|
818
1208
|
|
|
819
|
-
const buyCands = fc.rows.filter((r) => r.signal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad).sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
820
|
-
const
|
|
1209
|
+
const buyCands = fc.rows.filter((r) => r.signal && r.expectedPct >= minEntryEdgePct && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad).sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
1210
|
+
const allShortSignals = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -minEntryEdgePct && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
1211
|
+
const nonShortableSkipped = [];
|
|
1212
|
+
const rawShortCands = [];
|
|
1213
|
+
for (const r of allShortSignals) {
|
|
1214
|
+
if (shortableMap && shortableMap[r.symbol] === false) {
|
|
1215
|
+
nonShortableSkipped.push(r);
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
rawShortCands.push(r);
|
|
1219
|
+
if (rawShortCands.length >= K) break;
|
|
1220
|
+
}
|
|
821
1221
|
const shortCands = (marginAllowed && planAllowsShorts) ? rawShortCands : [];
|
|
822
1222
|
|
|
823
1223
|
const legacyMode = acc.legacyMode || 'isolate';
|
|
@@ -843,38 +1243,42 @@ export async function prepareBrokerOrders(id) {
|
|
|
843
1243
|
const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
|
|
844
1244
|
const aQty = algoQtyOf(sym, pos.qty);
|
|
845
1245
|
const baseQty = pos.qty - aQty;
|
|
1246
|
+
const heldMs = entryTsMap[sym] ? (nowTs - entryTsMap[sym]) : (120 * 60000);
|
|
1247
|
+
// Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
|
|
1248
|
+
// чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95)
|
|
1249
|
+
const curExitThr = (autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct;
|
|
846
1250
|
if (aQty > 0) {
|
|
847
1251
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
848
|
-
const keep = r.liquid && r.expectedPct >
|
|
1252
|
+
const keep = r.liquid && r.expectedPct > curExitThr;
|
|
849
1253
|
if (lots >= 1 && !keep) {
|
|
850
1254
|
const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
851
1255
|
orders.push({
|
|
852
1256
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
853
1257
|
refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
854
|
-
reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${
|
|
1258
|
+
reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`,
|
|
855
1259
|
});
|
|
856
1260
|
closing.add(sym);
|
|
857
1261
|
}
|
|
858
1262
|
} else if (aQty < 0) {
|
|
859
1263
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
860
|
-
const keep = dir !== 'long' && r.liquid && r.expectedPct < -
|
|
1264
|
+
const keep = dir !== 'long' && r.liquid && r.expectedPct < -curExitThr;
|
|
861
1265
|
if (lots >= 1 && !keep) {
|
|
862
1266
|
const limitPrice = roundToStep(pos.price * (1 + acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
863
1267
|
orders.push({
|
|
864
1268
|
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
865
1269
|
refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
866
|
-
reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${
|
|
1270
|
+
reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`,
|
|
867
1271
|
});
|
|
868
1272
|
closing.add(sym);
|
|
869
1273
|
}
|
|
870
1274
|
}
|
|
871
1275
|
// В режиме «Плавный переход» (smooth) проверяем начальные акции:
|
|
872
|
-
// если по старой акции вышел сильный сигнал
|
|
873
|
-
// иначе
|
|
1276
|
+
// 1) если по старой акции вышел сильный медвежий сигнал (ожидание ниже −needPct), сразу закрываем всю позицию для защиты счёта;
|
|
1277
|
+
// 2) иначе добавляем бумагу в пул плавной ротации (в первую очередь высвобождаются бумаги с отрицательным/слабым прогнозом под новые лонги и шорты)
|
|
874
1278
|
if (legacyMode === 'smooth' && baseQty > 0 && !closing.has(sym)) {
|
|
875
1279
|
const bLots = Math.floor(baseQty / sp.lotSize);
|
|
876
1280
|
if (bLots >= 1 && !(r.liquid && r.expectedPct > exitThresholdPct)) {
|
|
877
|
-
if (r.expectedPct
|
|
1281
|
+
if (r.expectedPct <= -needPct) {
|
|
878
1282
|
const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
879
1283
|
orders.push({
|
|
880
1284
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots: bLots, shares: bLots * sp.lotSize,
|
|
@@ -895,63 +1299,95 @@ export async function prepareBrokerOrders(id) {
|
|
|
895
1299
|
const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
|
|
896
1300
|
let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
|
|
897
1301
|
|
|
898
|
-
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1302
|
+
// 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
|
|
1303
|
+
const rawNewBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol)));
|
|
1304
|
+
// Не открываем шорт по тикеру, в котором ещё лежит непроданный остаток старого портфеля (baseQty > 0), чтобы брокер не превратил шорт в продажу старой позиции
|
|
1305
|
+
const rawNewShorts = shortCands.filter((r) => {
|
|
1306
|
+
const curQ = heldMap.get(r.symbol)?.qty || 0;
|
|
1307
|
+
const aQ = algoQtyOf(r.symbol, curQ);
|
|
1308
|
+
const bQ = curQ - aQ;
|
|
1309
|
+
if (bQ > 0 && !closing.has(r.symbol)) return false;
|
|
1310
|
+
return !(aQ < 0 && !closing.has(r.symbol));
|
|
1311
|
+
});
|
|
1312
|
+
const newBuys = filterBrokerDynamicTopK(rawNewBuys, minEntryEdgePct, Math.max(0, K - activeLongs));
|
|
1313
|
+
const newShorts = filterBrokerDynamicTopK(rawNewShorts, minEntryEdgePct, Math.max(0, K - activeShorts));
|
|
1314
|
+
const totalNew = newBuys.length + newShorts.length;
|
|
1315
|
+
const activeTotal = Math.max(2, activeLongs + activeShorts + totalNew);
|
|
1316
|
+
const dynSlotRub = (isOwnerPlan && autoAdapt)
|
|
1317
|
+
? budget * Math.min(dir === 'long' ? 0.58 : 0.75, Math.max(slotRub / Math.max(1, budget), (dir === 'long' ? 1.8 : 3.0) / Math.min(slots, activeTotal + 1)))
|
|
1318
|
+
: (vipBoost && autoAdapt)
|
|
1319
|
+
? budget * Math.min(0.48, Math.max(slotRub / Math.max(1, budget), (dir === 'long' ? 1.4 : 1.9) / Math.min(slots, activeTotal + 1)))
|
|
1320
|
+
: slotRub;
|
|
1321
|
+
|
|
1322
|
+
// В режиме «Плавный переход» (smooth):
|
|
1323
|
+
// • высвобождаем кэш из старого портфеля как под новые ПОКУПКИ, так и под новые ШОРТЫ (чтобы шорты открывались полным рабочим слотом dynSlotRub, а не на остаток копеек);
|
|
1324
|
+
// • даже если прямо сейчас нет нового входа, но свободного кэша на счёте меньше 1 рабочего слота, плавно продаём на каждом шаге по 1–2 самые слабые старые акции с отрицательным прогнозом (expectedPct <= -0.02%), заранее формируя рублёвый резерв;
|
|
1325
|
+
// • сортируем кандидатов строго по худшему прогнозу ИИ (сначала акции с отрицательным ожиданием, среди близких — позиции в плюсе или с минимальным убытком), до 4 бумаг за шаг при активном сигнале.
|
|
1326
|
+
if (legacyMode === 'smooth' && smoothSellPool.length > 0) {
|
|
1327
|
+
const activeEntries = [...newBuys, ...newShorts];
|
|
1328
|
+
const neededForSignalsRub = activeEntries.reduce((s, r) => s + (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r), 0);
|
|
1329
|
+
const hasWeakLegacy = smoothSellPool.some((x) => (x.r.expectedPct || 0) <= -0.02);
|
|
1330
|
+
const proactiveReserveRub = (!activeEntries.length && hasWeakLegacy && availRub < slotRub) ? slotRub : 0;
|
|
1331
|
+
const targetNeedRub = Math.max(neededForSignalsRub, proactiveReserveRub);
|
|
1332
|
+
let shortfall = Math.min(Math.max(0, budget * 0.99 - usedRub), targetNeedRub) - availRub;
|
|
907
1333
|
if (shortfall > 1000) {
|
|
908
|
-
smoothSellPool.sort((a, b) =>
|
|
909
|
-
|
|
1334
|
+
smoothSellPool.sort((a, b) => {
|
|
1335
|
+
const ea = Number(a.r.expectedPct || 0);
|
|
1336
|
+
const eb = Number(b.r.expectedPct || 0);
|
|
1337
|
+
// Сначала бумаги с явно худшим прогнозом (разница > 0.03%), при близком прогнозе — сначала плюсовые/околонулевые
|
|
1338
|
+
if (Math.abs(ea - eb) > 0.03) return ea - eb;
|
|
1339
|
+
return ((b.pos.pnlRub >= 0 ? 1 : 0) - (a.pos.pnlRub >= 0 ? 1 : 0)) || (ea - eb);
|
|
1340
|
+
});
|
|
1341
|
+
const maxSteps = activeEntries.length > 0 ? 4 : 2;
|
|
1342
|
+
const poolToUse = activeEntries.length > 0
|
|
1343
|
+
? smoothSellPool.slice(0, maxSteps)
|
|
1344
|
+
: smoothSellPool.filter((x) => (x.r.expectedPct || 0) <= -0.02).slice(0, maxSteps);
|
|
1345
|
+
for (const item of poolToUse) {
|
|
910
1346
|
if (shortfall <= 1000) break;
|
|
911
1347
|
const limitPrice = roundToStep(item.pos.price * (1 - acc.limitOffsetPct / 100), item.sp.minStep, item.sp.decimals);
|
|
912
1348
|
const lotVal = limitPrice * item.sp.lotSize;
|
|
913
1349
|
const lotsToSell = Math.min(item.bLots, Math.max(1, Math.ceil(shortfall / lotVal)));
|
|
914
1350
|
const amountRub = round(lotsToSell * item.sp.lotSize * limitPrice, 2);
|
|
1351
|
+
const sigReason = activeEntries.length > 0
|
|
1352
|
+
? `под сигнал стратегии (${activeEntries.map((x) => x.symbol).join(', ')})`
|
|
1353
|
+
: 'упреждающее освобождение кэша из слабой бумаги';
|
|
915
1354
|
orders.push({
|
|
916
1355
|
symbol: item.sym, action: 'sell', lotSize: item.sp.lotSize, lots: lotsToSell, shares: lotsToSell * item.sp.lotSize,
|
|
917
1356
|
refPrice: item.pos.price, limitPrice, amountRub,
|
|
918
|
-
reason: `Плавный переход: высвобождение ${fmtRubSimple(amountRub)} ₽ из старого портфеля (
|
|
1357
|
+
reason: `Плавный переход: высвобождение ${fmtRubSimple(amountRub)} ₽ из старого портфеля (прогноз ${item.r.expectedPct > 0 ? '+' : ''}${item.r.expectedPct}%) ${sigReason}`,
|
|
919
1358
|
});
|
|
920
1359
|
availRub += amountRub;
|
|
921
1360
|
shortfall -= amountRub;
|
|
922
1361
|
}
|
|
923
1362
|
}
|
|
924
1363
|
}
|
|
925
|
-
const totalNew = newBuys.length + newShorts.length;
|
|
926
1364
|
let remNew = totalNew;
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
const ord = calcLotOrder(r.symbol, sum, refPx, 'buy', acc.limitOffsetPct, specs);
|
|
934
|
-
if (ord) {
|
|
935
|
-
availRub = Math.max(0, availRub - ord.amountRub);
|
|
936
|
-
orders.push({
|
|
937
|
-
...ord,
|
|
938
|
-
expectedPct: r.expectedPct,
|
|
939
|
-
reason: `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`,
|
|
940
|
-
});
|
|
941
|
-
}
|
|
1365
|
+
const combinedNew = [
|
|
1366
|
+
...newBuys.map((r) => ({ r, side: 'buy' })),
|
|
1367
|
+
...newShorts.map((r) => ({ r, side: 'short' })),
|
|
1368
|
+
];
|
|
1369
|
+
if (autoAdapt && combinedNew.length > 1) {
|
|
1370
|
+
combinedNew.sort((a, b) => Math.abs(b.r.expectedPct || 0) - Math.abs(a.r.expectedPct || 0));
|
|
942
1371
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1372
|
+
let remWeightSum = combinedNew.reduce((s, x) => s + Math.max(0.01, Math.abs(x.r.expectedPct || 0)), 0);
|
|
1373
|
+
|
|
1374
|
+
for (const { r, side } of combinedNew) {
|
|
1375
|
+
const wCur = Math.max(0.01, Math.abs(r.expectedPct || 0));
|
|
1376
|
+
const propShare = autoAdapt && remWeightSum > 0 ? availRub * (wCur / remWeightSum) : availRub / Math.max(1, remNew);
|
|
1377
|
+
remWeightSum = Math.max(0, remWeightSum - wCur);
|
|
946
1378
|
remNew--;
|
|
947
|
-
const
|
|
948
|
-
const
|
|
1379
|
+
const rawSum = (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r);
|
|
1380
|
+
const sum = Math.min(rawSum, propShare, availRub);
|
|
1381
|
+
const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
|
|
1382
|
+
const ord = calcLotOrder(r.symbol, sum, refPx, side, acc.limitOffsetPct, specs);
|
|
949
1383
|
if (ord) {
|
|
950
1384
|
availRub = Math.max(0, availRub - ord.amountRub);
|
|
951
1385
|
orders.push({
|
|
952
1386
|
...ord,
|
|
953
1387
|
expectedPct: r.expectedPct,
|
|
954
|
-
reason:
|
|
1388
|
+
reason: side === 'buy'
|
|
1389
|
+
? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
|
|
1390
|
+
: `Продажа без покупки: ожидание ${r.expectedPct}% за ${params.modelHorizon} ч`,
|
|
955
1391
|
});
|
|
956
1392
|
}
|
|
957
1393
|
}
|
|
@@ -960,9 +1396,13 @@ export async function prepareBrokerOrders(id) {
|
|
|
960
1396
|
let planThrottleNote = '';
|
|
961
1397
|
if (plan.id === 'investor') {
|
|
962
1398
|
const shInfo = rawShortCands.length ? ` (пропущены сигналы на шорт: ${rawShortCands.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
963
|
-
|
|
1399
|
+
const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
|
|
1400
|
+
planThrottleNote = `Тариф «Инвестор» (целевой потолок до 60% годовых, 1 счёт со стартовым лимитом до 1 млн ₽${reinvNote}): активен режим «Только в рост (без шорта)»${shInfo}. Для включения заработка на падении акций (шорты, до 120% годовых и до 5 счетов) перейдите на тариф «Профи».`;
|
|
964
1401
|
} else if (plan.id === 'pro') {
|
|
965
|
-
|
|
1402
|
+
const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
|
|
1403
|
+
planThrottleNote = `Тариф «Профи» (целевой потолок до 120% годовых, до 5 счетов со стартовым лимитом до 1 млн ₽/счёт${reinvNote}): активны покупки и шорты. Для расширения потолка доходности до 240% годовых и индивидуальных лимитов подключите тариф «VIP».`;
|
|
1404
|
+
} else if (plan.id === 'vip') {
|
|
1405
|
+
planThrottleNote = `Тариф «VIP» (целевой потолок до 240% годовых, лимиты по согласованию): активны покупки и шорты с повышенной концентрацией слота.`;
|
|
966
1406
|
}
|
|
967
1407
|
if (!orders.length) {
|
|
968
1408
|
if ((newBuys.length || newShorts.length) && availRub < 1500) {
|
|
@@ -971,12 +1411,13 @@ export async function prepareBrokerOrders(id) {
|
|
|
971
1411
|
diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
|
|
972
1412
|
} else if (!newBuys.length && !planAllowsShorts && rawShortCands.length > 0) {
|
|
973
1413
|
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
974
|
-
diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до
|
|
1414
|
+
diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 60% годовых). Чтобы зарабатывать и на падении акций (до 120% годовых), перейдите на тариф «Профи» или «VIP».`;
|
|
975
1415
|
} else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
|
|
976
1416
|
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
977
1417
|
diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
|
|
978
1418
|
} else if (!newBuys.length && !newShorts.length) {
|
|
979
|
-
|
|
1419
|
+
const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
1420
|
+
diagNote = `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
|
|
980
1421
|
}
|
|
981
1422
|
}
|
|
982
1423
|
|
|
@@ -1004,6 +1445,7 @@ export async function prepareBrokerOrders(id) {
|
|
|
1004
1445
|
}
|
|
1005
1446
|
const st = brokersState();
|
|
1006
1447
|
hub.emit({ type: 'brokers:update', state: st });
|
|
1448
|
+
hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
|
|
1007
1449
|
return st;
|
|
1008
1450
|
}
|
|
1009
1451
|
|
|
@@ -1043,6 +1485,25 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1043
1485
|
const isBuy = ord.action === 'buy' || ord.action === 'cover';
|
|
1044
1486
|
let lotsToOrder = Number(ord.lots) || 0;
|
|
1045
1487
|
|
|
1488
|
+
// Перед выставлением новой лимитной заявки автоматически снимаем старые неисполненные заявки по этой же бумаге и направлению,
|
|
1489
|
+
// чтобы в стакане никогда не копились дублирующиеся заявки и не блокировали рублёвый лимит счёта!
|
|
1490
|
+
const rActive = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
|
|
1491
|
+
method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId }), timeoutMs: 8000,
|
|
1492
|
+
}).catch(() => null);
|
|
1493
|
+
if (rActive?.ok) {
|
|
1494
|
+
const jActive = await rActive.json().catch(() => null);
|
|
1495
|
+
const wantDir = isBuy ? 'BUY' : 'SELL';
|
|
1496
|
+
for (const ao of jActive?.orders || []) {
|
|
1497
|
+
const sameInst = ao.instrumentUid === instrumentId || ao.figi === instrumentId || ao.ticker === ord.symbol;
|
|
1498
|
+
const sameDir = String(ao.direction || '').includes(wantDir);
|
|
1499
|
+
if (sameInst && sameDir && ao.orderId) {
|
|
1500
|
+
await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
1501
|
+
method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId, orderId: ao.orderId }), timeoutMs: 8000,
|
|
1502
|
+
}).catch(() => null);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1046
1507
|
// Проверяем реальный доступный лимит лотов у брокера через GetMaxLots перед отправкой заявки
|
|
1047
1508
|
if (!acc.sandbox && lotsToOrder > 0) {
|
|
1048
1509
|
const rMax = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetMaxLots`, {
|
|
@@ -1059,7 +1520,7 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1059
1520
|
if (Number.isFinite(maxLots)) {
|
|
1060
1521
|
if (maxLots < 1) {
|
|
1061
1522
|
if (ord.action === 'short') {
|
|
1062
|
-
throw new Error(
|
|
1523
|
+
throw new Error(`Короткая продажа (шорт) по бумаге ${ord.symbol} недоступна у брокера (бумага не входит в список маржинальных для шорта или исчерпан лимит займа)`);
|
|
1063
1524
|
}
|
|
1064
1525
|
throw new Error(`У брокера недостаточно доступного лимита для ${isBuy ? 'покупки' : 'продажи'} ${ord.symbol} (доступно 0 лотов)`);
|
|
1065
1526
|
}
|
|
@@ -1153,6 +1614,97 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1153
1614
|
const j = await r.json();
|
|
1154
1615
|
return { ok: true, orderId: j.orderId || body.clientOrderId };
|
|
1155
1616
|
}
|
|
1617
|
+
if (acc.broker === 'cifra') {
|
|
1618
|
+
const [apiKey, secKey] = String(acc.token || '').split(':').map((s) => s.trim());
|
|
1619
|
+
if (!apiKey || !secKey) throw new Error('Для Цифра брокер укажите ключи в формате PublicKey:SecretKey');
|
|
1620
|
+
const qObj = {
|
|
1621
|
+
cmd: 'putTradeOrder',
|
|
1622
|
+
params: {
|
|
1623
|
+
instr_name: `${ord.symbol}.MOEX`,
|
|
1624
|
+
action_id: ord.action === 'buy' || ord.action === 'cover' ? 1 : 3,
|
|
1625
|
+
order_type_id: 2,
|
|
1626
|
+
curr: 'RUR',
|
|
1627
|
+
limit_price: ord.limitPrice,
|
|
1628
|
+
qty: ord.shares || ord.lots,
|
|
1629
|
+
},
|
|
1630
|
+
};
|
|
1631
|
+
const qStr = `q=${JSON.stringify(qObj)}`;
|
|
1632
|
+
const sig = crypto.createHmac('sha256', secKey).update(qStr).digest('hex');
|
|
1633
|
+
const r = await fetch('https://tradernet.ru/api/v2/cmd/putTradeOrder', {
|
|
1634
|
+
method: 'POST',
|
|
1635
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-NtApi-PublicKey': apiKey, 'X-NtApi-Sig': sig },
|
|
1636
|
+
body: qStr,
|
|
1637
|
+
signal: AbortSignal.timeout(15000),
|
|
1638
|
+
});
|
|
1639
|
+
if (!r.ok) throw new Error(`Tradernet PostOrder HTTP ${r.status}`);
|
|
1640
|
+
const j = await r.json();
|
|
1641
|
+
return { ok: true, orderId: String(j.order_id || j.result?.order_id || 'ok') };
|
|
1642
|
+
}
|
|
1643
|
+
if (acc.broker === 'iticapital') {
|
|
1644
|
+
const oauth = acc.sandbox ? 'https://oauthdev.iticapital.ru' : 'https://oauth.iticapital.ru';
|
|
1645
|
+
const api = acc.sandbox ? 'https://apidev.iticapital.ru' : 'https://api.iticapital.ru';
|
|
1646
|
+
const rTok = await fetch(`${oauth}/refresh?token=${encodeURIComponent(acc.token)}`, { method: 'POST', signal: AbortSignal.timeout(15000) });
|
|
1647
|
+
if (!rTok.ok) throw new Error(`ITI Capital OAuth HTTP ${rTok.status}`);
|
|
1648
|
+
const { AccessToken } = await rTok.json();
|
|
1649
|
+
const body = {
|
|
1650
|
+
side: ord.action === 'buy' || ord.action === 'cover' ? 'buy' : 'sell',
|
|
1651
|
+
type: 'limit',
|
|
1652
|
+
quantity: ord.lots,
|
|
1653
|
+
price: ord.limitPrice,
|
|
1654
|
+
instrument: { symbol: ord.symbol, exchange: 'MOEX', instrumentGroup: 'TQBR' },
|
|
1655
|
+
user: { portfolio: acc.accountId },
|
|
1656
|
+
};
|
|
1657
|
+
const r = await fetch(`${api}/commandapi/warptrans/TRADE/v2/client/orders/actions/limit`, {
|
|
1658
|
+
method: 'POST',
|
|
1659
|
+
headers: { Authorization: `Bearer ${AccessToken}`, 'Content-Type': 'application/json', 'X-ALOR-REQID': crypto.randomUUID() },
|
|
1660
|
+
body: JSON.stringify(body),
|
|
1661
|
+
signal: AbortSignal.timeout(15000),
|
|
1662
|
+
});
|
|
1663
|
+
if (!r.ok) throw new Error(`ITI Capital PostOrder HTTP ${r.status}`);
|
|
1664
|
+
const j = await r.json();
|
|
1665
|
+
return { ok: true, orderId: j.orderNumber || 'ok' };
|
|
1666
|
+
}
|
|
1667
|
+
if (['sber', 'vtb', 'alfa'].includes(acc.broker)) {
|
|
1668
|
+
const tokenStr = String(acc.token || '').trim();
|
|
1669
|
+
if (tokenStr.startsWith('http://') || tokenStr.startsWith('https://')) {
|
|
1670
|
+
const base = tokenStr.replace(/\/+$/, '');
|
|
1671
|
+
const r = await fetch(`${base}/orders`, {
|
|
1672
|
+
method: 'POST',
|
|
1673
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1674
|
+
body: JSON.stringify({ accountId: acc.accountId, ...ord }),
|
|
1675
|
+
signal: AbortSignal.timeout(12000),
|
|
1676
|
+
});
|
|
1677
|
+
if (!r.ok) throw new Error(`QUIK HTTP-шлюз PostOrder: HTTP ${r.status}`);
|
|
1678
|
+
const j = await r.json();
|
|
1679
|
+
return { ok: true, orderId: j.orderId || 'quik_ok' };
|
|
1680
|
+
}
|
|
1681
|
+
// Режим ручного подтверждения (СберИнвестиции / ВТБ / Альфа без QUIK): учитываем исполнение в портфеле платформы
|
|
1682
|
+
const prevPos = [...(acc.lastSync?.positions || [])];
|
|
1683
|
+
const isBuy = ord.action === 'buy' || ord.action === 'cover';
|
|
1684
|
+
const deltaQty = isBuy ? (ord.shares || ord.lots) : -(ord.shares || ord.lots);
|
|
1685
|
+
const deltaCash = isBuy ? -ord.amountRub : ord.amountRub;
|
|
1686
|
+
const pIdx = prevPos.findIndex((p) => p.symbol === ord.symbol);
|
|
1687
|
+
if (pIdx >= 0) {
|
|
1688
|
+
const cur = prevPos[pIdx];
|
|
1689
|
+
const nextQty = cur.qty + deltaQty;
|
|
1690
|
+
if (nextQty === 0) prevPos.splice(pIdx, 1);
|
|
1691
|
+
else prevPos[pIdx] = { ...cur, qty: nextQty, price: ord.limitPrice, valueRub: round(nextQty * ord.limitPrice, 2) };
|
|
1692
|
+
} else if (deltaQty !== 0) {
|
|
1693
|
+
prevPos.push({ symbol: ord.symbol, name: ord.symbol, instrumentType: 'share', qty: deltaQty, lots: ord.lots, avgPrice: ord.limitPrice, price: ord.limitPrice, valueRub: round(deltaQty * ord.limitPrice, 2), pnlRub: 0 });
|
|
1694
|
+
}
|
|
1695
|
+
const prevCash = acc.lastSync?.cashRub ?? (acc.maxCapitalRub || 100000);
|
|
1696
|
+
const nextCash = round(Math.max(0, prevCash + deltaCash), 2);
|
|
1697
|
+
const posSum = prevPos.reduce((s, p) => s + Math.abs(p.valueRub || 0), 0);
|
|
1698
|
+
acc.lastSync = {
|
|
1699
|
+
...(acc.lastSync || {}),
|
|
1700
|
+
at: Date.now(),
|
|
1701
|
+
ok: true,
|
|
1702
|
+
cashRub: nextCash,
|
|
1703
|
+
totalRub: round(nextCash + posSum, 2),
|
|
1704
|
+
positions: prevPos,
|
|
1705
|
+
};
|
|
1706
|
+
return { ok: true, orderId: 'учтено в портфеле' };
|
|
1707
|
+
}
|
|
1156
1708
|
throw new Error(`Неизвестный брокер: ${acc.broker}`);
|
|
1157
1709
|
}
|
|
1158
1710
|
|
|
@@ -1165,10 +1717,31 @@ export async function executeBrokerOrders(id) {
|
|
|
1165
1717
|
const batch = acc.pendingBatch;
|
|
1166
1718
|
if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
|
|
1167
1719
|
const results = [];
|
|
1168
|
-
|
|
1720
|
+
acc.entryTsBySymbol = acc.entryTsBySymbol || {};
|
|
1721
|
+
acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
|
|
1722
|
+
const execNowTs = Date.now();
|
|
1723
|
+
const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
|
|
1724
|
+
const entryOrders = batch.orders.filter((o) => o.action !== 'sell' && o.action !== 'cover');
|
|
1725
|
+
const orderedBatch = [...exitOrders, ...entryOrders];
|
|
1726
|
+
let hadExits = false;
|
|
1727
|
+
for (const ord of orderedBatch) {
|
|
1728
|
+
// Если перед открытием новых позиций мы отправили закрывающие ордера (sell/cover),
|
|
1729
|
+
// даём брокеру 1.2 секунды на исполнение в стакане и пересчёт свободного маржинального/рублёвого лимита перед вызовом GetMaxLots
|
|
1730
|
+
if (hadExits && (ord.action === 'buy' || ord.action === 'short')) {
|
|
1731
|
+
hadExits = false;
|
|
1732
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
1733
|
+
}
|
|
1169
1734
|
try {
|
|
1170
1735
|
const r = await sendOrderToBroker(acc, ord);
|
|
1171
1736
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
|
|
1737
|
+
if (ord.action === 'buy' || ord.action === 'short') {
|
|
1738
|
+
acc.entryTsBySymbol[ord.symbol] = execNowTs;
|
|
1739
|
+
delete acc.exitCooldownBySymbol[ord.symbol];
|
|
1740
|
+
} else if (ord.action === 'sell' || ord.action === 'cover') {
|
|
1741
|
+
hadExits = true;
|
|
1742
|
+
delete acc.entryTsBySymbol[ord.symbol];
|
|
1743
|
+
acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
|
|
1744
|
+
}
|
|
1172
1745
|
} catch (e) {
|
|
1173
1746
|
results.push({ symbol: ord.symbol, ok: false, error: e.message });
|
|
1174
1747
|
}
|
|
@@ -1177,11 +1750,45 @@ export async function executeBrokerOrders(id) {
|
|
|
1177
1750
|
batch.results = results;
|
|
1178
1751
|
batch.executedAt = Date.now();
|
|
1179
1752
|
saveRawAccounts(list);
|
|
1753
|
+
await syncBrokerAccount(id).catch(() => {});
|
|
1180
1754
|
const st = brokersState();
|
|
1181
1755
|
hub.emit({ type: 'brokers:update', state: st });
|
|
1182
1756
|
return st;
|
|
1183
1757
|
}
|
|
1184
1758
|
|
|
1759
|
+
export async function cancelBrokerOrder(id, orderId = null) {
|
|
1760
|
+
const list = loadRawAccounts();
|
|
1761
|
+
const acc = list.find((x) => x.id === id);
|
|
1762
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
1763
|
+
if (!acc.token) throw new Error('Не задан токен API брокера');
|
|
1764
|
+
if (acc.broker === 'tbank') {
|
|
1765
|
+
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
1766
|
+
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
1767
|
+
const accountId = acc.accountId || acc.lastSync?.accountId;
|
|
1768
|
+
if (!accountId) throw new Error('Не определён ID счёта');
|
|
1769
|
+
if (orderId && orderId !== 'all') {
|
|
1770
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
1771
|
+
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 10000,
|
|
1772
|
+
});
|
|
1773
|
+
if (!r.ok) throw new Error(`T-Invest CancelOrder: HTTP ${r.status}`);
|
|
1774
|
+
} else {
|
|
1775
|
+
const rOrd = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
|
|
1776
|
+
method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
|
|
1777
|
+
});
|
|
1778
|
+
if (rOrd.ok) {
|
|
1779
|
+
const jOrd = await rOrd.json();
|
|
1780
|
+
await Promise.all((jOrd.orders || []).map((o) => (
|
|
1781
|
+
o.orderId ? brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
1782
|
+
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(o.orderId) }), timeoutMs: 10000,
|
|
1783
|
+
}).catch(() => null) : null
|
|
1784
|
+
)));
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
return syncBrokerAccount(id);
|
|
1788
|
+
}
|
|
1789
|
+
throw new Error('Отмена активных заявок из интерфейса сейчас поддерживается для Т-Банка (T-Invest API)');
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1185
1792
|
export function rejectBrokerOrders(id) {
|
|
1186
1793
|
const list = loadRawAccounts();
|
|
1187
1794
|
const acc = list.find((x) => x.id === id);
|
|
@@ -1348,13 +1955,12 @@ async function tickBrokerSchedule() {
|
|
|
1348
1955
|
if ((plan.maxBrokerAccounts ?? 0) < 1) return;
|
|
1349
1956
|
for (const acc of list) {
|
|
1350
1957
|
if (acc.autoSchedule === false || acc.haltedReason) continue;
|
|
1351
|
-
const
|
|
1352
|
-
const params = normalizeParams(baseRun?.params || getSettings());
|
|
1958
|
+
const params = normalizeParams(getSettings());
|
|
1353
1959
|
const autoAdapt = params.modelAutoAdapt !== false;
|
|
1354
1960
|
const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
|
|
1355
1961
|
const H = barsFromHours(effHorizon);
|
|
1356
|
-
// Внутричасовой перехват H/5 (каждые 30 мин) активен в
|
|
1357
|
-
const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
1962
|
+
// Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
|
|
1963
|
+
const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
1358
1964
|
if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
|
|
1359
1965
|
acc.lastScheduledBarTs = latest;
|
|
1360
1966
|
saveRawAccounts(list);
|
|
@@ -1419,6 +2025,11 @@ async function tickBrokerAutoRefresh() {
|
|
|
1419
2025
|
}
|
|
1420
2026
|
|
|
1421
2027
|
export function startBrokerScheduler() {
|
|
2028
|
+
ensureBoardSpecs().catch(() => {});
|
|
2029
|
+
// В фоне после старта сервера сразу прогреваем прогноз, чтобы при открытии вкладки «Прогноз сейчас» он уже был в памяти
|
|
2030
|
+
setTimeout(() => {
|
|
2031
|
+
forecastNow(getSettings()).catch(() => {});
|
|
2032
|
+
}, 800);
|
|
1422
2033
|
setTimeout(resumeBrokerAccountsOnBoot, 2500);
|
|
1423
2034
|
const timer = setInterval(tickBrokerSchedule, 60000);
|
|
1424
2035
|
const syncTimer = setInterval(tickBrokerAutoRefresh, 120000);
|