neuroalpha 0.3.15 → 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 +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 +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 +444 -829
- package/public/index.html +141 -199
- 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 +430 -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 +16 -16
- package/server/strategist/index.js +4 -0
- package/server/strategist/tools.js +229 -0
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 === 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
|
+
}
|
|
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,31 @@ 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 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
|
+
}
|
|
387
425
|
}
|
|
388
426
|
}
|
|
389
|
-
kvSet('tbankShortableMap', { at: Date.now(), map });
|
|
427
|
+
kvSet('tbankShortableMap', { v: 3, at: Date.now(), map, uidMap, specMap });
|
|
390
428
|
return map;
|
|
391
429
|
} catch {
|
|
392
430
|
return cached?.map || null;
|
|
@@ -395,16 +433,39 @@ async function ensureTbankShortableMap(acc) {
|
|
|
395
433
|
|
|
396
434
|
async function resolveTbankInstrumentId(base, headers, symbol) {
|
|
397
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
|
+
}
|
|
398
458
|
try {
|
|
399
|
-
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`, {
|
|
400
460
|
method: 'POST',
|
|
401
461
|
headers,
|
|
402
|
-
body: JSON.stringify({
|
|
462
|
+
body: JSON.stringify({ query: symbol }),
|
|
403
463
|
timeoutMs: 10000,
|
|
404
464
|
});
|
|
405
465
|
if (r.ok) {
|
|
406
466
|
const j = await r.json();
|
|
407
|
-
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;
|
|
408
469
|
if (uid) { tbankUidCache.set(symbol, uid); return uid; }
|
|
409
470
|
}
|
|
410
471
|
} catch { /* fallback */ }
|
|
@@ -475,9 +536,43 @@ async function syncTbank(acc) {
|
|
|
475
536
|
marginEnabled = !mTxt.includes('30051') && !/margin.*disabled/i.test(mTxt) ? false : false;
|
|
476
537
|
}
|
|
477
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
|
+
}
|
|
478
570
|
const totalRub = round(qVal(p.totalAmountPortfolio), 2);
|
|
479
571
|
const rawPositions = p.positions || [];
|
|
480
|
-
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);
|
|
481
576
|
const metaMap = await resolveTbankMetaMap(base, headers, lookupIds);
|
|
482
577
|
|
|
483
578
|
const positions = [];
|
|
@@ -491,7 +586,7 @@ async function syncTbank(acc) {
|
|
|
491
586
|
const qty = qVal(pos.quantity);
|
|
492
587
|
const px = qVal(pos.currentPrice);
|
|
493
588
|
if (isRubCashPos(pos, rawTicker)) {
|
|
494
|
-
cashRub += qty;
|
|
589
|
+
cashRub += Math.max(0, qty);
|
|
495
590
|
continue;
|
|
496
591
|
}
|
|
497
592
|
if (qty === 0) continue;
|
|
@@ -527,23 +622,28 @@ async function syncTbank(acc) {
|
|
|
527
622
|
}
|
|
528
623
|
|
|
529
624
|
const activeOrders = [];
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
+
});
|
|
546
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);
|
|
547
647
|
|
|
548
648
|
const breakdown = {
|
|
549
649
|
sharesRub: round(qVal(p.totalAmountShares), 0),
|
|
@@ -863,13 +963,43 @@ export async function syncBrokerAccount(id) {
|
|
|
863
963
|
else if (isManualCapable) data = await syncQuikOrManual(acc);
|
|
864
964
|
else throw new Error(`Неизвестный брокер: ${acc.broker}`);
|
|
865
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
|
+
}
|
|
866
974
|
if (data.accountName) acc.tbankAccountName = data.accountName;
|
|
867
975
|
if ((!acc.name || acc.name === 'Т-Банк (T-Invest API)') && data.accountName) {
|
|
868
976
|
acc.name = `Т-Банк · ${data.accountName}`;
|
|
869
977
|
}
|
|
870
|
-
// При первой синхронизации
|
|
978
|
+
// При первой синхронизации запоминаем стартовый портфель и стартовую базу капитала;
|
|
979
|
+
// а при продаже старых бумаг (в режиме «Плавный переход» или вручную) автоматически уменьшаем baselineQty до фактического остатка,
|
|
980
|
+
// чтобы новые покупки стратегии по тем же тикерам никогда не путались со старым портфелем
|
|
871
981
|
if (!acc.baselineQty || (!Object.keys(acc.baselineQty).length && data.positions?.length)) {
|
|
872
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;
|
|
873
1003
|
}
|
|
874
1004
|
const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
|
|
875
1005
|
const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
|
|
@@ -877,7 +1007,13 @@ export async function syncBrokerAccount(id) {
|
|
|
877
1007
|
if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
|
|
878
1008
|
acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
|
|
879
1009
|
} else if (ddPct >= acc.maxDailyDrawdownPct) {
|
|
880
|
-
|
|
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;
|
|
881
1017
|
}
|
|
882
1018
|
// История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
|
|
883
1019
|
const hist = Array.isArray(acc.equityHistory) ? acc.equityHistory : [];
|
|
@@ -887,6 +1023,17 @@ export async function syncBrokerAccount(id) {
|
|
|
887
1023
|
if (hist.length > 500) hist.splice(0, hist.length - 500);
|
|
888
1024
|
}
|
|
889
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;
|
|
890
1037
|
acc.lastSync = { at: nowTs, ok: true, error: null, day: today, dayStartRub, dayDrawdownPct: ddPct, ...data };
|
|
891
1038
|
} catch (e) {
|
|
892
1039
|
acc.lastSync = { ...(acc.lastSync || {}), at: Date.now(), ok: false, error: e.message };
|
|
@@ -897,6 +1044,7 @@ export async function syncBrokerAccount(id) {
|
|
|
897
1044
|
saveRawAccounts(list);
|
|
898
1045
|
const st = brokersState();
|
|
899
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 });
|
|
900
1048
|
return st;
|
|
901
1049
|
}
|
|
902
1050
|
|
|
@@ -935,52 +1083,106 @@ function sessionEdgeWarning(now = Date.now()) {
|
|
|
935
1083
|
|
|
936
1084
|
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
937
1085
|
export async function prepareBrokerOrders(id) {
|
|
938
|
-
|
|
939
|
-
|
|
1086
|
+
let list = loadRawAccounts();
|
|
1087
|
+
let acc = list.find((x) => x.id === id);
|
|
940
1088
|
if (!acc) throw new Error('Счёт не найден');
|
|
941
1089
|
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
942
1090
|
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
943
1091
|
|
|
944
1092
|
const plan = effectivePlan(currentUser());
|
|
945
1093
|
if ((plan.maxBrokerAccounts ?? 0) < 1) {
|
|
946
|
-
throw new Error(
|
|
1094
|
+
throw new Error('Для работы с брокерскими счетами оформите подписку «Инвестор» (3 900 ₽/мес), «Профи» (9 900 ₽/мес) или «VIP» в разделе «Подписка и уведомления».');
|
|
1095
|
+
}
|
|
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;
|
|
947
1106
|
}
|
|
948
|
-
const
|
|
949
|
-
const
|
|
950
|
-
const [fc, specs, shortableMap] = await Promise.all([
|
|
1107
|
+
const params = normalizeParams(getSettings());
|
|
1108
|
+
const [fc, rawSpecs, shortableMap] = await Promise.all([
|
|
951
1109
|
forecastNow(params),
|
|
952
1110
|
ensureBoardSpecs(),
|
|
953
1111
|
ensureTbankShortableMap(acc),
|
|
954
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
|
+
}
|
|
955
1120
|
const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
|
|
956
1121
|
const effectiveMaxCap = Math.min(acc.maxCapitalRub, capCeiling);
|
|
957
|
-
|
|
958
|
-
|
|
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
|
+
};
|
|
959
1148
|
// Автоматическая подстройка направления и мощности торговли под тариф и реальные данные счёта:
|
|
960
1149
|
const marginAllowed = acc.lastSync?.marginEnabled !== false;
|
|
961
1150
|
const planAllowsShorts = Boolean(plan.allowShorts);
|
|
962
1151
|
const vipBoost = Boolean(plan.allowVipBoost);
|
|
963
1152
|
const rawDir = params.modelDirection || 'long';
|
|
964
1153
|
const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
|
|
965
|
-
const autoAdapt = params.modelAutoAdapt !== false;
|
|
966
1154
|
const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
|
|
967
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);
|
|
968
1165
|
const slots = dir === 'long' ? K : 2 * K;
|
|
969
1166
|
// Фильтр мощности слота по тарифу:
|
|
970
|
-
// • Инвестор (до
|
|
971
|
-
// • Профи (до
|
|
972
|
-
// • 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);
|
|
973
1172
|
let slotRub;
|
|
974
|
-
if (
|
|
975
|
-
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));
|
|
976
1178
|
slotRub = budget * maxSlotWeight;
|
|
977
1179
|
} else if (plan.id === 'pro') {
|
|
978
|
-
slotRub = budget * Math.min(0.
|
|
1180
|
+
slotRub = budget * Math.min(0.28, 1.15 / Math.max(2, slots));
|
|
979
1181
|
} else {
|
|
980
|
-
slotRub = budget * Math.min(0.
|
|
1182
|
+
slotRub = budget * Math.min(0.24, 1.05 / Math.max(3, K + 1));
|
|
981
1183
|
}
|
|
982
|
-
//
|
|
983
|
-
if (!
|
|
1184
|
+
// Мягкий дроссель при опережении годового потолка доходности тарифа (60% Инвестор, 120% Профи, 240% VIP; у Хозяина = 0 — без потолка)
|
|
1185
|
+
if (!isOwnerPlan && plan.maxAnnualReturnPct > 0 && Array.isArray(acc.equityHistory) && acc.equityHistory.length >= 2) {
|
|
984
1186
|
const firstPt = acc.equityHistory[0];
|
|
985
1187
|
const lastPt = acc.equityHistory[acc.equityHistory.length - 1];
|
|
986
1188
|
const elapsedDays = Math.max(7, (lastPt.ts - firstPt.ts) / DAY);
|
|
@@ -993,14 +1195,19 @@ export async function prepareBrokerOrders(id) {
|
|
|
993
1195
|
}
|
|
994
1196
|
}
|
|
995
1197
|
const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
|
|
996
|
-
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
|
+
};
|
|
997
1204
|
const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
|
|
998
1205
|
const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
|
|
999
|
-
const quarantined = new Set(
|
|
1206
|
+
const quarantined = new Set();
|
|
1000
1207
|
const nb = params.newsFilter ? blockingNews(6) : null;
|
|
1001
1208
|
|
|
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) : [];
|
|
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) : [];
|
|
1004
1211
|
const nonShortableSkipped = [];
|
|
1005
1212
|
const rawShortCands = [];
|
|
1006
1213
|
for (const r of allShortSignals) {
|
|
@@ -1036,38 +1243,42 @@ export async function prepareBrokerOrders(id) {
|
|
|
1036
1243
|
const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
|
|
1037
1244
|
const aQty = algoQtyOf(sym, pos.qty);
|
|
1038
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;
|
|
1039
1250
|
if (aQty > 0) {
|
|
1040
1251
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1041
|
-
const keep = r.liquid && r.expectedPct >
|
|
1252
|
+
const keep = r.liquid && r.expectedPct > curExitThr;
|
|
1042
1253
|
if (lots >= 1 && !keep) {
|
|
1043
1254
|
const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
1044
1255
|
orders.push({
|
|
1045
1256
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1046
1257
|
refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
1047
|
-
reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${
|
|
1258
|
+
reason: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`,
|
|
1048
1259
|
});
|
|
1049
1260
|
closing.add(sym);
|
|
1050
1261
|
}
|
|
1051
1262
|
} else if (aQty < 0) {
|
|
1052
1263
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1053
|
-
const keep = dir !== 'long' && r.liquid && r.expectedPct < -
|
|
1264
|
+
const keep = dir !== 'long' && r.liquid && r.expectedPct < -curExitThr;
|
|
1054
1265
|
if (lots >= 1 && !keep) {
|
|
1055
1266
|
const limitPrice = roundToStep(pos.price * (1 + acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
1056
1267
|
orders.push({
|
|
1057
1268
|
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1058
1269
|
refPrice: pos.price, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
1059
|
-
reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${
|
|
1270
|
+
reason: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`,
|
|
1060
1271
|
});
|
|
1061
1272
|
closing.add(sym);
|
|
1062
1273
|
}
|
|
1063
1274
|
}
|
|
1064
1275
|
// В режиме «Плавный переход» (smooth) проверяем начальные акции:
|
|
1065
|
-
// если по старой акции вышел сильный сигнал
|
|
1066
|
-
// иначе
|
|
1276
|
+
// 1) если по старой акции вышел сильный медвежий сигнал (ожидание ниже −needPct), сразу закрываем всю позицию для защиты счёта;
|
|
1277
|
+
// 2) иначе добавляем бумагу в пул плавной ротации (в первую очередь высвобождаются бумаги с отрицательным/слабым прогнозом под новые лонги и шорты)
|
|
1067
1278
|
if (legacyMode === 'smooth' && baseQty > 0 && !closing.has(sym)) {
|
|
1068
1279
|
const bLots = Math.floor(baseQty / sp.lotSize);
|
|
1069
1280
|
if (bLots >= 1 && !(r.liquid && r.expectedPct > exitThresholdPct)) {
|
|
1070
|
-
if (r.expectedPct
|
|
1281
|
+
if (r.expectedPct <= -needPct) {
|
|
1071
1282
|
const limitPrice = roundToStep(pos.price * (1 - acc.limitOffsetPct / 100), sp.minStep, sp.decimals);
|
|
1072
1283
|
orders.push({
|
|
1073
1284
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots: bLots, shares: bLots * sp.lotSize,
|
|
@@ -1088,63 +1299,95 @@ export async function prepareBrokerOrders(id) {
|
|
|
1088
1299
|
const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub : budget;
|
|
1089
1300
|
let availRub = Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
|
|
1090
1301
|
|
|
1091
|
-
|
|
1092
|
-
const
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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;
|
|
1100
1333
|
if (shortfall > 1000) {
|
|
1101
|
-
smoothSellPool.sort((a, b) =>
|
|
1102
|
-
|
|
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) {
|
|
1103
1346
|
if (shortfall <= 1000) break;
|
|
1104
1347
|
const limitPrice = roundToStep(item.pos.price * (1 - acc.limitOffsetPct / 100), item.sp.minStep, item.sp.decimals);
|
|
1105
1348
|
const lotVal = limitPrice * item.sp.lotSize;
|
|
1106
1349
|
const lotsToSell = Math.min(item.bLots, Math.max(1, Math.ceil(shortfall / lotVal)));
|
|
1107
1350
|
const amountRub = round(lotsToSell * item.sp.lotSize * limitPrice, 2);
|
|
1351
|
+
const sigReason = activeEntries.length > 0
|
|
1352
|
+
? `под сигнал стратегии (${activeEntries.map((x) => x.symbol).join(', ')})`
|
|
1353
|
+
: 'упреждающее освобождение кэша из слабой бумаги';
|
|
1108
1354
|
orders.push({
|
|
1109
1355
|
symbol: item.sym, action: 'sell', lotSize: item.sp.lotSize, lots: lotsToSell, shares: lotsToSell * item.sp.lotSize,
|
|
1110
1356
|
refPrice: item.pos.price, limitPrice, amountRub,
|
|
1111
|
-
reason: `Плавный переход: высвобождение ${fmtRubSimple(amountRub)} ₽ из старого портфеля (
|
|
1357
|
+
reason: `Плавный переход: высвобождение ${fmtRubSimple(amountRub)} ₽ из старого портфеля (прогноз ${item.r.expectedPct > 0 ? '+' : ''}${item.r.expectedPct}%) ${sigReason}`,
|
|
1112
1358
|
});
|
|
1113
1359
|
availRub += amountRub;
|
|
1114
1360
|
shortfall -= amountRub;
|
|
1115
1361
|
}
|
|
1116
1362
|
}
|
|
1117
1363
|
}
|
|
1118
|
-
const totalNew = newBuys.length + newShorts.length;
|
|
1119
1364
|
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
|
-
}
|
|
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));
|
|
1135
1371
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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);
|
|
1139
1378
|
remNew--;
|
|
1140
|
-
const
|
|
1141
|
-
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);
|
|
1142
1383
|
if (ord) {
|
|
1143
1384
|
availRub = Math.max(0, availRub - ord.amountRub);
|
|
1144
1385
|
orders.push({
|
|
1145
1386
|
...ord,
|
|
1146
1387
|
expectedPct: r.expectedPct,
|
|
1147
|
-
reason:
|
|
1388
|
+
reason: side === 'buy'
|
|
1389
|
+
? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
|
|
1390
|
+
: `Продажа без покупки: ожидание ${r.expectedPct}% за ${params.modelHorizon} ч`,
|
|
1148
1391
|
});
|
|
1149
1392
|
}
|
|
1150
1393
|
}
|
|
@@ -1153,9 +1396,13 @@ export async function prepareBrokerOrders(id) {
|
|
|
1153
1396
|
let planThrottleNote = '';
|
|
1154
1397
|
if (plan.id === 'investor') {
|
|
1155
1398
|
const shInfo = rawShortCands.length ? ` (пропущены сигналы на шорт: ${rawShortCands.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
1156
|
-
|
|
1399
|
+
const reinvNote = reinvestedProfitRub > 0 ? ` + реинвестировано прибыли +${fmtRubSimple(reinvestedProfitRub)} ₽ (в работе ${fmtRubSimple(budget)} ₽)` : '';
|
|
1400
|
+
planThrottleNote = `Тариф «Инвестор» (целевой потолок до 60% годовых, 1 счёт со стартовым лимитом до 1 млн ₽${reinvNote}): активен режим «Только в рост (без шорта)»${shInfo}. Для включения заработка на падении акций (шорты, до 120% годовых и до 5 счетов) перейдите на тариф «Профи».`;
|
|
1157
1401
|
} else if (plan.id === 'pro') {
|
|
1158
|
-
|
|
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% годовых, лимиты по согласованию): активны покупки и шорты с повышенной концентрацией слота.`;
|
|
1159
1406
|
}
|
|
1160
1407
|
if (!orders.length) {
|
|
1161
1408
|
if ((newBuys.length || newShorts.length) && availRub < 1500) {
|
|
@@ -1164,7 +1411,7 @@ export async function prepareBrokerOrders(id) {
|
|
|
1164
1411
|
diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
|
|
1165
1412
|
} else if (!newBuys.length && !planAllowsShorts && rawShortCands.length > 0) {
|
|
1166
1413
|
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
1167
|
-
diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до
|
|
1414
|
+
diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 60% годовых). Чтобы зарабатывать и на падении акций (до 120% годовых), перейдите на тариф «Профи» или «VIP».`;
|
|
1168
1415
|
} else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
|
|
1169
1416
|
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
1170
1417
|
diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
|
|
@@ -1198,6 +1445,7 @@ export async function prepareBrokerOrders(id) {
|
|
|
1198
1445
|
}
|
|
1199
1446
|
const st = brokersState();
|
|
1200
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 });
|
|
1201
1449
|
return st;
|
|
1202
1450
|
}
|
|
1203
1451
|
|
|
@@ -1237,6 +1485,25 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1237
1485
|
const isBuy = ord.action === 'buy' || ord.action === 'cover';
|
|
1238
1486
|
let lotsToOrder = Number(ord.lots) || 0;
|
|
1239
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
|
+
|
|
1240
1507
|
// Проверяем реальный доступный лимит лотов у брокера через GetMaxLots перед отправкой заявки
|
|
1241
1508
|
if (!acc.sandbox && lotsToOrder > 0) {
|
|
1242
1509
|
const rMax = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetMaxLots`, {
|
|
@@ -1450,10 +1717,31 @@ export async function executeBrokerOrders(id) {
|
|
|
1450
1717
|
const batch = acc.pendingBatch;
|
|
1451
1718
|
if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
|
|
1452
1719
|
const results = [];
|
|
1453
|
-
|
|
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
|
+
}
|
|
1454
1734
|
try {
|
|
1455
1735
|
const r = await sendOrderToBroker(acc, ord);
|
|
1456
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
|
+
}
|
|
1457
1745
|
} catch (e) {
|
|
1458
1746
|
results.push({ symbol: ord.symbol, ok: false, error: e.message });
|
|
1459
1747
|
}
|
|
@@ -1462,11 +1750,45 @@ export async function executeBrokerOrders(id) {
|
|
|
1462
1750
|
batch.results = results;
|
|
1463
1751
|
batch.executedAt = Date.now();
|
|
1464
1752
|
saveRawAccounts(list);
|
|
1753
|
+
await syncBrokerAccount(id).catch(() => {});
|
|
1465
1754
|
const st = brokersState();
|
|
1466
1755
|
hub.emit({ type: 'brokers:update', state: st });
|
|
1467
1756
|
return st;
|
|
1468
1757
|
}
|
|
1469
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
|
+
|
|
1470
1792
|
export function rejectBrokerOrders(id) {
|
|
1471
1793
|
const list = loadRawAccounts();
|
|
1472
1794
|
const acc = list.find((x) => x.id === id);
|
|
@@ -1633,13 +1955,12 @@ async function tickBrokerSchedule() {
|
|
|
1633
1955
|
if ((plan.maxBrokerAccounts ?? 0) < 1) return;
|
|
1634
1956
|
for (const acc of list) {
|
|
1635
1957
|
if (acc.autoSchedule === false || acc.haltedReason) continue;
|
|
1636
|
-
const
|
|
1637
|
-
const params = normalizeParams(baseRun?.params || getSettings());
|
|
1958
|
+
const params = normalizeParams(getSettings());
|
|
1638
1959
|
const autoAdapt = params.modelAutoAdapt !== false;
|
|
1639
1960
|
const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
|
|
1640
1961
|
const H = barsFromHours(effHorizon);
|
|
1641
|
-
// Внутричасовой перехват H/5 (каждые 30 мин) активен в
|
|
1642
|
-
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;
|
|
1643
1964
|
if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
|
|
1644
1965
|
acc.lastScheduledBarTs = latest;
|
|
1645
1966
|
saveRawAccounts(list);
|
|
@@ -1704,6 +2025,11 @@ async function tickBrokerAutoRefresh() {
|
|
|
1704
2025
|
}
|
|
1705
2026
|
|
|
1706
2027
|
export function startBrokerScheduler() {
|
|
2028
|
+
ensureBoardSpecs().catch(() => {});
|
|
2029
|
+
// В фоне после старта сервера сразу прогреваем прогноз, чтобы при открытии вкладки «Прогноз сейчас» он уже был в памяти
|
|
2030
|
+
setTimeout(() => {
|
|
2031
|
+
forecastNow(getSettings()).catch(() => {});
|
|
2032
|
+
}, 800);
|
|
1707
2033
|
setTimeout(resumeBrokerAccountsOnBoot, 2500);
|
|
1708
2034
|
const timer = setInterval(tickBrokerSchedule, 60000);
|
|
1709
2035
|
const syncTimer = setInterval(tickBrokerAutoRefresh, 120000);
|