neuroalpha 0.5.33 → 0.6.0
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/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 +26 -3
- package/package.json +1 -1
- package/public/app.js +10 -3
- package/public/index.html +4 -2
- package/server/api/routes.js +2 -1
- package/server/brokers/index.js +204 -77
- package/server/data/lqdt.js +28 -0
- package/server/model/edge.js +12 -0
- package/server/model/forecast.js +8 -2
- package/server/model/policy.js +193 -0
- package/server/model/research.js +50 -14
- package/server/model/simulator.js +366 -0
- package/server/model/walkforward.js +59 -0
- package/server/news/index.js +25 -1
- package/server/strategist/tools.js +2 -0
package/server/brokers/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
3
4
|
import https from 'node:https';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { ROOT } from '../config.js';
|
|
4
7
|
import { db, kvGet, kvSet, userKvGet, userKvSet, getRun } from '../db.js';
|
|
5
8
|
import { encryptSecret, decryptSecret, currentUser, currentUserId, effectivePlan, runWithUser } from '../auth/index.js';
|
|
6
9
|
import { notifyBrokerBatch, registerBrokerHandlers } from '../notify/index.js';
|
|
@@ -10,6 +13,7 @@ import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
|
|
|
10
13
|
import { forecastNow } from '../model/forecast.js';
|
|
11
14
|
import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
|
|
12
15
|
import { liveDividendGuard } from '../model/dividends.js';
|
|
16
|
+
import { STRATEGY_POLICY, sessionEdgeWarning, evaluateProfitLock, protectiveStopPct, tierSizeMult, MAX_LEVERAGE } from '../model/policy.js';
|
|
13
17
|
import { MODELS } from '../model/models.js';
|
|
14
18
|
import { blockingNews } from '../news/index.js';
|
|
15
19
|
import { round, MSK, DAY, fmtBarMsk } from '../util.js';
|
|
@@ -193,6 +197,45 @@ function logBrokerEvent(acc, kind, text) {
|
|
|
193
197
|
} catch { /* журнал не должен ломать торговлю */ }
|
|
194
198
|
}
|
|
195
199
|
|
|
200
|
+
// ---------- журнал торговых решений: почему открыта или закрыта каждая позиция (для разбора дня и сверки с симулятором) ----------
|
|
201
|
+
db.exec(`CREATE TABLE IF NOT EXISTS trade_decisions (
|
|
202
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
203
|
+
ts INTEGER NOT NULL,
|
|
204
|
+
user_id INTEGER NOT NULL,
|
|
205
|
+
account_id TEXT NOT NULL,
|
|
206
|
+
symbol TEXT NOT NULL,
|
|
207
|
+
action TEXT NOT NULL,
|
|
208
|
+
version TEXT,
|
|
209
|
+
data TEXT NOT NULL
|
|
210
|
+
);
|
|
211
|
+
CREATE INDEX IF NOT EXISTS trade_decisions_acc ON trade_decisions(user_id, account_id, ts);`);
|
|
212
|
+
|
|
213
|
+
const APP_VERSION = (() => {
|
|
214
|
+
try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version || ''; } catch { return ''; }
|
|
215
|
+
})();
|
|
216
|
+
|
|
217
|
+
function logTradeDecision(acc, ord, result = {}) {
|
|
218
|
+
try {
|
|
219
|
+
const data = {
|
|
220
|
+
reason: ord.reason || '',
|
|
221
|
+
lots: result.lots ?? ord.lots,
|
|
222
|
+
limitPrice: result.limitPrice ?? ord.limitPrice,
|
|
223
|
+
refPrice: ord.refPrice,
|
|
224
|
+
amountRub: ord.amountRub,
|
|
225
|
+
isStopLoss: Boolean(ord.isStopLoss),
|
|
226
|
+
...(ord.decision || {}),
|
|
227
|
+
};
|
|
228
|
+
db.prepare('INSERT INTO trade_decisions (ts, user_id, account_id, symbol, action, version, data) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
229
|
+
.run(Date.now(), currentUserId(), String(acc?.id || ''), String(ord.symbol), String(ord.action), APP_VERSION, JSON.stringify(data));
|
|
230
|
+
} catch { /* журнал не должен ломать торговлю */ }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function listTradeDecisions(accountId, { fromTs = 0, limit = 500 } = {}) {
|
|
234
|
+
return db.prepare('SELECT ts, symbol, action, version, data FROM trade_decisions WHERE user_id = ? AND account_id = ? AND ts >= ? ORDER BY id DESC LIMIT ?')
|
|
235
|
+
.all(currentUserId(), String(accountId), Number(fromTs) || 0, Math.max(1, Math.min(5000, Number(limit) || 500)))
|
|
236
|
+
.map((r) => { let d = {}; try { d = JSON.parse(r.data); } catch { /* пусто */ } return { ts: r.ts, symbol: r.symbol, action: r.action, version: r.version, ...d }; });
|
|
237
|
+
}
|
|
238
|
+
|
|
196
239
|
export function listBrokerEvents(accountId, limit = 200) {
|
|
197
240
|
return db.prepare('SELECT ts, kind, text FROM broker_events WHERE user_id = ? AND account_id = ? ORDER BY id DESC LIMIT ?')
|
|
198
241
|
.all(currentUserId(), String(accountId), Math.max(1, Math.min(1000, Number(limit) || 200)));
|
|
@@ -500,7 +543,11 @@ export function saveBrokerAccount(input = {}) {
|
|
|
500
543
|
const maxCapitalRub = Math.min(rawMaxCap, capCeiling);
|
|
501
544
|
const nextBaseRunId = input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null);
|
|
502
545
|
const nextLimitOffset = Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.02)));
|
|
546
|
+
const nextLeverage = Math.max(1, Math.min(planMaxLeverage(plan), Math.round((Number(input.maxLeverage ?? prev.maxLeverage ?? 1) || 1) * 2) / 2));
|
|
547
|
+
const rawTotalDd = Number(input.maxDrawdownPct ?? prev.maxDrawdownPct ?? 0) || 0;
|
|
548
|
+
const nextTotalDd = rawTotalDd > 0 ? Math.max(3, Math.min(60, rawTotalDd)) : 0;
|
|
503
549
|
const strategyOrCapChanged = idx < 0
|
|
550
|
+
|| nextLeverage !== (prev.maxLeverage || 1)
|
|
504
551
|
|| acctChanged
|
|
505
552
|
|| nextBaseRunId !== (prev.baseRunId || null)
|
|
506
553
|
|| maxCapitalRub !== (prev.maxCapitalRub || 100000)
|
|
@@ -524,11 +571,18 @@ export function saveBrokerAccount(input = {}) {
|
|
|
524
571
|
entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
|
|
525
572
|
exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
|
|
526
573
|
entryFailCooldownBySymbol: acctChanged ? {} : (prev.entryFailCooldownBySymbol || {}),
|
|
574
|
+
entryFailReasonBySymbol: acctChanged ? {} : (prev.entryFailReasonBySymbol || {}),
|
|
575
|
+
entryDayBlockBySymbol: acctChanged ? {} : (prev.entryDayBlockBySymbol || {}),
|
|
527
576
|
peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
|
|
528
577
|
baseRunId: nextBaseRunId,
|
|
529
578
|
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
530
579
|
maxCapitalRub,
|
|
531
580
|
maxDailyDrawdownPct: Math.max(0.5, Math.min(50, Number(input.maxDailyDrawdownPct ?? prev.maxDailyDrawdownPct ?? 3))),
|
|
581
|
+
// плечо: множитель размера позиций 1-го эшелона и предел суммарной позиции (× капитал); 1 — без плеча
|
|
582
|
+
maxLeverage: nextLeverage,
|
|
583
|
+
// стоп по общей просадке от максимума капитала (0 — выключен): запрещает новые входы до ручного снятия
|
|
584
|
+
maxDrawdownPct: nextTotalDd,
|
|
585
|
+
equityPeakRub: acctChanged ? 0 : (prev.equityPeakRub || 0),
|
|
532
586
|
limitOffsetPct: nextLimitOffset,
|
|
533
587
|
skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
|
|
534
588
|
autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
|
|
@@ -574,6 +628,16 @@ export function resetBrokerDailyStop(id) {
|
|
|
574
628
|
const acc = list.find((x) => x.id === id);
|
|
575
629
|
if (!acc) throw new Error('Счёт не найден');
|
|
576
630
|
if (!acc.haltedReason) throw new Error('Дневной стоп на этом счёте не активен');
|
|
631
|
+
if (acc.haltedReason.startsWith(TOTAL_DD_PREFIX)) {
|
|
632
|
+
// снятие стопа по общей просадке: отсчёт просадки начинается заново от текущей оценки счёта
|
|
633
|
+
logBrokerEvent(acc, 'total_dd_reset', `снят вручную (${acc.haltedReason})`);
|
|
634
|
+
acc.haltedReason = null;
|
|
635
|
+
acc.equityPeakRub = Number(acc.lastSync?.totalRub) || 0;
|
|
636
|
+
saveRawAccounts(list);
|
|
637
|
+
const st0 = brokersState();
|
|
638
|
+
hub.emit({ type: 'brokers:update', state: st0 });
|
|
639
|
+
return st0;
|
|
640
|
+
}
|
|
577
641
|
if (!acc.haltedReason.startsWith('Сработал дневной стоп')) throw new Error('Эту блокировку снять нельзя: ' + acc.haltedReason);
|
|
578
642
|
const today = new Date(Date.now() + MSK).toISOString().slice(0, 10);
|
|
579
643
|
logBrokerEvent(acc, 'daily_stop_reset', `снят вручную до конца дня (${acc.haltedReason})`);
|
|
@@ -1526,6 +1590,17 @@ export async function syncBrokerAccount(id) {
|
|
|
1526
1590
|
} else {
|
|
1527
1591
|
acc.ddBreachCount = 0;
|
|
1528
1592
|
}
|
|
1593
|
+
// Стоп по общей просадке от максимума оценки счёта (настройка maxDrawdownPct). Вывод денег со счёта уменьшает оценку —
|
|
1594
|
+
// после вывода стоп снимается вручную («Снять стоп»), и отсчёт начинается от новой оценки.
|
|
1595
|
+
if (!isGlitchZeroPortfolio && data.totalRub > 0) {
|
|
1596
|
+
const peak = Math.max(Number(acc.equityPeakRub) || 0, data.totalRub);
|
|
1597
|
+
acc.equityPeakRub = round(peak, 2);
|
|
1598
|
+
const totalDd = peak > 0 ? round(((peak - data.totalRub) / peak) * 100, 2) : 0;
|
|
1599
|
+
if (acc.maxDrawdownPct > 0 && totalDd >= acc.maxDrawdownPct && !acc.haltedReason) {
|
|
1600
|
+
acc.haltedReason = `${TOTAL_DD_PREFIX}: −${totalDd}% от максимума ${fmtRubSimple(peak)} ₽ (порог ${acc.maxDrawdownPct}%)`;
|
|
1601
|
+
logBrokerEvent(acc, 'total_dd_stop', acc.haltedReason);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1529
1604
|
// История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
|
|
1530
1605
|
const hist = Array.isArray(acc.equityHistory) ? acc.equityHistory : [];
|
|
1531
1606
|
const nowTs = Date.now();
|
|
@@ -1611,46 +1686,17 @@ export async function freezeBrokerBaseline(id) {
|
|
|
1611
1686
|
return st;
|
|
1612
1687
|
}
|
|
1613
1688
|
|
|
1614
|
-
//
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
const tierMult = symbolEdgeMult(sym);
|
|
1624
|
-
// Минимальный чистый порог фиксации с учётом спреда стакана и двойной комиссии (чтобы выход не закрывался в 0.00% после исполнения лимитки)
|
|
1625
|
-
const minNetFloorPct = Math.max(roundTripFeePct + 0.30, round(0.38 * tierMult, 2));
|
|
1626
|
-
// 1) Скользящий Take-Profit при сильном перевыполнении цели (в первые 15 мин требуем более сильный импульс 1.55×tierMult, далее 1.20×tierMult):
|
|
1627
|
-
// фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
|
|
1628
|
-
const tpTriggerPct = (heldMin < 15 ? 1.55 : 1.20) * tierMult;
|
|
1629
|
-
if (peakGainPct >= tpTriggerPct) {
|
|
1630
|
-
const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, minNetFloorPct);
|
|
1631
|
-
if (curGainPct <= trailFloorPct && curGainPct >= minNetFloorPct) {
|
|
1632
|
-
return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
// 2) Защита заработанного плюса / безубытка (после первых 15 минут удержания, от +0.85% на 1-м эшелоне и от +1.02% на 2-м эшелоне):
|
|
1636
|
-
// не позволяем зрелой плюсовой сделке развернуться обратно в убыток, но и не режем позицию стартовым шумом стакана в первые минуты
|
|
1637
|
-
if (heldMin >= 15 && peakGainPct >= 0.85 * tierMult) {
|
|
1638
|
-
const breakevenFloorPct = Math.max(minNetFloorPct, peakGainPct * 0.45);
|
|
1639
|
-
if (curGainPct <= breakevenFloorPct && curGainPct >= roundTripFeePct + 0.22) {
|
|
1640
|
-
return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
return null;
|
|
1644
|
-
}
|
|
1645
|
-
function sessionEdgeWarning(now = Date.now()) {
|
|
1646
|
-
const d = new Date(now + MSK);
|
|
1647
|
-
const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
1648
|
-
// открытие утренней сессии 06:50–07:25 (отыгрыш ночных гэпов и формирование стакана), открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
|
|
1649
|
-
if ((mins >= 6 * 60 + 50 && mins <= 7 * 60 + 25) || (mins >= 9 * 60 + 50 && mins <= 10 * 60 + 10) || (mins >= 18 * 60 + 35 && mins <= 18 * 60 + 55)) {
|
|
1650
|
-
return 'Первые минуты утренней/основной сессии или закрытие дневной (широкий спред и утренние гэпы в стакане)';
|
|
1651
|
-
}
|
|
1652
|
-
return null;
|
|
1689
|
+
// Дневной стоп по просадке запрещает только НОВЫЕ входы: выходы по сигналу, фиксация прибыли, стопы и закрытие перед ночью продолжают работать
|
|
1690
|
+
// Стоп по общей просадке от максимума капитала действует так же (только выходы), но не снимается сам с началом нового дня
|
|
1691
|
+
const TOTAL_DD_PREFIX = 'Сработал стоп по общей просадке';
|
|
1692
|
+
const isDailyStopHalt = (acc) => Boolean(acc?.haltedReason && (acc.haltedReason.startsWith('Сработал дневной стоп') || acc.haltedReason.startsWith(TOTAL_DD_PREFIX)));
|
|
1693
|
+
// Предел плеча по тарифу: «Инвестор» — без плеча (только покупки), «Профи» — до 3, «VIP» и «Хозяин» — до MAX_LEVERAGE
|
|
1694
|
+
function planMaxLeverage(plan) {
|
|
1695
|
+
if (plan?.isOwner || plan?.id === 'owner' || plan?.allowVipBoost || plan?.id === 'vip') return MAX_LEVERAGE;
|
|
1696
|
+
if (plan?.id === 'pro') return 3;
|
|
1697
|
+
return 1;
|
|
1653
1698
|
}
|
|
1699
|
+
const isExitOrder = (o) => o.action === 'sell' || o.action === 'cover';
|
|
1654
1700
|
|
|
1655
1701
|
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
1656
1702
|
const ACCOUNT_LOCK_TTL_MS = 75000;
|
|
@@ -1767,9 +1813,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1767
1813
|
let list = loadRawAccounts();
|
|
1768
1814
|
let acc = list.find((x) => x.id === id);
|
|
1769
1815
|
if (!acc) throw new Error('Счёт не найден');
|
|
1770
|
-
|
|
1816
|
+
const exitsOnly = isDailyStopHalt(acc);
|
|
1817
|
+
if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
|
|
1771
1818
|
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
1772
|
-
if (onlyEntries && edgeWarn) {
|
|
1819
|
+
if (onlyEntries && (edgeWarn || exitsOnly)) {
|
|
1773
1820
|
return brokersState();
|
|
1774
1821
|
}
|
|
1775
1822
|
|
|
@@ -1825,6 +1872,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1825
1872
|
const vipBoost = Boolean(plan.allowVipBoost);
|
|
1826
1873
|
const rawDir = params.modelDirection || 'long';
|
|
1827
1874
|
const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
|
|
1875
|
+
// Плечо счёта (только при включённой маржинальной торговле и в пределах тарифа) и режим «капитал в LQDT как залог»:
|
|
1876
|
+
// внутридневные позиции открываются под обеспечение, LQDT под входы не продаётся; предел заявки проверяет брокер (GetMaxLots)
|
|
1877
|
+
const leverage = marginAllowed ? Math.max(1, Math.min(planMaxLeverage(plan), Number(acc.maxLeverage) || 1)) : 1;
|
|
1878
|
+
const collateral = Boolean(STRATEGY_POLICY.cashYield && acc.cashSweep && acc.broker === 'tbank' && marginAllowed);
|
|
1879
|
+
const marginSizing = marginAllowed && (collateral || leverage > 1);
|
|
1828
1880
|
// Единые пороги удержания с эталонным симулятором (research.js): первые 65 минут — мягкий порог 0.35, затем плотный порог 0.95 (с учётом эшелона)
|
|
1829
1881
|
const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
|
|
1830
1882
|
const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
|
|
@@ -1837,6 +1889,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1837
1889
|
const entryTsMap = acc.entryTsBySymbol || {};
|
|
1838
1890
|
const exitCooldownMap = acc.exitCooldownBySymbol || {};
|
|
1839
1891
|
const entryFailMap = acc.entryFailCooldownBySymbol || {};
|
|
1892
|
+
const entryFailReasonMap = acc.entryFailReasonBySymbol || {};
|
|
1840
1893
|
// Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
|
|
1841
1894
|
// Отдельного вечернего ужесточения нет: проверка 23.10.2026 (первые 70% и последние 30% истории) показала,
|
|
1842
1895
|
// что порог ×1,25 после 19:00 МСК снижает итог на обеих частях — правила совпадают с бэктестом.
|
|
@@ -1846,10 +1899,13 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1846
1899
|
const minShortEdgePct = round(baseShortNeedPct, 3);
|
|
1847
1900
|
const reqBuyEdgeOf = (sym) => rowMap.get(sym)?.buyThresholdPct ?? round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
|
|
1848
1901
|
const reqShortEdgeOf = (sym) => rowMap.get(sym)?.shortThresholdPct ?? round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
|
|
1849
|
-
const
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
)
|
|
1902
|
+
const isExitCooldown = (sym) => Boolean(autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000));
|
|
1903
|
+
const isEntryFailCooldown = (sym) => Boolean(entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000));
|
|
1904
|
+
const isInCooldown = (sym) => isExitCooldown(sym) || isEntryFailCooldown(sym);
|
|
1905
|
+
// вход, отклонённый брокером из-за ценового коридора (код 30099), заблокирован в эту сторону до конца дня
|
|
1906
|
+
const todayMsk = new Date(nowTs + MSK).toISOString().slice(0, 10);
|
|
1907
|
+
const entryDayBlockMap = acc.entryDayBlockBySymbol || {};
|
|
1908
|
+
const isDayBlocked = (sym, side) => entryDayBlockMap[sym]?.day === todayMsk && entryDayBlockMap[sym]?.side === side;
|
|
1853
1909
|
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
|
|
1854
1910
|
// если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
|
|
1855
1911
|
// временно исключаем такую бумагу из новых входов!
|
|
@@ -1909,15 +1965,17 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1909
1965
|
const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
|
|
1910
1966
|
const nb = params.newsFilter ? blockingNews(6) : null;
|
|
1911
1967
|
|
|
1912
|
-
const
|
|
1968
|
+
const isRawSignalRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol));
|
|
1969
|
+
const cooldownSkipped = fc.rows.filter((r) => isRawSignalRow(r) && isExitCooldown(r.symbol));
|
|
1970
|
+
const entryFailSkipped = fc.rows.filter((r) => isRawSignalRow(r) && !isExitCooldown(r.symbol) && isEntryFailCooldown(r.symbol));
|
|
1913
1971
|
// Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
|
|
1914
1972
|
const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
|
|
1915
1973
|
const divBlocked = [];
|
|
1916
|
-
const entryTimeOk = timeOk && !edgeWarn;
|
|
1917
|
-
const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
|
|
1974
|
+
const entryTimeOk = timeOk && !edgeWarn && !exitsOnly;
|
|
1975
|
+
const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'buy') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
|
|
1918
1976
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
1919
1977
|
.sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
1920
|
-
const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && (!autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
|
|
1978
|
+
const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && (!autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'short') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
|
|
1921
1979
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
1922
1980
|
.sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
1923
1981
|
const nonShortableSkipped = [];
|
|
@@ -1980,8 +2038,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1980
2038
|
if (aQty > 0) {
|
|
1981
2039
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1982
2040
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1983
|
-
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
1984
|
-
const hitHardStopLoss = curGainPct <= -
|
|
2041
|
+
const profitLockReason = autoAdapt && STRATEGY_POLICY.profitLock ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
2042
|
+
const hitHardStopLoss = curGainPct <= -BROKER_STOP_LONG_PCT || Boolean(autoAdapt && r.regimeCrash);
|
|
1985
2043
|
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
|
|
1986
2044
|
const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
1987
2045
|
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && (r.expectedPct > curExitThr || holdThroughMinorDip));
|
|
@@ -2004,14 +2062,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2004
2062
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
2005
2063
|
reason: exitReason,
|
|
2006
2064
|
isStopLoss: hitHardStopLoss,
|
|
2065
|
+
decision: { bar: fc.bar, expectedPct: r.expectedPct, exitThresholdPct: curExitThr, heldMin: round(heldMin, 1), gainPct: round(curGainPct, 2), avgPrice: avgPx, day: r.day || null },
|
|
2007
2066
|
});
|
|
2008
2067
|
closing.add(sym);
|
|
2009
2068
|
}
|
|
2010
2069
|
} else if (aQty < 0) {
|
|
2011
2070
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
2012
2071
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
2013
|
-
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
2014
|
-
const hitHardStopLoss = curGainPct <= -
|
|
2072
|
+
const profitLockReason = autoAdapt && STRATEGY_POLICY.profitLock ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
2073
|
+
const hitHardStopLoss = curGainPct <= -BROKER_STOP_SHORT_PCT || Boolean(autoAdapt && r.regimeSqueeze);
|
|
2015
2074
|
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
|
|
2016
2075
|
const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
2017
2076
|
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && (r.expectedPct < -curExitThr || holdThroughMinorSpike));
|
|
@@ -2034,6 +2093,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2034
2093
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
2035
2094
|
reason: exitReason,
|
|
2036
2095
|
isStopLoss: hitHardStopLoss,
|
|
2096
|
+
decision: { bar: fc.bar, expectedPct: r.expectedPct, exitThresholdPct: -curExitThr, heldMin: round(heldMin, 1), gainPct: round(curGainPct, 2), avgPrice: avgPx, day: r.day || null },
|
|
2037
2097
|
});
|
|
2038
2098
|
closing.add(sym);
|
|
2039
2099
|
}
|
|
@@ -2070,7 +2130,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2070
2130
|
// Ротация остывшей позиции в мощного нового лидера (Opportunity Rotation):
|
|
2071
2131
|
// если на счёте занято >65% бюджета, а на рынке появился новый лидер с прогнозом >= +0.55% (в 2.4 раза сильнее текущей остывшей бумаги),
|
|
2072
2132
|
// освобождаем слот под лидера, чтобы капитал не застревал в вялых позициях
|
|
2073
|
-
if (!onlyEntries && autoAdapt && usedRub > budget * 0.65 && (buyCands.length > 0 || shortCands.length > 0)) {
|
|
2133
|
+
if (!onlyEntries && autoAdapt && usedRub > budget * 0.65 * (marginSizing ? leverage : 1) && (buyCands.length > 0 || shortCands.length > 0)) {
|
|
2074
2134
|
const bestNewPct = Math.max(
|
|
2075
2135
|
buyCands.length ? Math.abs(buyCands[0].expectedPct || 0) : 0,
|
|
2076
2136
|
shortCands.length ? Math.abs(shortCands[0].expectedPct || 0) : 0,
|
|
@@ -2123,7 +2183,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2123
2183
|
.filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
|
|
2124
2184
|
.reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
|
|
2125
2185
|
const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
|
|
2126
|
-
let availRub =
|
|
2186
|
+
let availRub = marginSizing
|
|
2187
|
+
? Math.max(0, budget * 0.99 * leverage - usedRub)
|
|
2188
|
+
: Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
|
|
2127
2189
|
|
|
2128
2190
|
// 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
|
|
2129
2191
|
const rawNewBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol)) && !(onlyEntries && activeOrderSymbols.has(r.symbol)));
|
|
@@ -2202,7 +2264,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2202
2264
|
];
|
|
2203
2265
|
// Если включено «Свободные рубли в LQDT» (cashSweep) и для новых сигналов не хватает свободных рублей,
|
|
2204
2266
|
// сначала автоматически продаём нужное число паёв фонда ликвидности LQDT, которые открыла стратегия (сверх неприкосновенного baselineQty)
|
|
2205
|
-
if (acc.cashSweep && acc.broker === 'tbank' && combinedNew.length > 0 && !closing.has('LQDT') && !activeOrderSymbols.has('LQDT')) {
|
|
2267
|
+
if (!collateral && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length > 0 && !closing.has('LQDT') && !activeOrderSymbols.has('LQDT')) {
|
|
2206
2268
|
const lqdtPos = heldMap.get('LQDT');
|
|
2207
2269
|
const lqdtAlgoQty = lqdtPos ? algoQtyOf('LQDT', lqdtPos.qty) : 0;
|
|
2208
2270
|
if (lqdtAlgoQty > 0 && lqdtPos.price > 0) {
|
|
@@ -2251,16 +2313,18 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2251
2313
|
remWeightSum = Math.max(0, remWeightSum - wCur);
|
|
2252
2314
|
remNew--;
|
|
2253
2315
|
const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
|
|
2254
|
-
|
|
2316
|
+
// множитель размера по эшелону: 1-й эшелон — плечо счёта, 2–3-й — половинный размер (policy.js)
|
|
2317
|
+
const tierMult = autoAdapt ? tierSizeMult(r.symbol, leverage) : 1;
|
|
2318
|
+
const rawSideCapRub = tierMult * (side === 'short'
|
|
2255
2319
|
? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
|
|
2256
|
-
: (isTier1 ? maxLongTier1Rub : maxLongTier2Rub);
|
|
2320
|
+
: (isTier1 ? maxLongTier1Rub : maxLongTier2Rub));
|
|
2257
2321
|
// Лимит от реального 10-минутного оборота стакана (Liquidity Cap): во 2-м эшелоне не входим объёмом больше 12% от 10-минутного оборота биржи,
|
|
2258
2322
|
// чтобы заявка не становилась «стенкой» для стаканных роботов и исполнялась целиком без проскальзывания
|
|
2259
2323
|
const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
|
|
2260
2324
|
const sideCapRub = (!isTier1 && barTurnoverRub > 0)
|
|
2261
2325
|
? Math.max(45000, Math.min(rawSideCapRub, barTurnoverRub * 0.12))
|
|
2262
2326
|
: rawSideCapRub;
|
|
2263
|
-
const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
|
|
2327
|
+
const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * tierMult * convOf(r));
|
|
2264
2328
|
const sum = Math.min(rawSum, propShare, availRub);
|
|
2265
2329
|
const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
|
|
2266
2330
|
const effOffsetPct = isTier1 ? Math.min(Number(acc.limitOffsetPct) || 0.05, 0.06) : Math.min(Number(acc.limitOffsetPct) || 0.04, 0.04);
|
|
@@ -2270,6 +2334,14 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2270
2334
|
orders.push({
|
|
2271
2335
|
...ord,
|
|
2272
2336
|
expectedPct: r.expectedPct,
|
|
2337
|
+
useMargin: marginSizing,
|
|
2338
|
+
decision: {
|
|
2339
|
+
bar: fc.bar, expectedPct: r.expectedPct,
|
|
2340
|
+
thresholdPct: side === 'buy' ? reqBuyEdgeOf(r.symbol) : reqShortEdgeOf(r.symbol),
|
|
2341
|
+
edgeMult: r.edgeMult, sigmaBarPct: r.sigmaPct, lastMovePct: r.lastMovePct, selfPct: r.selfPct,
|
|
2342
|
+
marketDown: Boolean(fc.marketDown), conv: round(convOf(r), 3), budgetRub: budget, targetRub: round(rawSum, 0), leverage, tierMult, collateral,
|
|
2343
|
+
stopPct: protectiveStopPct(side === 'buy' ? 'long' : 'short'), day: r.day || null, onlyEntries: Boolean(onlyEntries),
|
|
2344
|
+
},
|
|
2273
2345
|
reason: side === 'buy'
|
|
2274
2346
|
? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
|
|
2275
2347
|
: `Продажа без покупки: ожидание ${r.expectedPct}% за ${params.modelHorizon} ч`,
|
|
@@ -2280,16 +2352,20 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2280
2352
|
// При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых заявок:
|
|
2281
2353
|
// учитываем, что в Т-Банке сделка с LQDT облагается комиссией 0.04% на сторону (0.085% за круг со спредом), что окупается только за >= 2 дня удержания.
|
|
2282
2354
|
// Поэтому покупаем LQDT только перед выходными (пятница вечер после закрытия внутридневного окна входов) либо если на счёте не было сделок по акциям более 24 часов!
|
|
2283
|
-
if (!onlyEntries && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length === 0 && !activePendingOrders.length && !edgeWarn && !orders.length) {
|
|
2355
|
+
if (!onlyEntries && !exitsOnly && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length === 0 && !activePendingOrders.length && !edgeWarn && !orders.length) {
|
|
2284
2356
|
const mskNow = new Date(nowTs + MSK);
|
|
2285
2357
|
const isFridayNight = mskNow.getUTCDay() === 5 && !timeOk;
|
|
2286
2358
|
const histOpsAll = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
|
|
2287
2359
|
const hadStockTrade24h = histOpsAll.some((op) => (op.kind === 'buy' || op.kind === 'sell') && op.symbol && op.symbol !== 'LQDT' && op.symbol !== 'TMON@' && (nowTs - (op.date || 0) < DAY));
|
|
2288
|
-
|
|
2360
|
+
// В режиме залога LQDT под входы не продаётся, поэтому свободные рубли размещаются сразу, как только у стратегии нет открытых позиций
|
|
2361
|
+
// (пока позиции открыты, рубли от шортов нужны для их закрытия — иначе к ночи образуется заём с платой за перенос)
|
|
2362
|
+
const flatNow = ![...heldMap.values()].some((q) => q.symbol !== 'LQDT' && rowMap.has(q.symbol) && algoQtyOf(q.symbol, q.qty) !== 0 && !closing.has(q.symbol));
|
|
2363
|
+
if (collateral ? flatNow : (isFridayNight || !hadStockTrade24h)) {
|
|
2289
2364
|
const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.0001, decimals: 4 };
|
|
2290
2365
|
const lqdtPx = Number(heldMap.get('LQDT')?.price || lqdtSp.lastPrice || 0);
|
|
2291
2366
|
const reserveRub = Math.max(5000, round(budget * 0.03, 0));
|
|
2292
|
-
const
|
|
2367
|
+
const cashNowRub = acc.lastSync?.cashRub || 0;
|
|
2368
|
+
const sweepRub = collateral ? cashNowRub - reserveRub : Math.min(availRub - reserveRub, cashNowRub - reserveRub);
|
|
2293
2369
|
if (lqdtPx > 0 && sweepRub >= 20000) {
|
|
2294
2370
|
const ord = calcLotOrder('LQDT', sweepRub, lqdtPx, 'buy', 0.02, { ...specs, LQDT: lqdtSp });
|
|
2295
2371
|
if (ord) {
|
|
@@ -2316,7 +2392,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2316
2392
|
planThrottleNote = 'Тариф «VIP» (лимиты по договору): активны покупки и шорты с повышенной концентрацией слота.';
|
|
2317
2393
|
}
|
|
2318
2394
|
if (!orders.length) {
|
|
2319
|
-
if (
|
|
2395
|
+
if (exitsOnly) {
|
|
2396
|
+
diagNote = `${acc.haltedReason}: новые входы запрещены до конца дня, открытые позиции сопровождаются (выходы по сигналу, фиксация прибыли, стопы, закрытие перед ночью).`;
|
|
2397
|
+
} else if (!timeOk) {
|
|
2320
2398
|
diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
|
|
2321
2399
|
} else if (edgeWarn) {
|
|
2322
2400
|
diagNote = `На свече ${fc.bar} новые входы временно отложены: ${edgeWarn.toLowerCase()}.`;
|
|
@@ -2334,14 +2412,19 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2334
2412
|
diagNote = `Сигнал ${deferredByImpulse.map((d) => `${d.symbol} (${d.side === 'short' ? 'шорт' : 'покупка'}, текущая ${d.curPx} ₽)`).join(', ')} взят на прицел: ждём затухания встречного минутного импульса для входа по лучшей цене (проверка каждые 20 сек).`;
|
|
2335
2413
|
} else if (!newBuys.length && !newShorts.length) {
|
|
2336
2414
|
const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
2337
|
-
const
|
|
2415
|
+
const failNote = entryFailSkipped.length
|
|
2416
|
+
? ` Заявка по ${entryFailSkipped.map((r) => `${r.symbol} (${entryFailReasonMap[r.symbol] || 'отказ шлюза брокера'})`).join('; ')} временно отложена на 5 мин после ошибки приёма у брокера.`
|
|
2417
|
+
: '';
|
|
2418
|
+
const dayBlocked = fc.rows.filter((r) => (r.signal && isDayBlocked(r.symbol, 'buy')) || (r.shortSignal && isDayBlocked(r.symbol, 'short')));
|
|
2419
|
+
const dayBlockNote = dayBlocked.length ? ` Сигнал ${dayBlocked.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} пропущен до конца дня: брокер отклонил вход из-за ценового коридора — бумага у планки Мосбиржи.` : '';
|
|
2420
|
+
const cdNote = dayBlockNote + (cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '');
|
|
2338
2421
|
const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2339
2422
|
const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2340
2423
|
const thrRangeStr = dir !== 'long'
|
|
2341
2424
|
? `+${round(minBuyEdgePct, 2)}% / +${buyThrT2}% в покупку, −${round(minShortEdgePct, 2)}% / −${shortThrT2}% в шорт`
|
|
2342
2425
|
: `+${round(minBuyEdgePct, 2)}% (1-й эшелон) / +${buyThrT2}% (2–3-й эшелон)`;
|
|
2343
|
-
diagNote = cdNote
|
|
2344
|
-
? `На свече ${fc.bar} новых входов нет.${cdNote}${noShortNote}`
|
|
2426
|
+
diagNote = (failNote || cdNote)
|
|
2427
|
+
? `На свече ${fc.bar} новых входов нет.${failNote}${cdNote}${noShortNote}`
|
|
2345
2428
|
: `На свече ${fc.bar} ни одна доступная бумага не превысила индивидуальный порог выгоды (${thrRangeStr})${noShortNote}.`;
|
|
2346
2429
|
}
|
|
2347
2430
|
}
|
|
@@ -2422,6 +2505,9 @@ function formatTbankOrderError(status, rawText = '', ord = {}) {
|
|
|
2422
2505
|
if (code.includes('30052') || /short.*disabled|short.*not/i.test(code)) {
|
|
2423
2506
|
return 'По данной бумаге брокер временно запретил короткие продажи (шорт)';
|
|
2424
2507
|
}
|
|
2508
|
+
if (code.includes('30099') || /outside the limits/i.test(code)) {
|
|
2509
|
+
return 'Цена бумаги вышла за границы дневного ценового коридора у брокера (код 30099: Мосбиржа расширила планку, шлюз брокера ещё обновляет лимиты)';
|
|
2510
|
+
}
|
|
2425
2511
|
return `T-Invest PostOrder HTTP ${status}${msg ? `: ${msg}` : ''}${desc ? ` (код ${desc})` : ''}`;
|
|
2426
2512
|
}
|
|
2427
2513
|
|
|
@@ -2543,8 +2629,15 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2543
2629
|
}).catch(() => null);
|
|
2544
2630
|
if (rMax?.ok) {
|
|
2545
2631
|
const jMax = await rMax.json().catch(() => null);
|
|
2632
|
+
// покупка под залог (плечо / LQDT в обеспечении) ограничивается маржинальным лимитом брокера, а не свободными рублями
|
|
2633
|
+
// (закрытие шорта тоже: при залоге в LQDT свободных рублей может не хватать, а закрыть позицию нужно целиком)
|
|
2634
|
+
const cashBuyLots = Number(jMax?.buyLimits?.buyMaxLots);
|
|
2635
|
+
const marginBuyLots = Number(jMax?.buyMarginLimits?.buyMaxLots);
|
|
2636
|
+
const bestBuyLots = Math.max(Number.isFinite(cashBuyLots) ? cashBuyLots : -1, Number.isFinite(marginBuyLots) ? marginBuyLots : -1);
|
|
2546
2637
|
const maxLots = isBuy
|
|
2547
|
-
?
|
|
2638
|
+
? ((ord.useMargin || ord.action === 'cover') && bestBuyLots >= 0
|
|
2639
|
+
? bestBuyLots
|
|
2640
|
+
: Number(jMax?.buyLimits?.buyMaxLots ?? jMax?.buyMarginLimits?.buyMaxLots ?? lotsToOrder))
|
|
2548
2641
|
: Number((ord.action === 'short' ? jMax?.sellMarginLimits?.sellMaxLots : null) ?? jMax?.sellLimits?.sellMaxLots ?? lotsToOrder);
|
|
2549
2642
|
if (Number.isFinite(maxLots)) {
|
|
2550
2643
|
if (maxLots < 1) {
|
|
@@ -2580,7 +2673,8 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2580
2673
|
throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
|
|
2581
2674
|
}
|
|
2582
2675
|
const j = await r.json();
|
|
2583
|
-
logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ (сигнал ${ord.refPrice || finalLimitPx} ₽) · ${body.orderId}`);
|
|
2676
|
+
logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ (сигнал ${ord.refPrice || finalLimitPx} ₽) · ${body.orderId}${ord.reason ? ` · ${ord.reason}` : ''}`);
|
|
2677
|
+
logTradeDecision(acc, ord, { lots: lotsToOrder, limitPrice: finalLimitPx });
|
|
2584
2678
|
return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder, limitPrice: finalLimitPx };
|
|
2585
2679
|
}
|
|
2586
2680
|
if (acc.broker === 'finam') {
|
|
@@ -2754,12 +2848,33 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2754
2848
|
const acc = list.find((x) => x.id === id);
|
|
2755
2849
|
if (!acc) throw new Error('Счёт не найден');
|
|
2756
2850
|
if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
|
|
2757
|
-
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
2758
2851
|
const gate = tradingGate();
|
|
2759
2852
|
if (!gate.allowNew) throw new Error(gate.reason);
|
|
2760
2853
|
const batch = acc.pendingBatch;
|
|
2761
2854
|
if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
|
|
2762
2855
|
if (batch.status === 'executed' || batch.status === 'executing') return brokersState();
|
|
2856
|
+
if (acc.haltedReason && !(isDailyStopHalt(acc) && batch.orders.every(isExitOrder))) throw new Error(acc.haltedReason);
|
|
2857
|
+
// Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна входов или в окне начала сессии не отправляем
|
|
2858
|
+
const execCheckTs = Date.now();
|
|
2859
|
+
const staleWhy = (() => {
|
|
2860
|
+
if (execCheckTs - (batch.createdAt || 0) > 10 * 60000) return `сигнал устарел (пакет сформирован ${Math.round((execCheckTs - (batch.createdAt || 0)) / 60000)} мин назад)`;
|
|
2861
|
+
if (!canEnterIntraday(execCheckTs, barsFromHours(2.5), BAR_INTERVAL)) return 'окно новых входов уже закрыто';
|
|
2862
|
+
if (acc.skipEdgeMinutes && sessionEdgeWarning(execCheckTs)) return 'начало/конец сессии';
|
|
2863
|
+
if (isDailyStopHalt(acc)) return acc.haltedReason;
|
|
2864
|
+
return null;
|
|
2865
|
+
})();
|
|
2866
|
+
if (staleWhy && batch.orders.some((o) => !isExitOrder(o) && o.symbol !== 'LQDT')) {
|
|
2867
|
+
const dropped = batch.orders.filter((o) => !isExitOrder(o) && o.symbol !== 'LQDT');
|
|
2868
|
+
batch.orders = batch.orders.filter((o) => isExitOrder(o) || o.symbol === 'LQDT');
|
|
2869
|
+
logBrokerEvent(acc, 'order_skip', `входы не отправлены (${staleWhy}): ${dropped.map((o) => `${o.action} ${o.symbol}`).join(', ')}`);
|
|
2870
|
+
if (!batch.orders.length) {
|
|
2871
|
+
batch.status = 'expired';
|
|
2872
|
+
batch.diagNote = `Входы не отправлены: ${staleWhy}.`;
|
|
2873
|
+
saveRawAccounts(list);
|
|
2874
|
+
hub.emit({ type: 'brokers:update', state: brokersState() });
|
|
2875
|
+
throw new Error(`Входы не отправлены: ${staleWhy}`);
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2763
2878
|
// Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
|
|
2764
2879
|
// ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
|
|
2765
2880
|
for (const ord of batch.orders) {
|
|
@@ -2773,6 +2888,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2773
2888
|
acc.entryTsBySymbol = acc.entryTsBySymbol || {};
|
|
2774
2889
|
acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
|
|
2775
2890
|
acc.entryFailCooldownBySymbol = acc.entryFailCooldownBySymbol || {};
|
|
2891
|
+
acc.entryFailReasonBySymbol = acc.entryFailReasonBySymbol || {};
|
|
2776
2892
|
acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
|
|
2777
2893
|
const execNowTs = Date.now();
|
|
2778
2894
|
const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
|
|
@@ -2815,6 +2931,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2815
2931
|
acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
|
|
2816
2932
|
delete acc.exitCooldownBySymbol[ord.symbol];
|
|
2817
2933
|
delete acc.entryFailCooldownBySymbol[ord.symbol];
|
|
2934
|
+
delete acc.entryFailReasonBySymbol[ord.symbol];
|
|
2818
2935
|
} else if (ord.action === 'sell' || ord.action === 'cover') {
|
|
2819
2936
|
hadExits = true;
|
|
2820
2937
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
@@ -2831,6 +2948,15 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2831
2948
|
else ord.uncertainSend = true;
|
|
2832
2949
|
if (ord.action === 'buy' || ord.action === 'short') {
|
|
2833
2950
|
acc.entryFailCooldownBySymbol[ord.symbol] = execNowTs;
|
|
2951
|
+
acc.entryFailReasonBySymbol[ord.symbol] = e.message;
|
|
2952
|
+
// Брокер отклонил вход из-за ценового коридора (код 30099): бумага упирается в планку Мосбиржи —
|
|
2953
|
+
// вход в эту сторону блокируется до конца торгового дня, а не на 5 минут (повторные попытки идут против планки)
|
|
2954
|
+
if (String(e.message).includes('30099')) {
|
|
2955
|
+
const dayMsk = new Date(execNowTs + MSK).toISOString().slice(0, 10);
|
|
2956
|
+
acc.entryDayBlockBySymbol = Object.fromEntries(Object.entries(acc.entryDayBlockBySymbol || {}).filter(([, b]) => b?.day === dayMsk));
|
|
2957
|
+
acc.entryDayBlockBySymbol[ord.symbol] = { day: dayMsk, side: ord.action, reason: 'цена у границы ценового коридора Мосбиржи (отказ брокера, код 30099)' };
|
|
2958
|
+
logBrokerEvent(acc, 'entry_day_block', `${ord.action} ${ord.symbol}: вход заблокирован до конца дня — цена у границы ценового коридора (код 30099)`);
|
|
2959
|
+
}
|
|
2834
2960
|
}
|
|
2835
2961
|
results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
|
|
2836
2962
|
logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
|
|
@@ -3069,7 +3195,7 @@ async function tickBrokerSchedule() {
|
|
|
3069
3195
|
const plan = effectivePlan(u);
|
|
3070
3196
|
if ((plan.maxBrokerAccounts ?? 0) < 1) return;
|
|
3071
3197
|
for (const acc of list) {
|
|
3072
|
-
if (acc.autoSchedule === false || acc.haltedReason) continue;
|
|
3198
|
+
if (acc.autoSchedule === false || (acc.haltedReason && !isDailyStopHalt(acc))) continue;
|
|
3073
3199
|
const params = normalizeParams(getSettings());
|
|
3074
3200
|
const autoAdapt = params.modelAutoAdapt !== false;
|
|
3075
3201
|
const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
|
|
@@ -3105,7 +3231,7 @@ async function tickBrokerSchedule() {
|
|
|
3105
3231
|
const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
|
|
3106
3232
|
const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
|
|
3107
3233
|
const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
|
|
3108
|
-
if (acc.mode === 'auto' && acc.token && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
3234
|
+
if (acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
3109
3235
|
await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
|
|
3110
3236
|
}
|
|
3111
3237
|
}
|
|
@@ -3143,8 +3269,8 @@ async function resumeBrokerAccountsOnBoot() {
|
|
|
3143
3269
|
// • перед любой закрывающей заявкой по бумаге её стоп-заявка снимается (closeBrokerStopBeforeExit);
|
|
3144
3270
|
// • внутренний защитный стоп по бумаге со стоп-заявкой у брокера не выставляется;
|
|
3145
3271
|
// • трогаются только стоп-заявки, выставленные самой программой (их ID хранятся в brokerStopBySymbol).
|
|
3146
|
-
const BROKER_STOP_LONG_PCT =
|
|
3147
|
-
const BROKER_STOP_SHORT_PCT =
|
|
3272
|
+
const BROKER_STOP_LONG_PCT = protectiveStopPct('long');
|
|
3273
|
+
const BROKER_STOP_SHORT_PCT = protectiveStopPct('short');
|
|
3148
3274
|
const tbankBase = (acc) => (acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest');
|
|
3149
3275
|
const tbankHeaders = (acc) => ({ Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' });
|
|
3150
3276
|
const qPrice = (v) => { const units = Math.trunc(v); return { units: String(units), nano: Math.round((v - units) * 1e9) }; };
|
|
@@ -3313,13 +3439,14 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3313
3439
|
async function checkIntradayProfitLock(accId) {
|
|
3314
3440
|
const list = loadRawAccounts();
|
|
3315
3441
|
const acc = list.find((x) => x.id === accId);
|
|
3316
|
-
if (!acc || acc.mode !== 'auto' || !acc.token || acc.haltedReason) return;
|
|
3442
|
+
if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
|
|
3317
3443
|
if (!tradingGate().allowProtective) return;
|
|
3318
3444
|
const nowMsk = new Date(Date.now() + MSK);
|
|
3319
3445
|
const dow = nowMsk.getUTCDay();
|
|
3320
3446
|
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
3321
3447
|
if (dow === 0 || dow === 6 || mins < 6 * 60 + 55 || mins > 23 * 60 + 48) return;
|
|
3322
|
-
|
|
3448
|
+
// В окнах начала/конца сессии (широкий спред) фиксация прибыли не срабатывает на шуме стакана, а стопы работают всегда
|
|
3449
|
+
const edgeNow = Boolean(acc.skipEdgeMinutes && sessionEdgeWarning());
|
|
3323
3450
|
|
|
3324
3451
|
const positions = acc.lastSync?.positions || [];
|
|
3325
3452
|
if (!positions.length) return;
|
|
@@ -3366,7 +3493,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3366
3493
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
3367
3494
|
if (lots < 1) continue;
|
|
3368
3495
|
const curGainPct = ((curPx - avgPx) / avgPx) * 100;
|
|
3369
|
-
const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
|
|
3496
|
+
const profitLockReason = !edgeNow && STRATEGY_POLICY.profitLock ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
3370
3497
|
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
|
|
3371
3498
|
if (hitHardStopLoss || profitLockReason) {
|
|
3372
3499
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
@@ -3381,7 +3508,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3381
3508
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
3382
3509
|
if (lots < 1) continue;
|
|
3383
3510
|
const curGainPct = ((avgPx - curPx) / avgPx) * 100;
|
|
3384
|
-
const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
|
|
3511
|
+
const profitLockReason = !edgeNow && STRATEGY_POLICY.profitLock ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
3385
3512
|
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
|
|
3386
3513
|
if (hitHardStopLoss || profitLockReason) {
|
|
3387
3514
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|