neuroalpha 0.3.15 → 0.3.17
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 +79 -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 +4 -3
- 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 +96 -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 +464 -930
- package/public/index.html +148 -223
- package/public/landing.html +19 -33
- package/public/legal.html +3 -4
- package/public/styles.css +6 -2
- package/server/api/routes.js +10 -3
- package/server/auth/index.js +53 -34
- package/server/brokers/index.js +434 -104
- 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 +19 -19
- package/server/strategist/index.js +423 -484
- package/server/strategist/tools.js +486 -303
package/server/brokers/index.js
CHANGED
|
@@ -74,7 +74,7 @@ export const BROKER_META = [
|
|
|
74
74
|
const SPECS_URL = 'https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=securities,marketdata';
|
|
75
75
|
export async function ensureBoardSpecs() {
|
|
76
76
|
const cached = kvGet('moexSpecs');
|
|
77
|
-
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;
|
|
78
78
|
try {
|
|
79
79
|
const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
|
|
80
80
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
@@ -95,9 +95,10 @@ export async function ensureBoardSpecs() {
|
|
|
95
95
|
const minStep = Number(row[is('MINSTEP')]) > 0 ? Number(row[is('MINSTEP')]) : 0.01;
|
|
96
96
|
const decimals = Math.max(0, Number(row[is('DECIMALS')]) || 0);
|
|
97
97
|
const prev = Number(row[is('PREVPRICE')] || row[is('PREVADMITTEDQUOTE')] || 0);
|
|
98
|
-
|
|
98
|
+
const issueSize = Number(row[is('ISSUESIZE')] || 0);
|
|
99
|
+
specs[sym] = { lotSize, minStep, decimals, issueSize, lastPrice: prices.get(sym) || prev || 0 };
|
|
99
100
|
}
|
|
100
|
-
kvSet('moexSpecs', { at: Date.now(), specs });
|
|
101
|
+
kvSet('moexSpecs', { v: 2, at: Date.now(), specs });
|
|
101
102
|
return specs;
|
|
102
103
|
} catch {
|
|
103
104
|
return cached?.specs || {};
|
|
@@ -144,13 +145,24 @@ function saveRawAccounts(list) {
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
function publicAccount(a) {
|
|
147
|
-
const { token, ...rest } = a;
|
|
148
148
|
let baseRunLabel = null;
|
|
149
149
|
if (a.baseRunId) {
|
|
150
150
|
const r = getRun(Number(a.baseRunId));
|
|
151
151
|
if (r) baseRunLabel = `№${r.id}${r.params?._title ? ` (${r.params._title})` : ''}`;
|
|
152
152
|
}
|
|
153
|
-
|
|
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 };
|
|
154
166
|
}
|
|
155
167
|
|
|
156
168
|
export function brokersState() {
|
|
@@ -204,9 +216,12 @@ export function saveBrokerAccount(input = {}) {
|
|
|
204
216
|
mode,
|
|
205
217
|
legacyMode,
|
|
206
218
|
baselineQty: acctChanged ? null : (prev.baselineQty || null),
|
|
219
|
+
initialTotalRub: acctChanged ? 0 : (prev.initialTotalRub || 0),
|
|
207
220
|
tbankAccountName: acctChanged ? '' : (prev.tbankAccountName || ''),
|
|
208
221
|
equityHistory: acctChanged ? [] : (prev.equityHistory || []),
|
|
209
222
|
lastHistory: acctChanged ? null : (prev.lastHistory || null),
|
|
223
|
+
entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
|
|
224
|
+
exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
|
|
210
225
|
baseRunId: nextBaseRunId,
|
|
211
226
|
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
212
227
|
maxCapitalRub,
|
|
@@ -363,11 +378,16 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
|
|
|
363
378
|
return cache;
|
|
364
379
|
}
|
|
365
380
|
|
|
366
|
-
// Загружает и кэширует на 12 часов справочник доступности
|
|
381
|
+
// Загружает и кэширует на 12 часов справочник доступности шортов, UID и точных параметров лота/шага цены по тикерам в Т-Банке (TQBR + SPBRU для SPBE)
|
|
367
382
|
async function ensureTbankShortableMap(acc) {
|
|
368
383
|
if (!acc || acc.broker !== 'tbank' || !acc.token) return null;
|
|
369
384
|
const cached = kvGet('tbankShortableMap');
|
|
370
|
-
if (cached?.map && Date.now() - cached.at < 12 * 3600000)
|
|
385
|
+
if (cached?.v === 4 && 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
|
+
}
|
|
371
391
|
try {
|
|
372
392
|
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
373
393
|
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
@@ -380,13 +400,29 @@ async function ensureTbankShortableMap(acc) {
|
|
|
380
400
|
if (!r.ok) return cached?.map || null;
|
|
381
401
|
const j = await r.json();
|
|
382
402
|
const map = {};
|
|
403
|
+
const uidMap = { ...(cached?.uidMap || {}) };
|
|
404
|
+
const specMap = { ...(cached?.specMap || {}) };
|
|
383
405
|
for (const inst of j.instruments || []) {
|
|
384
|
-
if (inst.ticker
|
|
406
|
+
if (!inst.ticker || inst.apiTradeAvailableFlag === false) continue;
|
|
407
|
+
if (inst.classCode === 'TQBR' || (inst.classCode === 'SPBRU' && !(inst.ticker in map))) {
|
|
385
408
|
map[inst.ticker] = Boolean(inst.shortEnabledFlag);
|
|
386
|
-
|
|
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 decimals = Math.max(0, Math.min(6, Math.ceil(-Math.log10(minStep) - 1e-9)));
|
|
417
|
+
specMap[inst.ticker] = {
|
|
418
|
+
lotSize: Math.max(1, Number(inst.lot) || 1),
|
|
419
|
+
minStep,
|
|
420
|
+
decimals,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
387
423
|
}
|
|
388
424
|
}
|
|
389
|
-
kvSet('tbankShortableMap', { at: Date.now(), map });
|
|
425
|
+
kvSet('tbankShortableMap', { v: 4, at: Date.now(), map, uidMap, specMap });
|
|
390
426
|
return map;
|
|
391
427
|
} catch {
|
|
392
428
|
return cached?.map || null;
|
|
@@ -395,16 +431,39 @@ async function ensureTbankShortableMap(acc) {
|
|
|
395
431
|
|
|
396
432
|
async function resolveTbankInstrumentId(base, headers, symbol) {
|
|
397
433
|
if (tbankUidCache.has(symbol)) return tbankUidCache.get(symbol);
|
|
434
|
+
const savedUid = kvGet('tbankShortableMap')?.uidMap?.[symbol];
|
|
435
|
+
if (savedUid) {
|
|
436
|
+
tbankUidCache.set(symbol, savedUid);
|
|
437
|
+
return savedUid;
|
|
438
|
+
}
|
|
439
|
+
for (const classCode of ['TQBR', 'SPBRU']) {
|
|
440
|
+
try {
|
|
441
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/ShareBy`, {
|
|
442
|
+
method: 'POST',
|
|
443
|
+
headers,
|
|
444
|
+
body: JSON.stringify({ idType: 'INSTRUMENT_ID_TYPE_TICKER', classCode, id: symbol }),
|
|
445
|
+
timeoutMs: 10000,
|
|
446
|
+
});
|
|
447
|
+
if (r.ok) {
|
|
448
|
+
const j = await r.json();
|
|
449
|
+
if (j.instrument?.apiTradeAvailableFlag !== false) {
|
|
450
|
+
const uid = j.instrument?.uid || j.instrument?.figi;
|
|
451
|
+
if (uid) { tbankUidCache.set(symbol, uid); return uid; }
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
} catch { /* next classCode */ }
|
|
455
|
+
}
|
|
398
456
|
try {
|
|
399
|
-
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/
|
|
457
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.InstrumentsService/FindInstrument`, {
|
|
400
458
|
method: 'POST',
|
|
401
459
|
headers,
|
|
402
|
-
body: JSON.stringify({
|
|
460
|
+
body: JSON.stringify({ query: symbol }),
|
|
403
461
|
timeoutMs: 10000,
|
|
404
462
|
});
|
|
405
463
|
if (r.ok) {
|
|
406
464
|
const j = await r.json();
|
|
407
|
-
const
|
|
465
|
+
const hit = (j.instruments || []).find((x) => x.ticker === symbol && x.apiTradeAvailableFlag === true && x.instrumentKind === 'INSTRUMENT_TYPE_SHARE');
|
|
466
|
+
const uid = hit?.uid || hit?.figi;
|
|
408
467
|
if (uid) { tbankUidCache.set(symbol, uid); return uid; }
|
|
409
468
|
}
|
|
410
469
|
} catch { /* fallback */ }
|
|
@@ -475,9 +534,43 @@ async function syncTbank(acc) {
|
|
|
475
534
|
marginEnabled = !mTxt.includes('30051') && !/margin.*disabled/i.test(mTxt) ? false : false;
|
|
476
535
|
}
|
|
477
536
|
const p = await rPort.json();
|
|
537
|
+
const jOrd = rOrd?.ok ? await rOrd.json().catch(() => null) : null;
|
|
538
|
+
let rawOrders = jOrd?.orders || [];
|
|
539
|
+
|
|
540
|
+
// Автоматическая уборка стакана (если счёт не в режиме «Только чтение»):
|
|
541
|
+
// 1) если по одному инструменту и направлению висит несколько дублирующих заявок — оставляем только самую свежую, остальные снимаем;
|
|
542
|
+
// 2) если лимитная заявка висит неисполненной дольше 12 минут (прошла свеча, цена ушла) — автоматически снимаем её и разблокируем рубли!
|
|
543
|
+
if (acc.mode !== 'readonly' && rawOrders.length > 0) {
|
|
544
|
+
const seenInstDir = new Set();
|
|
545
|
+
const toCancelIds = [];
|
|
546
|
+
const keptOrders = [];
|
|
547
|
+
const sortedByDate = [...rawOrders].sort((a, b) => Date.parse(b.orderDate || 0) - Date.parse(a.orderDate || 0));
|
|
548
|
+
const nowMs = Date.now();
|
|
549
|
+
for (const o of sortedByDate) {
|
|
550
|
+
const k = `${o.instrumentUid || o.figi || o.ticker}:${o.direction}`;
|
|
551
|
+
const ageMs = o.orderDate ? (nowMs - Date.parse(o.orderDate)) : 0;
|
|
552
|
+
if (seenInstDir.has(k) || ageMs > 12 * 60000) {
|
|
553
|
+
if (o.orderId) toCancelIds.push(o.orderId);
|
|
554
|
+
} else {
|
|
555
|
+
seenInstDir.add(k);
|
|
556
|
+
keptOrders.push(o);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (toCancelIds.length > 0) {
|
|
560
|
+
await Promise.all(toCancelIds.map((orderId) => (
|
|
561
|
+
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
562
|
+
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 8000,
|
|
563
|
+
}).catch(() => null)
|
|
564
|
+
)));
|
|
565
|
+
rawOrders = keptOrders;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
478
568
|
const totalRub = round(qVal(p.totalAmountPortfolio), 2);
|
|
479
569
|
const rawPositions = p.positions || [];
|
|
480
|
-
const lookupIds =
|
|
570
|
+
const lookupIds = [
|
|
571
|
+
...rawPositions.flatMap((pos) => [pos.ticker, pos.figi, pos.instrumentUid]),
|
|
572
|
+
...rawOrders.flatMap((o) => [o.ticker, o.figi, o.instrumentUid]),
|
|
573
|
+
].filter(Boolean);
|
|
481
574
|
const metaMap = await resolveTbankMetaMap(base, headers, lookupIds);
|
|
482
575
|
|
|
483
576
|
const positions = [];
|
|
@@ -491,7 +584,7 @@ async function syncTbank(acc) {
|
|
|
491
584
|
const qty = qVal(pos.quantity);
|
|
492
585
|
const px = qVal(pos.currentPrice);
|
|
493
586
|
if (isRubCashPos(pos, rawTicker)) {
|
|
494
|
-
cashRub += qty;
|
|
587
|
+
cashRub += Math.max(0, qty);
|
|
495
588
|
continue;
|
|
496
589
|
}
|
|
497
590
|
if (qty === 0) continue;
|
|
@@ -527,23 +620,28 @@ async function syncTbank(acc) {
|
|
|
527
620
|
}
|
|
528
621
|
|
|
529
622
|
const activeOrders = [];
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
623
|
+
for (const o of rawOrders) {
|
|
624
|
+
const m = metaMap[o.ticker] || metaMap[o.figi] || metaMap[o.instrumentUid] || {};
|
|
625
|
+
const sym = m.ticker || o.ticker || o.figi || '—';
|
|
626
|
+
const lotsReq = Number(o.lotsRequested || 0);
|
|
627
|
+
const lotsExec = Number(o.lotsExecuted || 0);
|
|
628
|
+
const remLots = Math.max(0, lotsReq - lotsExec);
|
|
629
|
+
const totalOrdRub = qVal(o.initialOrderPrice);
|
|
630
|
+
const remRub = lotsReq > 0 ? round(totalOrdRub * (remLots / lotsReq), 2) : round(totalOrdRub, 2);
|
|
631
|
+
activeOrders.push({
|
|
632
|
+
orderId: o.orderId,
|
|
633
|
+
symbol: sym,
|
|
634
|
+
name: m.name || '',
|
|
635
|
+
direction: String(o.direction || '').includes('BUY') ? 'buy' : 'sell',
|
|
636
|
+
lotsRequested: lotsReq,
|
|
637
|
+
lotsExecuted: lotsExec,
|
|
638
|
+
price: round(qVal(o.initialSecurityPrice), 4),
|
|
639
|
+
amountRub: remRub,
|
|
640
|
+
orderDate: o.orderDate || null,
|
|
641
|
+
});
|
|
546
642
|
}
|
|
643
|
+
const activeBuyOrdersRub = activeOrders.filter((o) => o.direction === 'buy').reduce((s, o) => s + (o.amountRub || 0), 0);
|
|
644
|
+
cashRub = Math.max(0, cashRub - activeBuyOrdersRub);
|
|
547
645
|
|
|
548
646
|
const breakdown = {
|
|
549
647
|
sharesRub: round(qVal(p.totalAmountShares), 0),
|
|
@@ -863,13 +961,43 @@ export async function syncBrokerAccount(id) {
|
|
|
863
961
|
else if (isManualCapable) data = await syncQuikOrManual(acc);
|
|
864
962
|
else throw new Error(`Неизвестный брокер: ${acc.broker}`);
|
|
865
963
|
if (data.accountId && !acc.accountId) acc.accountId = data.accountId;
|
|
964
|
+
const specs = await ensureBoardSpecs();
|
|
965
|
+
if (Array.isArray(data.positions)) {
|
|
966
|
+
data.positions = data.positions.map((p) => {
|
|
967
|
+
const issueSize = specs[p.symbol]?.issueSize || 0;
|
|
968
|
+
const ownershipPct = issueSize > 0 && p.qty > 0 ? (p.qty / issueSize) * 100 : null;
|
|
969
|
+
return { ...p, issueSize, ownershipPct };
|
|
970
|
+
});
|
|
971
|
+
}
|
|
866
972
|
if (data.accountName) acc.tbankAccountName = data.accountName;
|
|
867
973
|
if ((!acc.name || acc.name === 'Т-Банк (T-Invest API)') && data.accountName) {
|
|
868
974
|
acc.name = `Т-Банк · ${data.accountName}`;
|
|
869
975
|
}
|
|
870
|
-
// При первой синхронизации
|
|
976
|
+
// При первой синхронизации запоминаем стартовый портфель и стартовую базу капитала;
|
|
977
|
+
// а при продаже старых бумаг (в режиме «Плавный переход» или вручную) автоматически уменьшаем baselineQty до фактического остатка,
|
|
978
|
+
// чтобы новые покупки стратегии по тем же тикерам никогда не путались со старым портфелем
|
|
871
979
|
if (!acc.baselineQty || (!Object.keys(acc.baselineQty).length && data.positions?.length)) {
|
|
872
980
|
acc.baselineQty = Object.fromEntries((data.positions || []).filter((p) => p.qty !== 0).map((p) => [p.symbol, p.qty]));
|
|
981
|
+
} else if (acc.baselineQty && Array.isArray(data.positions)) {
|
|
982
|
+
const curPosMap = new Map(data.positions.map((p) => [p.symbol, p.qty]));
|
|
983
|
+
for (const [sym, bq] of Object.entries(acc.baselineQty)) {
|
|
984
|
+
const curQ = Number(curPosMap.get(sym) || 0);
|
|
985
|
+
if (bq > 0 && curQ < bq) {
|
|
986
|
+
if (curQ <= 0) delete acc.baselineQty[sym];
|
|
987
|
+
else acc.baselineQty[sym] = curQ;
|
|
988
|
+
} else if (bq < 0 && curQ > bq) {
|
|
989
|
+
if (curQ >= 0) delete acc.baselineQty[sym];
|
|
990
|
+
else acc.baselineQty[sym] = curQ;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (!acc.initialTotalRub && data.totalRub > 0) {
|
|
995
|
+
acc.initialTotalRub = round(data.totalRub, 2);
|
|
996
|
+
}
|
|
997
|
+
// С началом нового календарного дня автоматически сбрасываем вчерашний дневной стоп по просадке
|
|
998
|
+
if (acc.lastSync?.day && acc.lastSync.day !== today && acc.haltedReason && acc.haltedReason.startsWith('Сработал дневной стоп')) {
|
|
999
|
+
acc.haltedReason = null;
|
|
1000
|
+
acc.ddBreachCount = 0;
|
|
873
1001
|
}
|
|
874
1002
|
const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
|
|
875
1003
|
const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
|
|
@@ -877,7 +1005,13 @@ export async function syncBrokerAccount(id) {
|
|
|
877
1005
|
if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
|
|
878
1006
|
acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
|
|
879
1007
|
} else if (ddPct >= acc.maxDailyDrawdownPct) {
|
|
880
|
-
|
|
1008
|
+
// Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
|
|
1009
|
+
acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
|
|
1010
|
+
if (acc.ddBreachCount >= 2) {
|
|
1011
|
+
acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
|
|
1012
|
+
}
|
|
1013
|
+
} else {
|
|
1014
|
+
acc.ddBreachCount = 0;
|
|
881
1015
|
}
|
|
882
1016
|
// История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
|
|
883
1017
|
const hist = Array.isArray(acc.equityHistory) ? acc.equityHistory : [];
|
|
@@ -887,6 +1021,17 @@ export async function syncBrokerAccount(id) {
|
|
|
887
1021
|
if (hist.length > 500) hist.splice(0, hist.length - 500);
|
|
888
1022
|
}
|
|
889
1023
|
acc.equityHistory = hist;
|
|
1024
|
+
const entryMap = { ...(acc.entryTsBySymbol || {}) };
|
|
1025
|
+
const curSymbols = new Set((data.positions || []).filter((p) => p.qty !== 0).map((p) => p.symbol));
|
|
1026
|
+
for (const s of curSymbols) {
|
|
1027
|
+
// Если позиция обнаружена при синхронизации без записи в executeBrokerOrders (например, перешла со вчерашнего дня),
|
|
1028
|
+
// помечаем её как зрелую (120 мин), чтобы для неё сразу действовал штатный порог выхода 0.95, а не стартовый 65-минутный буфер
|
|
1029
|
+
if (!entryMap[s]) entryMap[s] = nowTs - 120 * 60000;
|
|
1030
|
+
}
|
|
1031
|
+
for (const s of Object.keys(entryMap)) {
|
|
1032
|
+
if (!curSymbols.has(s)) delete entryMap[s];
|
|
1033
|
+
}
|
|
1034
|
+
acc.entryTsBySymbol = entryMap;
|
|
890
1035
|
acc.lastSync = { at: nowTs, ok: true, error: null, day: today, dayStartRub, dayDrawdownPct: ddPct, ...data };
|
|
891
1036
|
} catch (e) {
|
|
892
1037
|
acc.lastSync = { ...(acc.lastSync || {}), at: Date.now(), ok: false, error: e.message };
|
|
@@ -897,6 +1042,7 @@ export async function syncBrokerAccount(id) {
|
|
|
897
1042
|
saveRawAccounts(list);
|
|
898
1043
|
const st = brokersState();
|
|
899
1044
|
hub.emit({ type: 'brokers:update', state: st });
|
|
1045
|
+
hub.emit({ type: 'trading:pulse', kind: 'broker_sync', ts: Date.now(), accountId: acc.id, name: acc.name });
|
|
900
1046
|
return st;
|
|
901
1047
|
}
|
|
902
1048
|
|
|
@@ -935,52 +1081,106 @@ function sessionEdgeWarning(now = Date.now()) {
|
|
|
935
1081
|
|
|
936
1082
|
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
937
1083
|
export async function prepareBrokerOrders(id) {
|
|
938
|
-
|
|
939
|
-
|
|
1084
|
+
let list = loadRawAccounts();
|
|
1085
|
+
let acc = list.find((x) => x.id === id);
|
|
940
1086
|
if (!acc) throw new Error('Счёт не найден');
|
|
941
1087
|
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
942
1088
|
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
943
1089
|
|
|
944
1090
|
const plan = effectivePlan(currentUser());
|
|
945
1091
|
if ((plan.maxBrokerAccounts ?? 0) < 1) {
|
|
946
|
-
throw new Error(
|
|
1092
|
+
throw new Error('Для работы с брокерскими счетами оформите подписку «Инвестор» (3 900 ₽/мес), «Профи» (9 900 ₽/мес) или «VIP» в разделе «Подписка и уведомления».');
|
|
947
1093
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1094
|
+
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) {
|
|
1097
|
+
await cancelBrokerOrder(id, 'all').catch(() => {});
|
|
1098
|
+
list = loadRawAccounts();
|
|
1099
|
+
acc = list.find((x) => x.id === id) || acc;
|
|
1100
|
+
} else if (acc.token && (!acc.lastSync || Date.now() - (acc.lastSync.at || 0) > 45000)) {
|
|
1101
|
+
await syncBrokerAccount(id).catch(() => {});
|
|
1102
|
+
list = loadRawAccounts();
|
|
1103
|
+
acc = list.find((x) => x.id === id) || acc;
|
|
1104
|
+
}
|
|
1105
|
+
const params = normalizeParams(getSettings());
|
|
1106
|
+
const [fc, rawSpecs, shortableMap] = await Promise.all([
|
|
951
1107
|
forecastNow(params),
|
|
952
1108
|
ensureBoardSpecs(),
|
|
953
1109
|
ensureTbankShortableMap(acc),
|
|
954
1110
|
]);
|
|
1111
|
+
const specs = { ...rawSpecs };
|
|
1112
|
+
if (acc.broker === 'tbank') {
|
|
1113
|
+
const tbSpecs = kvGet('tbankShortableMap')?.specMap || {};
|
|
1114
|
+
for (const [sym, ts] of Object.entries(tbSpecs)) {
|
|
1115
|
+
specs[sym] = { ...(specs[sym] || { lastPrice: 0, issueSize: 0 }), ...ts };
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
955
1118
|
const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
|
|
956
1119
|
const effectiveMaxCap = Math.min(acc.maxCapitalRub, capCeiling);
|
|
957
|
-
|
|
958
|
-
|
|
1120
|
+
// Лимит тарифа (1 млн ₽ на Инвесторе/Профи) ограничивает СТАРТОВУЮ выделенную сумму на счёте,
|
|
1121
|
+
// а вся заработанная стратегией сверху прибыль автоматически реинвестируется в рабочий бюджет (сложный процент):
|
|
1122
|
+
const curTotalRub = acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : effectiveMaxCap;
|
|
1123
|
+
const initBaseRub = acc.initialTotalRub > 0 ? acc.initialTotalRub : (acc.equityHistory?.[0]?.totalRub > 0 ? acc.equityHistory[0].totalRub : curTotalRub);
|
|
1124
|
+
const reinvestedProfitRub = round(Math.max(0, curTotalRub - Math.max(initBaseRub, effectiveMaxCap)), 0);
|
|
1125
|
+
const allowedTotalCapRub = effectiveMaxCap + reinvestedProfitRub;
|
|
1126
|
+
const budget = round(Math.min(allowedTotalCapRub, curTotalRub), 0);
|
|
1127
|
+
const autoAdapt = params.modelAutoAdapt !== false;
|
|
1128
|
+
const rawTopK = Number(params.modelTopK ?? 2);
|
|
1129
|
+
const autoK = rawTopK === 0 || autoAdapt;
|
|
1130
|
+
const K = rawTopK > 0 ? rawTopK : 4;
|
|
1131
|
+
const filterBrokerDynamicTopK = (cands, needVal, maxCount) => {
|
|
1132
|
+
if (!cands.length || maxCount <= 0) return [];
|
|
1133
|
+
const lim = Math.min(cands.length, maxCount);
|
|
1134
|
+
if (!autoK || lim <= 1) return cands.slice(0, lim);
|
|
1135
|
+
const bestE = Math.abs(cands[0].expectedPct || 0);
|
|
1136
|
+
const out = [cands[0]];
|
|
1137
|
+
for (let m = 1; m < lim; m++) {
|
|
1138
|
+
const curE = Math.abs(cands[m].expectedPct || 0);
|
|
1139
|
+
const relMin = m === 1 ? 0.62 : 0.78;
|
|
1140
|
+
const absMin = m === 1 ? needVal * 1.15 : needVal * 1.45;
|
|
1141
|
+
if (curE >= bestE * relMin && curE >= absMin) out.push(cands[m]);
|
|
1142
|
+
else break;
|
|
1143
|
+
}
|
|
1144
|
+
return out;
|
|
1145
|
+
};
|
|
959
1146
|
// Автоматическая подстройка направления и мощности торговли под тариф и реальные данные счёта:
|
|
960
1147
|
const marginAllowed = acc.lastSync?.marginEnabled !== false;
|
|
961
1148
|
const planAllowsShorts = Boolean(plan.allowShorts);
|
|
962
1149
|
const vipBoost = Boolean(plan.allowVipBoost);
|
|
963
1150
|
const rawDir = params.modelDirection || 'long';
|
|
964
1151
|
const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
|
|
965
|
-
const autoAdapt = params.modelAutoAdapt !== false;
|
|
966
1152
|
const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
|
|
967
1153
|
const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
|
|
1154
|
+
const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.35, 3);
|
|
1155
|
+
const nowTs = Date.now();
|
|
1156
|
+
const entryTsMap = acc.entryTsBySymbol || {};
|
|
1157
|
+
const exitCooldownMap = acc.exitCooldownBySymbol || {};
|
|
1158
|
+
const mskDate = new Date(nowTs + MSK);
|
|
1159
|
+
const isEveningSession = (mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes()) >= 19 * 60;
|
|
1160
|
+
const eveningBoost = (autoAdapt && isEveningSession) ? 1.25 : 1.0;
|
|
1161
|
+
const minEntryEdgePct = round((fc.thresholdPct || 0) * eveningBoost, 3);
|
|
1162
|
+
const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
|
|
968
1163
|
const slots = dir === 'long' ? K : 2 * K;
|
|
969
1164
|
// Фильтр мощности слота по тарифу:
|
|
970
|
-
// • Инвестор (до
|
|
971
|
-
// • Профи (до
|
|
972
|
-
// • VIP (
|
|
1165
|
+
// • Инвестор (до 60% годовых): 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
|
|
1166
|
+
// • Профи (до 120% годовых): до 5 счетов по 1 млн ₽, покупки + шорты, базовый слот до 28% капитала
|
|
1167
|
+
// • VIP (до 240% годовых): счета и сумма по согласованию, покупки + шорты, слот до 48% капитала
|
|
1168
|
+
// • Хозяин (без ограничений): полная мощность ядра (>400% годовых, Conviction Sizing до 75% капитала)
|
|
1169
|
+
const isOwnerPlan = Boolean(plan.isOwner || plan.id === 'owner' || plan.maxAnnualReturnPct === 0);
|
|
973
1170
|
let slotRub;
|
|
974
|
-
if (
|
|
975
|
-
const maxSlotWeight =
|
|
1171
|
+
if (isOwnerPlan && autoAdapt) {
|
|
1172
|
+
const maxSlotWeight = Math.min(0.45, Math.max(1 / K, 1.8 / K));
|
|
1173
|
+
slotRub = budget * maxSlotWeight;
|
|
1174
|
+
} else if (vipBoost && autoAdapt) {
|
|
1175
|
+
const maxSlotWeight = Math.min(0.40, Math.max(1 / K, 1.4 / K));
|
|
976
1176
|
slotRub = budget * maxSlotWeight;
|
|
977
1177
|
} else if (plan.id === 'pro') {
|
|
978
|
-
slotRub = budget * Math.min(0.
|
|
1178
|
+
slotRub = budget * Math.min(0.28, 1.15 / Math.max(2, slots));
|
|
979
1179
|
} else {
|
|
980
|
-
slotRub = budget * Math.min(0.
|
|
1180
|
+
slotRub = budget * Math.min(0.24, 1.05 / Math.max(3, K + 1));
|
|
981
1181
|
}
|
|
982
|
-
//
|
|
983
|
-
if (!
|
|
1182
|
+
// Мягкий дроссель при опережении годового потолка доходности тарифа (60% Инвестор, 120% Профи, 240% VIP; у Хозяина = 0 — без потолка)
|
|
1183
|
+
if (!isOwnerPlan && plan.maxAnnualReturnPct > 0 && Array.isArray(acc.equityHistory) && acc.equityHistory.length >= 2) {
|
|
984
1184
|
const firstPt = acc.equityHistory[0];
|
|
985
1185
|
const lastPt = acc.equityHistory[acc.equityHistory.length - 1];
|
|
986
1186
|
const elapsedDays = Math.max(7, (lastPt.ts - firstPt.ts) / DAY);
|
|
@@ -993,14 +1193,19 @@ export async function prepareBrokerOrders(id) {
|
|
|
993
1193
|
}
|
|
994
1194
|
}
|
|
995
1195
|
const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
|
|
996
|
-
const convOf = (r) =>
|
|
1196
|
+
const convOf = (r) => {
|
|
1197
|
+
if (!autoAdapt || !r.expectedPct) return 1;
|
|
1198
|
+
if (isOwnerPlan) return Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (Math.abs(r.expectedPct) / needPct)));
|
|
1199
|
+
if (vipBoost) return Math.min(1.30, Math.max(0.85, 0.80 + 0.25 * (Math.abs(r.expectedPct) / needPct)));
|
|
1200
|
+
return 1;
|
|
1201
|
+
};
|
|
997
1202
|
const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
|
|
998
1203
|
const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
|
|
999
|
-
const quarantined = new Set(
|
|
1204
|
+
const quarantined = new Set();
|
|
1000
1205
|
const nb = params.newsFilter ? blockingNews(6) : null;
|
|
1001
1206
|
|
|
1002
|
-
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);
|
|
1003
|
-
const allShortSignals = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
1207
|
+
const buyCands = fc.rows.filter((r) => r.signal && r.expectedPct >= minEntryEdgePct && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad).sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
1208
|
+
const allShortSignals = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -minEntryEdgePct && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
1004
1209
|
const nonShortableSkipped = [];
|
|
1005
1210
|
const rawShortCands = [];
|
|
1006
1211
|
for (const r of allShortSignals) {
|
|
@@ -1036,38 +1241,42 @@ export async function prepareBrokerOrders(id) {
|
|
|
1036
1241
|
const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
|
|
1037
1242
|
const aQty = algoQtyOf(sym, pos.qty);
|
|
1038
1243
|
const baseQty = pos.qty - aQty;
|
|
1244
|
+
const heldMs = entryTsMap[sym] ? (nowTs - entryTsMap[sym]) : (120 * 60000);
|
|
1245
|
+
// Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
|
|
1246
|
+
// чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95)
|
|
1247
|
+
const curExitThr = (autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct;
|
|
1039
1248
|
if (aQty > 0) {
|
|
1040
1249
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1041
|
-
const keep = r.liquid && r.expectedPct >
|
|
1250
|
+
const keep = r.liquid && r.expectedPct > curExitThr;
|
|
1042
1251
|
if (lots >= 1 && !keep) {
|
|
1043
1252
|
const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
1044
1253
|
orders.push({
|
|
1045
1254
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1046
1255
|
refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
1047
|
-
reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${
|
|
1256
|
+
reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`,
|
|
1048
1257
|
});
|
|
1049
1258
|
closing.add(sym);
|
|
1050
1259
|
}
|
|
1051
1260
|
} else if (aQty < 0) {
|
|
1052
1261
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1053
|
-
const keep = dir !== 'long' && r.liquid && r.expectedPct < -
|
|
1262
|
+
const keep = dir !== 'long' && r.liquid && r.expectedPct < -curExitThr;
|
|
1054
1263
|
if (lots >= 1 && !keep) {
|
|
1055
1264
|
const limitPrice = roundToStep(pos.price * (1 + acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
1056
1265
|
orders.push({
|
|
1057
1266
|
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1058
1267
|
refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
1059
|
-
reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${
|
|
1268
|
+
reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`,
|
|
1060
1269
|
});
|
|
1061
1270
|
closing.add(sym);
|
|
1062
1271
|
}
|
|
1063
1272
|
}
|
|
1064
1273
|
// В режиме «Плавный переход» (smooth) проверяем начальные акции:
|
|
1065
|
-
// если по старой акции вышел сильный сигнал
|
|
1066
|
-
// иначе
|
|
1274
|
+
// 1) если по старой акции вышел сильный медвежий сигнал (ожидание ниже −needPct), сразу закрываем всю позицию для защиты счёта;
|
|
1275
|
+
// 2) иначе добавляем бумагу в пул плавной ротации (в первую очередь высвобождаются бумаги с отрицательным/слабым прогнозом под новые лонги и шорты)
|
|
1067
1276
|
if (legacyMode === 'smooth' && baseQty > 0 && !closing.has(sym)) {
|
|
1068
1277
|
const bLots = Math.floor(baseQty / sp.lotSize);
|
|
1069
1278
|
if (bLots >= 1 && !(r.liquid && r.expectedPct > exitThresholdPct)) {
|
|
1070
|
-
if (r.expectedPct
|
|
1279
|
+
if (r.expectedPct <= -needPct) {
|
|
1071
1280
|
const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
1072
1281
|
orders.push({
|
|
1073
1282
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots: bLots, shares: bLots * sp.lotSize,
|
|
@@ -1088,63 +1297,101 @@ export async function prepareBrokerOrders(id) {
|
|
|
1088
1297
|
const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
|
|
1089
1298
|
let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
|
|
1090
1299
|
|
|
1091
|
-
|
|
1092
|
-
const
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1300
|
+
// 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
|
|
1301
|
+
const rawNewBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol)));
|
|
1302
|
+
// Не открываем шорт по тикеру, в котором ещё лежит непроданный остаток старого портфеля (baseQty > 0), чтобы брокер не превратил шорт в продажу старой позиции
|
|
1303
|
+
const rawNewShorts = shortCands.filter((r) => {
|
|
1304
|
+
const curQ = heldMap.get(r.symbol)?.qty || 0;
|
|
1305
|
+
const aQ = algoQtyOf(r.symbol, curQ);
|
|
1306
|
+
const bQ = curQ - aQ;
|
|
1307
|
+
if (bQ > 0 && !closing.has(r.symbol)) return false;
|
|
1308
|
+
return !(aQ < 0 && !closing.has(r.symbol));
|
|
1309
|
+
});
|
|
1310
|
+
const newBuys = filterBrokerDynamicTopK(rawNewBuys, minEntryEdgePct, Math.max(0, K - activeLongs));
|
|
1311
|
+
const newShorts = filterBrokerDynamicTopK(rawNewShorts, minEntryEdgePct, Math.max(0, K - activeShorts));
|
|
1312
|
+
const totalNew = newBuys.length + newShorts.length;
|
|
1313
|
+
const activeTotal = Math.max(2, activeLongs + activeShorts + totalNew);
|
|
1314
|
+
const dynSlotRub = (isOwnerPlan && autoAdapt)
|
|
1315
|
+
? budget * Math.min(0.45, Math.max(slotRub / Math.max(1, budget), 1.8 / Math.min(slots, activeTotal + 1)))
|
|
1316
|
+
: (vipBoost && autoAdapt)
|
|
1317
|
+
? budget * Math.min(0.40, Math.max(slotRub / Math.max(1, budget), 1.5 / Math.min(slots, activeTotal + 1)))
|
|
1318
|
+
: slotRub;
|
|
1319
|
+
|
|
1320
|
+
// В режиме «Плавный переход» (smooth):
|
|
1321
|
+
// • высвобождаем кэш из старого портфеля как под новые ПОКУПКИ, так и под новые ШОРТЫ (чтобы шорты открывались полным рабочим слотом dynSlotRub, а не на остаток копеек);
|
|
1322
|
+
// • даже если прямо сейчас нет нового входа, но свободного кэша на счёте меньше 1 рабочего слота, плавно продаём на каждом шаге по 1–2 самые слабые старые акции с отрицательным прогнозом (expectedPct <= -0.02%), заранее формируя рублёвый резерв;
|
|
1323
|
+
// • сортируем кандидатов строго по худшему прогнозу ИИ (сначала акции с отрицательным ожиданием, среди близких — позиции в плюсе или с минимальным убытком), до 4 бумаг за шаг при активном сигнале.
|
|
1324
|
+
if (legacyMode === 'smooth' && smoothSellPool.length > 0) {
|
|
1325
|
+
const activeEntries = [...newBuys, ...newShorts];
|
|
1326
|
+
const neededForSignalsRub = activeEntries.reduce((s, r) => s + (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r), 0);
|
|
1327
|
+
const hasWeakLegacy = smoothSellPool.some((x) => (x.r.expectedPct || 0) <= -0.02);
|
|
1328
|
+
const proactiveReserveRub = (!activeEntries.length && hasWeakLegacy && availRub < slotRub) ? slotRub : 0;
|
|
1329
|
+
const targetNeedRub = Math.max(neededForSignalsRub, proactiveReserveRub);
|
|
1330
|
+
let shortfall = Math.min(Math.max(0, budget * 0.99 - usedRub), targetNeedRub) - availRub;
|
|
1100
1331
|
if (shortfall > 1000) {
|
|
1101
|
-
smoothSellPool.sort((a, b) =>
|
|
1102
|
-
|
|
1332
|
+
smoothSellPool.sort((a, b) => {
|
|
1333
|
+
const ea = Number(a.r.expectedPct || 0);
|
|
1334
|
+
const eb = Number(b.r.expectedPct || 0);
|
|
1335
|
+
// Сначала бумаги с явно худшим прогнозом (разница > 0.03%), при близком прогнозе — сначала плюсовые/околонулевые
|
|
1336
|
+
if (Math.abs(ea - eb) > 0.03) return ea - eb;
|
|
1337
|
+
return ((b.pos.pnlRub >= 0 ? 1 : 0) - (a.pos.pnlRub >= 0 ? 1 : 0)) || (ea - eb);
|
|
1338
|
+
});
|
|
1339
|
+
const maxSteps = activeEntries.length > 0 ? 4 : 2;
|
|
1340
|
+
const poolToUse = activeEntries.length > 0
|
|
1341
|
+
? smoothSellPool.slice(0, maxSteps)
|
|
1342
|
+
: smoothSellPool.filter((x) => (x.r.expectedPct || 0) <= -0.02).slice(0, maxSteps);
|
|
1343
|
+
for (const item of poolToUse) {
|
|
1103
1344
|
if (shortfall <= 1000) break;
|
|
1104
1345
|
const limitPrice = roundToStep(item.pos.price * (1 - acc.limitOffsetPct / 100), item.sp.minStep, item.sp.decimals);
|
|
1105
1346
|
const lotVal = limitPrice * item.sp.lotSize;
|
|
1106
1347
|
const lotsToSell = Math.min(item.bLots, Math.max(1, Math.ceil(shortfall / lotVal)));
|
|
1107
1348
|
const amountRub = round(lotsToSell * item.sp.lotSize * limitPrice, 2);
|
|
1349
|
+
const sigReason = activeEntries.length > 0
|
|
1350
|
+
? `под сигнал стратегии (${activeEntries.map((x) => x.symbol).join(', ')})`
|
|
1351
|
+
: 'упреждающее освобождение кэша из слабой бумаги';
|
|
1108
1352
|
orders.push({
|
|
1109
1353
|
symbol: item.sym, action: 'sell', lotSize: item.sp.lotSize, lots: lotsToSell, shares: lotsToSell * item.sp.lotSize,
|
|
1110
1354
|
refPrice: item.pos.price, limitPrice, amountRub,
|
|
1111
|
-
reason: `Плавный переход: высвобождение ${fmtRubSimple(amountRub)} ₽ из старого портфеля (
|
|
1355
|
+
reason: `Плавный переход: высвобождение ${fmtRubSimple(amountRub)} ₽ из старого портфеля (прогноз ${item.r.expectedPct > 0 ? '+' : ''}${item.r.expectedPct}%) ${sigReason}`,
|
|
1112
1356
|
});
|
|
1113
1357
|
availRub += amountRub;
|
|
1114
1358
|
shortfall -= amountRub;
|
|
1115
1359
|
}
|
|
1116
1360
|
}
|
|
1117
1361
|
}
|
|
1118
|
-
const totalNew = newBuys.length + newShorts.length;
|
|
1119
1362
|
let remNew = totalNew;
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
const ord = calcLotOrder(r.symbol, sum, refPx, 'buy', acc.limitOffsetPct, specs);
|
|
1127
|
-
if (ord) {
|
|
1128
|
-
availRub = Math.max(0, availRub - ord.amountRub);
|
|
1129
|
-
orders.push({
|
|
1130
|
-
...ord,
|
|
1131
|
-
expectedPct: r.expectedPct,
|
|
1132
|
-
reason: `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`,
|
|
1133
|
-
});
|
|
1134
|
-
}
|
|
1363
|
+
const combinedNew = [
|
|
1364
|
+
...newBuys.map((r) => ({ r, side: 'buy' })),
|
|
1365
|
+
...newShorts.map((r) => ({ r, side: 'short' })),
|
|
1366
|
+
];
|
|
1367
|
+
if (autoAdapt && combinedNew.length > 1) {
|
|
1368
|
+
combinedNew.sort((a, b) => Math.abs(b.r.expectedPct || 0) - Math.abs(a.r.expectedPct || 0));
|
|
1135
1369
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1370
|
+
let remWeightSum = combinedNew.reduce((s, x) => s + Math.max(0.01, Math.abs(x.r.expectedPct || 0)), 0);
|
|
1371
|
+
|
|
1372
|
+
// Жёсткий предохранитель концентрации на одну бумагу в реальном стакане брокера:
|
|
1373
|
+
// максимум до 45% счёта на один лонг и максимум до 35% счёта на один шорт (включая множитель силы сигнала convOf)
|
|
1374
|
+
const maxLongPerStockRub = budget * 0.45;
|
|
1375
|
+
const maxShortPerStockRub = budget * 0.35;
|
|
1376
|
+
|
|
1377
|
+
for (const { r, side } of combinedNew) {
|
|
1378
|
+
const wCur = Math.max(0.01, Math.abs(r.expectedPct || 0));
|
|
1379
|
+
const propShare = autoAdapt && remWeightSum > 0 ? availRub * (wCur / remWeightSum) : availRub / Math.max(1, remNew);
|
|
1380
|
+
remWeightSum = Math.max(0, remWeightSum - wCur);
|
|
1139
1381
|
remNew--;
|
|
1140
|
-
const
|
|
1141
|
-
const
|
|
1382
|
+
const sideCapRub = side === 'short' ? maxShortPerStockRub : maxLongPerStockRub;
|
|
1383
|
+
const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
|
|
1384
|
+
const sum = Math.min(rawSum, propShare, availRub);
|
|
1385
|
+
const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
|
|
1386
|
+
const ord = calcLotOrder(r.symbol, sum, refPx, side, acc.limitOffsetPct, specs);
|
|
1142
1387
|
if (ord) {
|
|
1143
1388
|
availRub = Math.max(0, availRub - ord.amountRub);
|
|
1144
1389
|
orders.push({
|
|
1145
1390
|
...ord,
|
|
1146
1391
|
expectedPct: r.expectedPct,
|
|
1147
|
-
reason:
|
|
1392
|
+
reason: side === 'buy'
|
|
1393
|
+
? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
|
|
1394
|
+
: `Продажа без покупки: ожидание ${r.expectedPct}% за ${params.modelHorizon} ч`,
|
|
1148
1395
|
});
|
|
1149
1396
|
}
|
|
1150
1397
|
}
|
|
@@ -1153,9 +1400,13 @@ export async function prepareBrokerOrders(id) {
|
|
|
1153
1400
|
let planThrottleNote = '';
|
|
1154
1401
|
if (plan.id === 'investor') {
|
|
1155
1402
|
const shInfo = rawShortCands.length ? ` (пропущены сигналы на шорт: ${rawShortCands.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
1156
|
-
|
|
1403
|
+
const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
|
|
1404
|
+
planThrottleNote = `Тариф «Инвестор» (целевой потолок до 60% годовых, 1 счёт со стартовым лимитом до 1 млн ₽${reinvNote}): активен режим «Только в рост (без шорта)»${shInfo}. Для включения заработка на падении акций (шорты, до 120% годовых и до 5 счетов) перейдите на тариф «Профи».`;
|
|
1157
1405
|
} else if (plan.id === 'pro') {
|
|
1158
|
-
|
|
1406
|
+
const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
|
|
1407
|
+
planThrottleNote = `Тариф «Профи» (целевой потолок до 120% годовых, до 5 счетов со стартовым лимитом до 1 млн ₽/счёт${reinvNote}): активны покупки и шорты. Для расширения потолка доходности до 240% годовых и индивидуальных лимитов подключите тариф «VIP».`;
|
|
1408
|
+
} else if (plan.id === 'vip') {
|
|
1409
|
+
planThrottleNote = `Тариф «VIP» (целевой потолок до 240% годовых, лимиты по согласованию): активны покупки и шорты с повышенной концентрацией слота.`;
|
|
1159
1410
|
}
|
|
1160
1411
|
if (!orders.length) {
|
|
1161
1412
|
if ((newBuys.length || newShorts.length) && availRub < 1500) {
|
|
@@ -1164,7 +1415,7 @@ export async function prepareBrokerOrders(id) {
|
|
|
1164
1415
|
diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
|
|
1165
1416
|
} else if (!newBuys.length && !planAllowsShorts && rawShortCands.length > 0) {
|
|
1166
1417
|
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
1167
|
-
diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до
|
|
1418
|
+
diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 60% годовых). Чтобы зарабатывать и на падении акций (до 120% годовых), перейдите на тариф «Профи» или «VIP».`;
|
|
1168
1419
|
} else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
|
|
1169
1420
|
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
1170
1421
|
diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
|
|
@@ -1198,6 +1449,7 @@ export async function prepareBrokerOrders(id) {
|
|
|
1198
1449
|
}
|
|
1199
1450
|
const st = brokersState();
|
|
1200
1451
|
hub.emit({ type: 'brokers:update', state: st });
|
|
1452
|
+
hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
|
|
1201
1453
|
return st;
|
|
1202
1454
|
}
|
|
1203
1455
|
|
|
@@ -1237,6 +1489,25 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1237
1489
|
const isBuy = ord.action === 'buy' || ord.action === 'cover';
|
|
1238
1490
|
let lotsToOrder = Number(ord.lots) || 0;
|
|
1239
1491
|
|
|
1492
|
+
// Перед выставлением новой лимитной заявки автоматически снимаем старые неисполненные заявки по этой же бумаге и направлению,
|
|
1493
|
+
// чтобы в стакане никогда не копились дублирующиеся заявки и не блокировали рублёвый лимит счёта!
|
|
1494
|
+
const rActive = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
|
|
1495
|
+
method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId }), timeoutMs: 8000,
|
|
1496
|
+
}).catch(() => null);
|
|
1497
|
+
if (rActive?.ok) {
|
|
1498
|
+
const jActive = await rActive.json().catch(() => null);
|
|
1499
|
+
const wantDir = isBuy ? 'BUY' : 'SELL';
|
|
1500
|
+
for (const ao of jActive?.orders || []) {
|
|
1501
|
+
const sameInst = ao.instrumentUid === instrumentId || ao.figi === instrumentId || ao.ticker === ord.symbol;
|
|
1502
|
+
const sameDir = String(ao.direction || '').includes(wantDir);
|
|
1503
|
+
if (sameInst && sameDir && ao.orderId) {
|
|
1504
|
+
await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
1505
|
+
method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId, orderId: ao.orderId }), timeoutMs: 8000,
|
|
1506
|
+
}).catch(() => null);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1240
1511
|
// Проверяем реальный доступный лимит лотов у брокера через GetMaxLots перед отправкой заявки
|
|
1241
1512
|
if (!acc.sandbox && lotsToOrder > 0) {
|
|
1242
1513
|
const rMax = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetMaxLots`, {
|
|
@@ -1450,10 +1721,31 @@ export async function executeBrokerOrders(id) {
|
|
|
1450
1721
|
const batch = acc.pendingBatch;
|
|
1451
1722
|
if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
|
|
1452
1723
|
const results = [];
|
|
1453
|
-
|
|
1724
|
+
acc.entryTsBySymbol = acc.entryTsBySymbol || {};
|
|
1725
|
+
acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
|
|
1726
|
+
const execNowTs = Date.now();
|
|
1727
|
+
const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
|
|
1728
|
+
const entryOrders = batch.orders.filter((o) => o.action !== 'sell' && o.action !== 'cover');
|
|
1729
|
+
const orderedBatch = [...exitOrders, ...entryOrders];
|
|
1730
|
+
let hadExits = false;
|
|
1731
|
+
for (const ord of orderedBatch) {
|
|
1732
|
+
// Если перед открытием новых позиций мы отправили закрывающие ордера (sell/cover),
|
|
1733
|
+
// даём брокеру 1.2 секунды на исполнение в стакане и пересчёт свободного маржинального/рублёвого лимита перед вызовом GetMaxLots
|
|
1734
|
+
if (hadExits && (ord.action === 'buy' || ord.action === 'short')) {
|
|
1735
|
+
hadExits = false;
|
|
1736
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
1737
|
+
}
|
|
1454
1738
|
try {
|
|
1455
1739
|
const r = await sendOrderToBroker(acc, ord);
|
|
1456
1740
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
|
|
1741
|
+
if (ord.action === 'buy' || ord.action === 'short') {
|
|
1742
|
+
acc.entryTsBySymbol[ord.symbol] = execNowTs;
|
|
1743
|
+
delete acc.exitCooldownBySymbol[ord.symbol];
|
|
1744
|
+
} else if (ord.action === 'sell' || ord.action === 'cover') {
|
|
1745
|
+
hadExits = true;
|
|
1746
|
+
delete acc.entryTsBySymbol[ord.symbol];
|
|
1747
|
+
acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
|
|
1748
|
+
}
|
|
1457
1749
|
} catch (e) {
|
|
1458
1750
|
results.push({ symbol: ord.symbol, ok: false, error: e.message });
|
|
1459
1751
|
}
|
|
@@ -1462,11 +1754,45 @@ export async function executeBrokerOrders(id) {
|
|
|
1462
1754
|
batch.results = results;
|
|
1463
1755
|
batch.executedAt = Date.now();
|
|
1464
1756
|
saveRawAccounts(list);
|
|
1757
|
+
await syncBrokerAccount(id).catch(() => {});
|
|
1465
1758
|
const st = brokersState();
|
|
1466
1759
|
hub.emit({ type: 'brokers:update', state: st });
|
|
1467
1760
|
return st;
|
|
1468
1761
|
}
|
|
1469
1762
|
|
|
1763
|
+
export async function cancelBrokerOrder(id, orderId = null) {
|
|
1764
|
+
const list = loadRawAccounts();
|
|
1765
|
+
const acc = list.find((x) => x.id === id);
|
|
1766
|
+
if (!acc) throw new Error('Счёт не найден');
|
|
1767
|
+
if (!acc.token) throw new Error('Не задан токен API брокера');
|
|
1768
|
+
if (acc.broker === 'tbank') {
|
|
1769
|
+
const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
1770
|
+
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
1771
|
+
const accountId = acc.accountId || acc.lastSync?.accountId;
|
|
1772
|
+
if (!accountId) throw new Error('Не определён ID счёта');
|
|
1773
|
+
if (orderId && orderId !== 'all') {
|
|
1774
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
1775
|
+
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 10000,
|
|
1776
|
+
});
|
|
1777
|
+
if (!r.ok) throw new Error(`T-Invest CancelOrder: HTTP ${r.status}`);
|
|
1778
|
+
} else {
|
|
1779
|
+
const rOrd = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
|
|
1780
|
+
method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
|
|
1781
|
+
});
|
|
1782
|
+
if (rOrd.ok) {
|
|
1783
|
+
const jOrd = await rOrd.json();
|
|
1784
|
+
await Promise.all((jOrd.orders || []).map((o) => (
|
|
1785
|
+
o.orderId ? brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
1786
|
+
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(o.orderId) }), timeoutMs: 10000,
|
|
1787
|
+
}).catch(() => null) : null
|
|
1788
|
+
)));
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return syncBrokerAccount(id);
|
|
1792
|
+
}
|
|
1793
|
+
throw new Error('Отмена активных заявок из интерфейса сейчас поддерживается для Т-Банка (T-Invest API)');
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1470
1796
|
export function rejectBrokerOrders(id) {
|
|
1471
1797
|
const list = loadRawAccounts();
|
|
1472
1798
|
const acc = list.find((x) => x.id === id);
|
|
@@ -1633,13 +1959,12 @@ async function tickBrokerSchedule() {
|
|
|
1633
1959
|
if ((plan.maxBrokerAccounts ?? 0) < 1) return;
|
|
1634
1960
|
for (const acc of list) {
|
|
1635
1961
|
if (acc.autoSchedule === false || acc.haltedReason) continue;
|
|
1636
|
-
const
|
|
1637
|
-
const params = normalizeParams(baseRun?.params || getSettings());
|
|
1962
|
+
const params = normalizeParams(getSettings());
|
|
1638
1963
|
const autoAdapt = params.modelAutoAdapt !== false;
|
|
1639
1964
|
const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
|
|
1640
1965
|
const H = barsFromHours(effHorizon);
|
|
1641
|
-
// Внутричасовой перехват H/5 (каждые 30 мин) активен в
|
|
1642
|
-
const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
1966
|
+
// Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
|
|
1967
|
+
const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
1643
1968
|
if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
|
|
1644
1969
|
acc.lastScheduledBarTs = latest;
|
|
1645
1970
|
saveRawAccounts(list);
|
|
@@ -1704,6 +2029,11 @@ async function tickBrokerAutoRefresh() {
|
|
|
1704
2029
|
}
|
|
1705
2030
|
|
|
1706
2031
|
export function startBrokerScheduler() {
|
|
2032
|
+
ensureBoardSpecs().catch(() => {});
|
|
2033
|
+
// В фоне после старта сервера сразу прогреваем прогноз, чтобы при открытии вкладки «Прогноз сейчас» он уже был в памяти
|
|
2034
|
+
setTimeout(() => {
|
|
2035
|
+
forecastNow(getSettings()).catch(() => {});
|
|
2036
|
+
}, 800);
|
|
1707
2037
|
setTimeout(resumeBrokerAccountsOnBoot, 2500);
|
|
1708
2038
|
const timer = setInterval(tickBrokerSchedule, 60000);
|
|
1709
2039
|
const syncTimer = setInterval(tickBrokerAutoRefresh, 120000);
|