neuroalpha 0.5.34 → 0.6.1
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 +69 -32
- package/public/index.html +4 -2
- package/server/api/routes.js +2 -1
- package/server/brokers/index.js +185 -70
- package/server/data/lqdt.js +28 -0
- package/server/model/edge.js +12 -0
- package/server/model/forecast.js +29 -4
- 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/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)
|
|
@@ -525,11 +572,17 @@ export function saveBrokerAccount(input = {}) {
|
|
|
525
572
|
exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
|
|
526
573
|
entryFailCooldownBySymbol: acctChanged ? {} : (prev.entryFailCooldownBySymbol || {}),
|
|
527
574
|
entryFailReasonBySymbol: acctChanged ? {} : (prev.entryFailReasonBySymbol || {}),
|
|
575
|
+
entryDayBlockBySymbol: acctChanged ? {} : (prev.entryDayBlockBySymbol || {}),
|
|
528
576
|
peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
|
|
529
577
|
baseRunId: nextBaseRunId,
|
|
530
578
|
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
531
579
|
maxCapitalRub,
|
|
532
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),
|
|
533
586
|
limitOffsetPct: nextLimitOffset,
|
|
534
587
|
skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
|
|
535
588
|
autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
|
|
@@ -575,6 +628,16 @@ export function resetBrokerDailyStop(id) {
|
|
|
575
628
|
const acc = list.find((x) => x.id === id);
|
|
576
629
|
if (!acc) throw new Error('Счёт не найден');
|
|
577
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
|
+
}
|
|
578
641
|
if (!acc.haltedReason.startsWith('Сработал дневной стоп')) throw new Error('Эту блокировку снять нельзя: ' + acc.haltedReason);
|
|
579
642
|
const today = new Date(Date.now() + MSK).toISOString().slice(0, 10);
|
|
580
643
|
logBrokerEvent(acc, 'daily_stop_reset', `снят вручную до конца дня (${acc.haltedReason})`);
|
|
@@ -1527,6 +1590,17 @@ export async function syncBrokerAccount(id) {
|
|
|
1527
1590
|
} else {
|
|
1528
1591
|
acc.ddBreachCount = 0;
|
|
1529
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
|
+
}
|
|
1530
1604
|
// История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
|
|
1531
1605
|
const hist = Array.isArray(acc.equityHistory) ? acc.equityHistory : [];
|
|
1532
1606
|
const nowTs = Date.now();
|
|
@@ -1612,46 +1686,17 @@ export async function freezeBrokerBaseline(id) {
|
|
|
1612
1686
|
return st;
|
|
1613
1687
|
}
|
|
1614
1688
|
|
|
1615
|
-
//
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
const tierMult = symbolEdgeMult(sym);
|
|
1625
|
-
// Минимальный чистый порог фиксации с учётом спреда стакана и двойной комиссии (чтобы выход не закрывался в 0.00% после исполнения лимитки)
|
|
1626
|
-
const minNetFloorPct = Math.max(roundTripFeePct + 0.30, round(0.38 * tierMult, 2));
|
|
1627
|
-
// 1) Скользящий Take-Profit при сильном перевыполнении цели (в первые 15 мин требуем более сильный импульс 1.55×tierMult, далее 1.20×tierMult):
|
|
1628
|
-
// фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
|
|
1629
|
-
const tpTriggerPct = (heldMin < 15 ? 1.55 : 1.20) * tierMult;
|
|
1630
|
-
if (peakGainPct >= tpTriggerPct) {
|
|
1631
|
-
const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, minNetFloorPct);
|
|
1632
|
-
if (curGainPct <= trailFloorPct && curGainPct >= minNetFloorPct) {
|
|
1633
|
-
return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
// 2) Защита заработанного плюса / безубытка (после первых 15 минут удержания, от +0.85% на 1-м эшелоне и от +1.02% на 2-м эшелоне):
|
|
1637
|
-
// не позволяем зрелой плюсовой сделке развернуться обратно в убыток, но и не режем позицию стартовым шумом стакана в первые минуты
|
|
1638
|
-
if (heldMin >= 15 && peakGainPct >= 0.85 * tierMult) {
|
|
1639
|
-
const breakevenFloorPct = Math.max(minNetFloorPct, peakGainPct * 0.45);
|
|
1640
|
-
if (curGainPct <= breakevenFloorPct && curGainPct >= roundTripFeePct + 0.22) {
|
|
1641
|
-
return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
return null;
|
|
1645
|
-
}
|
|
1646
|
-
function sessionEdgeWarning(now = Date.now()) {
|
|
1647
|
-
const d = new Date(now + MSK);
|
|
1648
|
-
const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
1649
|
-
// открытие утренней сессии 06:50–07:25 (отыгрыш ночных гэпов и формирование стакана), открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
|
|
1650
|
-
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)) {
|
|
1651
|
-
return 'Первые минуты утренней/основной сессии или закрытие дневной (широкий спред и утренние гэпы в стакане)';
|
|
1652
|
-
}
|
|
1653
|
-
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;
|
|
1654
1698
|
}
|
|
1699
|
+
const isExitOrder = (o) => o.action === 'sell' || o.action === 'cover';
|
|
1655
1700
|
|
|
1656
1701
|
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
1657
1702
|
const ACCOUNT_LOCK_TTL_MS = 75000;
|
|
@@ -1768,9 +1813,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1768
1813
|
let list = loadRawAccounts();
|
|
1769
1814
|
let acc = list.find((x) => x.id === id);
|
|
1770
1815
|
if (!acc) throw new Error('Счёт не найден');
|
|
1771
|
-
|
|
1816
|
+
const exitsOnly = isDailyStopHalt(acc);
|
|
1817
|
+
if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
|
|
1772
1818
|
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
1773
|
-
if (onlyEntries && edgeWarn) {
|
|
1819
|
+
if (onlyEntries && (edgeWarn || exitsOnly)) {
|
|
1774
1820
|
return brokersState();
|
|
1775
1821
|
}
|
|
1776
1822
|
|
|
@@ -1826,6 +1872,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1826
1872
|
const vipBoost = Boolean(plan.allowVipBoost);
|
|
1827
1873
|
const rawDir = params.modelDirection || 'long';
|
|
1828
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);
|
|
1829
1880
|
// Единые пороги удержания с эталонным симулятором (research.js): первые 65 минут — мягкий порог 0.35, затем плотный порог 0.95 (с учётом эшелона)
|
|
1830
1881
|
const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
|
|
1831
1882
|
const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
|
|
@@ -1851,6 +1902,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1851
1902
|
const isExitCooldown = (sym) => Boolean(autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000));
|
|
1852
1903
|
const isEntryFailCooldown = (sym) => Boolean(entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000));
|
|
1853
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;
|
|
1854
1909
|
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
|
|
1855
1910
|
// если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
|
|
1856
1911
|
// временно исключаем такую бумагу из новых входов!
|
|
@@ -1916,11 +1971,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1916
1971
|
// Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
|
|
1917
1972
|
const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
|
|
1918
1973
|
const divBlocked = [];
|
|
1919
|
-
const entryTimeOk = timeOk && !edgeWarn;
|
|
1920
|
-
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)
|
|
1921
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; })
|
|
1922
1977
|
.sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
1923
|
-
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)
|
|
1924
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; })
|
|
1925
1980
|
.sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
1926
1981
|
const nonShortableSkipped = [];
|
|
@@ -1983,8 +2038,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1983
2038
|
if (aQty > 0) {
|
|
1984
2039
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1985
2040
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1986
|
-
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
1987
|
-
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);
|
|
1988
2043
|
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
|
|
1989
2044
|
const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
1990
2045
|
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && (r.expectedPct > curExitThr || holdThroughMinorDip));
|
|
@@ -2007,14 +2062,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2007
2062
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
2008
2063
|
reason: exitReason,
|
|
2009
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 },
|
|
2010
2066
|
});
|
|
2011
2067
|
closing.add(sym);
|
|
2012
2068
|
}
|
|
2013
2069
|
} else if (aQty < 0) {
|
|
2014
2070
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
2015
2071
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
2016
|
-
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
2017
|
-
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);
|
|
2018
2074
|
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
|
|
2019
2075
|
const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
2020
2076
|
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && (r.expectedPct < -curExitThr || holdThroughMinorSpike));
|
|
@@ -2037,6 +2093,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2037
2093
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
2038
2094
|
reason: exitReason,
|
|
2039
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 },
|
|
2040
2097
|
});
|
|
2041
2098
|
closing.add(sym);
|
|
2042
2099
|
}
|
|
@@ -2073,7 +2130,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2073
2130
|
// Ротация остывшей позиции в мощного нового лидера (Opportunity Rotation):
|
|
2074
2131
|
// если на счёте занято >65% бюджета, а на рынке появился новый лидер с прогнозом >= +0.55% (в 2.4 раза сильнее текущей остывшей бумаги),
|
|
2075
2132
|
// освобождаем слот под лидера, чтобы капитал не застревал в вялых позициях
|
|
2076
|
-
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)) {
|
|
2077
2134
|
const bestNewPct = Math.max(
|
|
2078
2135
|
buyCands.length ? Math.abs(buyCands[0].expectedPct || 0) : 0,
|
|
2079
2136
|
shortCands.length ? Math.abs(shortCands[0].expectedPct || 0) : 0,
|
|
@@ -2126,7 +2183,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2126
2183
|
.filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
|
|
2127
2184
|
.reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
|
|
2128
2185
|
const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
|
|
2129
|
-
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));
|
|
2130
2189
|
|
|
2131
2190
|
// 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
|
|
2132
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)));
|
|
@@ -2205,7 +2264,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2205
2264
|
];
|
|
2206
2265
|
// Если включено «Свободные рубли в LQDT» (cashSweep) и для новых сигналов не хватает свободных рублей,
|
|
2207
2266
|
// сначала автоматически продаём нужное число паёв фонда ликвидности LQDT, которые открыла стратегия (сверх неприкосновенного baselineQty)
|
|
2208
|
-
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')) {
|
|
2209
2268
|
const lqdtPos = heldMap.get('LQDT');
|
|
2210
2269
|
const lqdtAlgoQty = lqdtPos ? algoQtyOf('LQDT', lqdtPos.qty) : 0;
|
|
2211
2270
|
if (lqdtAlgoQty > 0 && lqdtPos.price > 0) {
|
|
@@ -2254,16 +2313,18 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2254
2313
|
remWeightSum = Math.max(0, remWeightSum - wCur);
|
|
2255
2314
|
remNew--;
|
|
2256
2315
|
const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
|
|
2257
|
-
|
|
2316
|
+
// множитель размера по эшелону: 1-й эшелон — плечо счёта, 2–3-й — половинный размер (policy.js)
|
|
2317
|
+
const tierMult = autoAdapt ? tierSizeMult(r.symbol, leverage) : 1;
|
|
2318
|
+
const rawSideCapRub = tierMult * (side === 'short'
|
|
2258
2319
|
? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
|
|
2259
|
-
: (isTier1 ? maxLongTier1Rub : maxLongTier2Rub);
|
|
2320
|
+
: (isTier1 ? maxLongTier1Rub : maxLongTier2Rub));
|
|
2260
2321
|
// Лимит от реального 10-минутного оборота стакана (Liquidity Cap): во 2-м эшелоне не входим объёмом больше 12% от 10-минутного оборота биржи,
|
|
2261
2322
|
// чтобы заявка не становилась «стенкой» для стаканных роботов и исполнялась целиком без проскальзывания
|
|
2262
2323
|
const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
|
|
2263
2324
|
const sideCapRub = (!isTier1 && barTurnoverRub > 0)
|
|
2264
2325
|
? Math.max(45000, Math.min(rawSideCapRub, barTurnoverRub * 0.12))
|
|
2265
2326
|
: rawSideCapRub;
|
|
2266
|
-
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));
|
|
2267
2328
|
const sum = Math.min(rawSum, propShare, availRub);
|
|
2268
2329
|
const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
|
|
2269
2330
|
const effOffsetPct = isTier1 ? Math.min(Number(acc.limitOffsetPct) || 0.05, 0.06) : Math.min(Number(acc.limitOffsetPct) || 0.04, 0.04);
|
|
@@ -2273,6 +2334,14 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2273
2334
|
orders.push({
|
|
2274
2335
|
...ord,
|
|
2275
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
|
+
},
|
|
2276
2345
|
reason: side === 'buy'
|
|
2277
2346
|
? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
|
|
2278
2347
|
: `Продажа без покупки: ожидание ${r.expectedPct}% за ${params.modelHorizon} ч`,
|
|
@@ -2283,16 +2352,20 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2283
2352
|
// При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых заявок:
|
|
2284
2353
|
// учитываем, что в Т-Банке сделка с LQDT облагается комиссией 0.04% на сторону (0.085% за круг со спредом), что окупается только за >= 2 дня удержания.
|
|
2285
2354
|
// Поэтому покупаем LQDT только перед выходными (пятница вечер после закрытия внутридневного окна входов) либо если на счёте не было сделок по акциям более 24 часов!
|
|
2286
|
-
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) {
|
|
2287
2356
|
const mskNow = new Date(nowTs + MSK);
|
|
2288
2357
|
const isFridayNight = mskNow.getUTCDay() === 5 && !timeOk;
|
|
2289
2358
|
const histOpsAll = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
|
|
2290
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));
|
|
2291
|
-
|
|
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)) {
|
|
2292
2364
|
const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.0001, decimals: 4 };
|
|
2293
2365
|
const lqdtPx = Number(heldMap.get('LQDT')?.price || lqdtSp.lastPrice || 0);
|
|
2294
2366
|
const reserveRub = Math.max(5000, round(budget * 0.03, 0));
|
|
2295
|
-
const
|
|
2367
|
+
const cashNowRub = acc.lastSync?.cashRub || 0;
|
|
2368
|
+
const sweepRub = collateral ? cashNowRub - reserveRub : Math.min(availRub - reserveRub, cashNowRub - reserveRub);
|
|
2296
2369
|
if (lqdtPx > 0 && sweepRub >= 20000) {
|
|
2297
2370
|
const ord = calcLotOrder('LQDT', sweepRub, lqdtPx, 'buy', 0.02, { ...specs, LQDT: lqdtSp });
|
|
2298
2371
|
if (ord) {
|
|
@@ -2319,7 +2392,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2319
2392
|
planThrottleNote = 'Тариф «VIP» (лимиты по договору): активны покупки и шорты с повышенной концентрацией слота.';
|
|
2320
2393
|
}
|
|
2321
2394
|
if (!orders.length) {
|
|
2322
|
-
if (
|
|
2395
|
+
if (exitsOnly) {
|
|
2396
|
+
diagNote = `${acc.haltedReason}: новые входы запрещены до конца дня, открытые позиции сопровождаются (выходы по сигналу, фиксация прибыли, стопы, закрытие перед ночью).`;
|
|
2397
|
+
} else if (!timeOk) {
|
|
2323
2398
|
diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
|
|
2324
2399
|
} else if (edgeWarn) {
|
|
2325
2400
|
diagNote = `На свече ${fc.bar} новые входы временно отложены: ${edgeWarn.toLowerCase()}.`;
|
|
@@ -2340,7 +2415,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2340
2415
|
const failNote = entryFailSkipped.length
|
|
2341
2416
|
? ` Заявка по ${entryFailSkipped.map((r) => `${r.symbol} (${entryFailReasonMap[r.symbol] || 'отказ шлюза брокера'})`).join('; ')} временно отложена на 5 мин после ошибки приёма у брокера.`
|
|
2342
2417
|
: '';
|
|
2343
|
-
const
|
|
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 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '');
|
|
2344
2421
|
const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2345
2422
|
const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2346
2423
|
const thrRangeStr = dir !== 'long'
|
|
@@ -2552,8 +2629,15 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2552
2629
|
}).catch(() => null);
|
|
2553
2630
|
if (rMax?.ok) {
|
|
2554
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);
|
|
2555
2637
|
const maxLots = isBuy
|
|
2556
|
-
?
|
|
2638
|
+
? ((ord.useMargin || ord.action === 'cover') && bestBuyLots >= 0
|
|
2639
|
+
? bestBuyLots
|
|
2640
|
+
: Number(jMax?.buyLimits?.buyMaxLots ?? jMax?.buyMarginLimits?.buyMaxLots ?? lotsToOrder))
|
|
2557
2641
|
: Number((ord.action === 'short' ? jMax?.sellMarginLimits?.sellMaxLots : null) ?? jMax?.sellLimits?.sellMaxLots ?? lotsToOrder);
|
|
2558
2642
|
if (Number.isFinite(maxLots)) {
|
|
2559
2643
|
if (maxLots < 1) {
|
|
@@ -2589,7 +2673,8 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2589
2673
|
throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
|
|
2590
2674
|
}
|
|
2591
2675
|
const j = await r.json();
|
|
2592
|
-
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 });
|
|
2593
2678
|
return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder, limitPrice: finalLimitPx };
|
|
2594
2679
|
}
|
|
2595
2680
|
if (acc.broker === 'finam') {
|
|
@@ -2763,12 +2848,33 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2763
2848
|
const acc = list.find((x) => x.id === id);
|
|
2764
2849
|
if (!acc) throw new Error('Счёт не найден');
|
|
2765
2850
|
if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
|
|
2766
|
-
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
2767
2851
|
const gate = tradingGate();
|
|
2768
2852
|
if (!gate.allowNew) throw new Error(gate.reason);
|
|
2769
2853
|
const batch = acc.pendingBatch;
|
|
2770
2854
|
if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
|
|
2771
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
|
+
}
|
|
2772
2878
|
// Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
|
|
2773
2879
|
// ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
|
|
2774
2880
|
for (const ord of batch.orders) {
|
|
@@ -2843,6 +2949,14 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2843
2949
|
if (ord.action === 'buy' || ord.action === 'short') {
|
|
2844
2950
|
acc.entryFailCooldownBySymbol[ord.symbol] = execNowTs;
|
|
2845
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
|
+
}
|
|
2846
2960
|
}
|
|
2847
2961
|
results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
|
|
2848
2962
|
logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
|
|
@@ -3081,7 +3195,7 @@ async function tickBrokerSchedule() {
|
|
|
3081
3195
|
const plan = effectivePlan(u);
|
|
3082
3196
|
if ((plan.maxBrokerAccounts ?? 0) < 1) return;
|
|
3083
3197
|
for (const acc of list) {
|
|
3084
|
-
if (acc.autoSchedule === false || acc.haltedReason) continue;
|
|
3198
|
+
if (acc.autoSchedule === false || (acc.haltedReason && !isDailyStopHalt(acc))) continue;
|
|
3085
3199
|
const params = normalizeParams(getSettings());
|
|
3086
3200
|
const autoAdapt = params.modelAutoAdapt !== false;
|
|
3087
3201
|
const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
|
|
@@ -3117,7 +3231,7 @@ async function tickBrokerSchedule() {
|
|
|
3117
3231
|
const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
|
|
3118
3232
|
const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
|
|
3119
3233
|
const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
|
|
3120
|
-
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) {
|
|
3121
3235
|
await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
|
|
3122
3236
|
}
|
|
3123
3237
|
}
|
|
@@ -3155,8 +3269,8 @@ async function resumeBrokerAccountsOnBoot() {
|
|
|
3155
3269
|
// • перед любой закрывающей заявкой по бумаге её стоп-заявка снимается (closeBrokerStopBeforeExit);
|
|
3156
3270
|
// • внутренний защитный стоп по бумаге со стоп-заявкой у брокера не выставляется;
|
|
3157
3271
|
// • трогаются только стоп-заявки, выставленные самой программой (их ID хранятся в brokerStopBySymbol).
|
|
3158
|
-
const BROKER_STOP_LONG_PCT =
|
|
3159
|
-
const BROKER_STOP_SHORT_PCT =
|
|
3272
|
+
const BROKER_STOP_LONG_PCT = protectiveStopPct('long');
|
|
3273
|
+
const BROKER_STOP_SHORT_PCT = protectiveStopPct('short');
|
|
3160
3274
|
const tbankBase = (acc) => (acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest');
|
|
3161
3275
|
const tbankHeaders = (acc) => ({ Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' });
|
|
3162
3276
|
const qPrice = (v) => { const units = Math.trunc(v); return { units: String(units), nano: Math.round((v - units) * 1e9) }; };
|
|
@@ -3325,13 +3439,14 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3325
3439
|
async function checkIntradayProfitLock(accId) {
|
|
3326
3440
|
const list = loadRawAccounts();
|
|
3327
3441
|
const acc = list.find((x) => x.id === accId);
|
|
3328
|
-
if (!acc || acc.mode !== 'auto' || !acc.token || acc.haltedReason) return;
|
|
3442
|
+
if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
|
|
3329
3443
|
if (!tradingGate().allowProtective) return;
|
|
3330
3444
|
const nowMsk = new Date(Date.now() + MSK);
|
|
3331
3445
|
const dow = nowMsk.getUTCDay();
|
|
3332
3446
|
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
3333
3447
|
if (dow === 0 || dow === 6 || mins < 6 * 60 + 55 || mins > 23 * 60 + 48) return;
|
|
3334
|
-
|
|
3448
|
+
// В окнах начала/конца сессии (широкий спред) фиксация прибыли не срабатывает на шуме стакана, а стопы работают всегда
|
|
3449
|
+
const edgeNow = Boolean(acc.skipEdgeMinutes && sessionEdgeWarning());
|
|
3335
3450
|
|
|
3336
3451
|
const positions = acc.lastSync?.positions || [];
|
|
3337
3452
|
if (!positions.length) return;
|
|
@@ -3378,7 +3493,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3378
3493
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
3379
3494
|
if (lots < 1) continue;
|
|
3380
3495
|
const curGainPct = ((curPx - avgPx) / avgPx) * 100;
|
|
3381
|
-
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;
|
|
3382
3497
|
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
|
|
3383
3498
|
if (hitHardStopLoss || profitLockReason) {
|
|
3384
3499
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
@@ -3393,7 +3508,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3393
3508
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
3394
3509
|
if (lots < 1) continue;
|
|
3395
3510
|
const curGainPct = ((avgPx - curPx) / avgPx) * 100;
|
|
3396
|
-
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;
|
|
3397
3512
|
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
|
|
3398
3513
|
if (hitHardStopLoss || profitLockReason) {
|
|
3399
3514
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
+
// Дневная история цен фонда денежного рынка LQDT (MOEX ISS) — доход капитала, который стратегия держит в залоге.
|
|
3
|
+
// Фонд переводился между режимами торгов (TQTF → TQBR с 22.06.2026), поэтому история берётся по всем режимам рынка акций.
|
|
4
|
+
import { kvGet, kvSet } from '../db.js';
|
|
5
|
+
|
|
6
|
+
const DAY = 86400000;
|
|
7
|
+
|
|
8
|
+
export async function ensureLqdtHistory({ fromDate = '2024-01-01', signal } = {}) {
|
|
9
|
+
const cached = kvGet('lqdtDaily');
|
|
10
|
+
if (cached?.rows?.length && Date.now() - (cached.at || 0) < DAY) return cached.rows;
|
|
11
|
+
try {
|
|
12
|
+
const byDate = new Map();
|
|
13
|
+
for (let start = 0; start < 10000; start += 100) {
|
|
14
|
+
const r = await fetch(`https://iss.moex.com/iss/history/engines/stock/markets/shares/securities/LQDT.json?from=${fromDate}&iss.meta=off&history.columns=BOARDID,TRADEDATE,CLOSE&start=${start}`, { signal: signal || AbortSignal.timeout(20000) });
|
|
15
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
16
|
+
const j = await r.json();
|
|
17
|
+
const data = j.history?.data || [];
|
|
18
|
+
for (const [, d, c] of data) if (c > 0 && !byDate.has(d)) byDate.set(d, c);
|
|
19
|
+
if (data.length < 100) break;
|
|
20
|
+
}
|
|
21
|
+
// цена закрытия дня d известна к концу основной сессии
|
|
22
|
+
const rows = [...byDate].map(([d, c]) => ({ ts: Date.parse(`${d}T18:50:00+03:00`), close: c })).sort((a, b) => a.ts - b.ts);
|
|
23
|
+
if (rows.length) kvSet('lqdtDaily', { at: Date.now(), rows });
|
|
24
|
+
return rows.length ? rows : (cached?.rows || []);
|
|
25
|
+
} catch {
|
|
26
|
+
return cached?.rows || [];
|
|
27
|
+
}
|
|
28
|
+
}
|
package/server/model/edge.js
CHANGED
|
@@ -35,6 +35,18 @@ export function symbolEdgeMult(symbol, { sigma = 0, avgSigma = 0, avgBarValRub =
|
|
|
35
35
|
return Math.round(base * (1 + liqAdj2) * 1000) / 1000;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Фильтр падающего рынка: индекс Мосбиржи в среднесрочном нисходящем тренде (ниже уровня 10 торговых дней назад)
|
|
39
|
+
// или в сильном локальном обвале (ниже уровня дня и 3 дней назад на >1%)
|
|
40
|
+
export function isMarketDown(ds, t) {
|
|
41
|
+
const bpd = Math.max(1, Math.round(ds.barsPerDay || 54));
|
|
42
|
+
const d10 = bpd * 10, d3 = bpd * 3;
|
|
43
|
+
if (t < d3) return false;
|
|
44
|
+
const cur = ds.mclose[t], p3 = ds.mclose[t - d3], pd = ds.mclose[t - bpd];
|
|
45
|
+
if (!(cur > 0 && p3 > 0 && pd > 0)) return false;
|
|
46
|
+
if (t >= d10 && ds.mclose[t - d10] > 0 && cur < ds.mclose[t - d10] * 0.99) return true;
|
|
47
|
+
return cur < pd * 0.99 && cur < p3 * 0.99;
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
// Пороги защиты от структурного слома режима (взрывного шорт-сквиза ≥20% за торговый день или отвесного обвала ≥9,5% за 30 минут):
|
|
39
51
|
export const REGIME_SHOCK_1D = 0.20;
|
|
40
52
|
export const REGIME_CRASH_30M = 0.095;
|