neuroalpha 0.5.27 → 0.5.29
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/package.json +1 -1
- package/public/app.js +260 -151
- package/server/api/routes.js +42 -3
- package/server/brokers/index.js +230 -73
- package/server/model/dataset.js +15 -3
- package/server/model/forecast.js +50 -33
- package/server/notify/index.js +11 -3
package/server/brokers/index.js
CHANGED
|
@@ -79,13 +79,29 @@ export const BROKER_META = [
|
|
|
79
79
|
|
|
80
80
|
// ---------- справочник лотов и шага цены Мосбиржи (TQBR) ----------
|
|
81
81
|
const SPECS_URL = 'https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=securities,marketdata';
|
|
82
|
+
|
|
83
|
+
function withWallTimeout(promise, ms, label = 'Превышено время ожидания') {
|
|
84
|
+
let timer = null;
|
|
85
|
+
return Promise.race([
|
|
86
|
+
promise,
|
|
87
|
+
new Promise((_, reject) => {
|
|
88
|
+
timer = setTimeout(() => reject(new Error(label)), ms);
|
|
89
|
+
timer.unref?.();
|
|
90
|
+
}),
|
|
91
|
+
]).finally(() => {
|
|
92
|
+
if (timer) clearTimeout(timer);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
export async function ensureBoardSpecs() {
|
|
83
97
|
const cached = kvGet('moexSpecs');
|
|
84
98
|
if (cached?.specs && cached?.v === 2 && Date.now() - cached.at < DAY) return cached.specs;
|
|
85
99
|
try {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
const j = await withWallTimeout((async () => {
|
|
101
|
+
const r = await fetch(SPECS_URL, { signal: AbortSignal.timeout(25000) });
|
|
102
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
103
|
+
return r.json();
|
|
104
|
+
})(), 26000, 'Таймаут загрузки справочника TQBR');
|
|
89
105
|
const sc = j.securities.columns, mc = j.marketdata.columns;
|
|
90
106
|
const is = (k) => sc.indexOf(k), im = (k) => mc.indexOf(k);
|
|
91
107
|
const prices = new Map();
|
|
@@ -489,6 +505,7 @@ export function saveBrokerAccount(input = {}) {
|
|
|
489
505
|
lastHistory: acctChanged ? null : (prev.lastHistory || null),
|
|
490
506
|
entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
|
|
491
507
|
exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
|
|
508
|
+
entryFailCooldownBySymbol: acctChanged ? {} : (prev.entryFailCooldownBySymbol || {}),
|
|
492
509
|
peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
|
|
493
510
|
baseRunId: nextBaseRunId,
|
|
494
511
|
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
@@ -751,8 +768,8 @@ export async function fetchTbankRealtimePrices() {
|
|
|
751
768
|
}
|
|
752
769
|
}
|
|
753
770
|
|
|
754
|
-
// Возвращает свежие
|
|
755
|
-
export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false } = {}) {
|
|
771
|
+
// Возвращает свежие свечи акции из T-Invest API (GetCandles, 0 секунд задержки, без 20-минутного отставания бесплатного MOEX ISS)
|
|
772
|
+
export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { includeIncomplete = false, intervalMin = 10 } = {}) {
|
|
756
773
|
try {
|
|
757
774
|
const rawAccs = loadRawAccounts();
|
|
758
775
|
const tbAcc = rawAccs.find((a) => a.broker === 'tbank' && a.token && !a.sandbox) || rawAccs.find((a) => a.broker === 'tbank' && a.token);
|
|
@@ -764,15 +781,17 @@ export async function fetchTbankCandles(symbol, fromTs, tillTs = Date.now(), { i
|
|
|
764
781
|
const lotSize = Math.max(1, Number(shortMap?.specMap?.[symbol]?.lotSize) || 1);
|
|
765
782
|
const base = tbAcc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
|
|
766
783
|
const headers = { Authorization: `Bearer ${tbAcc.token}`, 'Content-Type': 'application/json' };
|
|
767
|
-
|
|
768
|
-
|
|
784
|
+
const is1m = Number(intervalMin) === 1;
|
|
785
|
+
// Для CANDLE_INTERVAL_1_MIN максимальный диапазон одного запроса в T-Invest API — 1 сутки, для CANDLE_INTERVAL_10_MIN — 3 суток
|
|
786
|
+
const maxSpanMs = is1m ? (86400000 - 60000) : (3 * 86400000 - 60000);
|
|
787
|
+
const safeFrom = Math.max(fromTs, tillTs - maxSpanMs);
|
|
769
788
|
if (safeFrom >= tillTs) return null;
|
|
770
789
|
const url = `${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetCandles`;
|
|
771
790
|
const body = JSON.stringify({
|
|
772
791
|
instrumentId: uid,
|
|
773
792
|
from: new Date(safeFrom).toISOString(),
|
|
774
793
|
to: new Date(tillTs).toISOString(),
|
|
775
|
-
interval: 'CANDLE_INTERVAL_10_MIN',
|
|
794
|
+
interval: is1m ? 'CANDLE_INTERVAL_1_MIN' : 'CANDLE_INTERVAL_10_MIN',
|
|
776
795
|
});
|
|
777
796
|
let r = await brokerFetch(url, { method: 'POST', headers, body, timeoutMs: 5000 });
|
|
778
797
|
if (r.status === 429 || r.status >= 500) {
|
|
@@ -965,10 +984,13 @@ async function syncTbank(acc) {
|
|
|
965
984
|
}
|
|
966
985
|
}
|
|
967
986
|
}
|
|
968
|
-
|
|
969
|
-
|
|
987
|
+
const p = await rPort.json();
|
|
988
|
+
const hasShortPos = (p.positions || []).some((pos) => qVal(pos.quantity) < 0);
|
|
989
|
+
let marginEnabled = Boolean(acc.lastSync?.marginEnabled || hasShortPos);
|
|
990
|
+
let marginInfo = acc.lastSync?.marginInfo || null;
|
|
970
991
|
if (accountType === 'ACCOUNT_TYPE_TINKOFF_IIS' || accountType === 'ACCOUNT_TYPE_INVEST_BOX') {
|
|
971
992
|
marginEnabled = false;
|
|
993
|
+
marginInfo = null;
|
|
972
994
|
} else if (rMargin?.ok) {
|
|
973
995
|
marginEnabled = true;
|
|
974
996
|
const jMar = await rMargin.json().catch(() => null);
|
|
@@ -990,9 +1012,11 @@ async function syncTbank(acc) {
|
|
|
990
1012
|
}
|
|
991
1013
|
} else if (rMargin) {
|
|
992
1014
|
const mTxt = await rMargin.text().catch(() => '');
|
|
993
|
-
|
|
1015
|
+
if (mTxt.includes('30051') || /margin.*disabled/i.test(mTxt)) {
|
|
1016
|
+
marginEnabled = false;
|
|
1017
|
+
marginInfo = null;
|
|
1018
|
+
}
|
|
994
1019
|
}
|
|
995
|
-
const p = await rPort.json();
|
|
996
1020
|
const jOrd = rOrd?.ok ? await rOrd.json().catch(() => null) : null;
|
|
997
1021
|
let rawOrders = jOrd?.orders || [];
|
|
998
1022
|
|
|
@@ -1464,10 +1488,17 @@ export async function syncBrokerAccount(id) {
|
|
|
1464
1488
|
}
|
|
1465
1489
|
const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
|
|
1466
1490
|
const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
|
|
1467
|
-
const
|
|
1491
|
+
const rawDdPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
|
|
1492
|
+
// Защита от ложного срабатывания стопа (−99%) при утреннем клиринге брокера, когда API временно отдаёт totalAmountPortfolio ≈ cashRub без оценки бумаг
|
|
1493
|
+
const prevTotalRub = Number(acc.lastSync?.totalRub || 0);
|
|
1494
|
+
const isGlitchZeroPortfolio = rawDdPct >= 50 && prevTotalRub > 50000 && data.totalRub < prevTotalRub * 0.5 && (data.positions || []).some((p) => p.qty !== 0);
|
|
1495
|
+
if (isGlitchZeroPortfolio) {
|
|
1496
|
+
data.totalRub = prevTotalRub;
|
|
1497
|
+
}
|
|
1498
|
+
const ddPct = isGlitchZeroPortfolio ? (Number(acc.lastSync?.dayDrawdownPct) || 0) : rawDdPct;
|
|
1468
1499
|
if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
|
|
1469
1500
|
acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
|
|
1470
|
-
} else if (ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
|
|
1501
|
+
} else if (!isGlitchZeroPortfolio && ddPct >= acc.maxDailyDrawdownPct && acc.ddOverrideDay !== today) {
|
|
1471
1502
|
// Требуем подтверждения просадки на 2 синхронизациях подряд (защита от секундного переходного состояния API брокера в момент исполнения лимитной заявки)
|
|
1472
1503
|
acc.ddBreachCount = (acc.ddBreachCount || 0) + 1;
|
|
1473
1504
|
if (acc.ddBreachCount >= 2) {
|
|
@@ -1562,7 +1593,34 @@ export async function freezeBrokerBaseline(id) {
|
|
|
1562
1593
|
return st;
|
|
1563
1594
|
}
|
|
1564
1595
|
|
|
1565
|
-
//
|
|
1596
|
+
// Оценка срабатывания скользящего Take-Profit и защиты заработанной прибыли от отката в минус (по лучшей цене с момента входа peakPx)
|
|
1597
|
+
function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10) {
|
|
1598
|
+
if (!(avgPx > 0 && curPx > 0)) return null;
|
|
1599
|
+
const isLong = side === 'long';
|
|
1600
|
+
const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
|
|
1601
|
+
const effPeakPx = peakPx > 0
|
|
1602
|
+
? (isLong ? Math.max(peakPx, avgPx, curPx) : Math.min(peakPx, avgPx, curPx))
|
|
1603
|
+
: (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
|
|
1604
|
+
const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
|
|
1605
|
+
const tierMult = symbolEdgeMult(sym);
|
|
1606
|
+
// 1) Скользящий Take-Profit при сильном перевыполнении цели (от +1.20% на 1-м эшелоне и от +1.44% на 2-м эшелоне):
|
|
1607
|
+
// фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
|
|
1608
|
+
if (peakGainPct >= 1.20 * tierMult) {
|
|
1609
|
+
const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, roundTripFeePct + 0.25);
|
|
1610
|
+
if (curGainPct <= trailFloorPct && curGainPct >= roundTripFeePct + 0.10) {
|
|
1611
|
+
return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
// 2) Защита заработанного плюса / безубытка (от +0.75% на 1-м эшелоне и от +0.90% на 2-м эшелоне):
|
|
1615
|
+
// не позволяем плюсовой сделке развернуться обратно в убыток
|
|
1616
|
+
if (peakGainPct >= 0.75 * tierMult) {
|
|
1617
|
+
const breakevenFloorPct = Math.max(roundTripFeePct + 0.18, peakGainPct * 0.40);
|
|
1618
|
+
if (curGainPct <= breakevenFloorPct) {
|
|
1619
|
+
return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return null;
|
|
1623
|
+
}
|
|
1566
1624
|
function sessionEdgeWarning(now = Date.now()) {
|
|
1567
1625
|
const d = new Date(now + MSK);
|
|
1568
1626
|
const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
@@ -1574,17 +1632,45 @@ function sessionEdgeWarning(now = Date.now()) {
|
|
|
1574
1632
|
}
|
|
1575
1633
|
|
|
1576
1634
|
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
1577
|
-
const
|
|
1635
|
+
const ACCOUNT_LOCK_TTL_MS = 75000;
|
|
1636
|
+
const accountBusyLocks = new Map(); // id -> { since, token }
|
|
1637
|
+
|
|
1638
|
+
function isAccountBusy(id) {
|
|
1639
|
+
const cur = accountBusyLocks.get(id);
|
|
1640
|
+
if (!cur) return false;
|
|
1641
|
+
if (Date.now() - cur.since >= ACCOUNT_LOCK_TTL_MS) {
|
|
1642
|
+
accountBusyLocks.delete(id);
|
|
1643
|
+
return false;
|
|
1644
|
+
}
|
|
1645
|
+
return true;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
function acquireAccountLock(id) {
|
|
1649
|
+
if (isAccountBusy(id)) return null;
|
|
1650
|
+
const token = Symbol(id);
|
|
1651
|
+
accountBusyLocks.set(id, { since: Date.now(), token });
|
|
1652
|
+
return token;
|
|
1653
|
+
}
|
|
1578
1654
|
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1655
|
+
function releaseAccountLock(id, token) {
|
|
1656
|
+
const cur = accountBusyLocks.get(id);
|
|
1657
|
+
if (cur && (!token || cur.token === token)) {
|
|
1658
|
+
accountBusyLocks.delete(id);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBarTs = 0 } = {}) {
|
|
1663
|
+
const lockToken = acquireAccountLock(id);
|
|
1664
|
+
if (!lockToken) return brokersState();
|
|
1582
1665
|
try {
|
|
1583
1666
|
let list = loadRawAccounts();
|
|
1584
1667
|
let acc = list.find((x) => x.id === id);
|
|
1585
1668
|
if (!acc) throw new Error('Счёт не найден');
|
|
1586
1669
|
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
1587
1670
|
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
1671
|
+
if (onlyEntries && edgeWarn) {
|
|
1672
|
+
return brokersState();
|
|
1673
|
+
}
|
|
1588
1674
|
|
|
1589
1675
|
const plan = effectivePlan(currentUser());
|
|
1590
1676
|
if ((plan.maxBrokerAccounts ?? 0) < 1) {
|
|
@@ -1602,11 +1688,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1602
1688
|
acc = list.find((x) => x.id === id) || acc;
|
|
1603
1689
|
}
|
|
1604
1690
|
const params = normalizeParams(getSettings());
|
|
1605
|
-
const [fc, rawSpecs, shortableMap] = await Promise.all([
|
|
1691
|
+
const [fc, rawSpecs, shortableMap] = await withWallTimeout(Promise.all([
|
|
1606
1692
|
forecastNow(params),
|
|
1607
1693
|
ensureBoardSpecs(),
|
|
1608
1694
|
ensureTbankShortableMap(acc),
|
|
1609
|
-
]);
|
|
1695
|
+
]), 55000, 'Превышено время ожидания расчёта прогноза');
|
|
1610
1696
|
const specs = { ...rawSpecs };
|
|
1611
1697
|
if (acc.broker === 'tbank') {
|
|
1612
1698
|
const tbSpecs = kvGet('tbankShortableMap')?.specMap || {};
|
|
@@ -1649,6 +1735,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1649
1735
|
const timeOk = !autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL);
|
|
1650
1736
|
const entryTsMap = acc.entryTsBySymbol || {};
|
|
1651
1737
|
const exitCooldownMap = acc.exitCooldownBySymbol || {};
|
|
1738
|
+
const entryFailMap = acc.entryFailCooldownBySymbol || {};
|
|
1652
1739
|
// Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
|
|
1653
1740
|
// Отдельного вечернего ужесточения нет: проверка 23.10.2026 (первые 70% и последние 30% истории) показала,
|
|
1654
1741
|
// что порог ×1,25 после 19:00 МСК снижает итог на обеих частях — правила совпадают с бэктестом.
|
|
@@ -1658,7 +1745,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1658
1745
|
const minShortEdgePct = round(baseShortNeedPct, 3);
|
|
1659
1746
|
const reqBuyEdgeOf = (sym) => rowMap.get(sym)?.buyThresholdPct ?? round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
|
|
1660
1747
|
const reqShortEdgeOf = (sym) => rowMap.get(sym)?.shortThresholdPct ?? round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
|
|
1661
|
-
const isInCooldown = (sym) =>
|
|
1748
|
+
const isInCooldown = (sym) => Boolean(
|
|
1749
|
+
(autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000)) ||
|
|
1750
|
+
(entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000)),
|
|
1751
|
+
);
|
|
1662
1752
|
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
|
|
1663
1753
|
// если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
|
|
1664
1754
|
// временно исключаем такую бумагу из новых входов!
|
|
@@ -1756,11 +1846,12 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1756
1846
|
const smoothSellPool = [];
|
|
1757
1847
|
const peakMap = acc.peakPriceBySymbol || {};
|
|
1758
1848
|
const roundTripFeePct = 2 * ((Number(params.commissionPct) || 0.04) + (Number(params.slippagePct) || 0.01));
|
|
1759
|
-
// 1) закрытие бумаг стратегии (покупок и шортов):
|
|
1849
|
+
// 1) закрытие бумаг стратегии (покупок и шортов) на плановом шаге (!onlyEntries):
|
|
1760
1850
|
// • по фиксации реальной прибыли (Take-Profit / Trailing Stop от лучшей достигнутой цены peakPrice, чтобы не отдавать заработанный плюс в минус!),
|
|
1761
1851
|
// • либо если прогноз ослаб ниже порога выхода curExitThr.
|
|
1852
|
+
// В быстром цикле добора (onlyEntries) закрывающие заявки не формируются — сопровождение и выходы идут строго по плановому шагу (и защитным стопам).
|
|
1762
1853
|
// Облигации, фонды (LQDT/TMOS) и бумаги вне словаря MOEXBMI (!rowMap.has(sym)) не затрагиваются никогда
|
|
1763
|
-
for (const [sym, pos] of heldMap) {
|
|
1854
|
+
for (const [sym, pos] of (onlyEntries ? [] : heldMap)) {
|
|
1764
1855
|
if (!pos.qty) continue;
|
|
1765
1856
|
const r = rowMap.get(sym);
|
|
1766
1857
|
if (!r) continue;
|
|
@@ -1773,30 +1864,39 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1773
1864
|
const lastHistEntryTs = lastHistEntryOp?.date || 0;
|
|
1774
1865
|
const effEntryTs = Math.max(entryTsMap[sym] || 0, lastHistEntryTs);
|
|
1775
1866
|
const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
|
|
1776
|
-
|
|
1777
|
-
|
|
1867
|
+
const heldMin = heldMs / 60000;
|
|
1868
|
+
const horizonMin = Math.max(90, effHorizon * 60);
|
|
1869
|
+
// Плавный переход порога удержания от мягкого (0.35 до 45 мин) к плотному (0.95 к концу горизонта 2.5 ч) без резкой «ступеньки» на 65-й минуте
|
|
1778
1870
|
const symMult = r.edgeMult || symbolEdgeMult(sym);
|
|
1779
|
-
const
|
|
1871
|
+
const rampProgress = autoAdapt ? (heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / (horizonMin - 45))) : 1;
|
|
1872
|
+
const rampedExitThr = earlyExitThresholdPct + (exitThresholdPct - earlyExitThresholdPct) * rampProgress;
|
|
1873
|
+
const curExitThr = round(rampedExitThr * symMult, 3);
|
|
1874
|
+
const softExitThr = round(earlyExitThresholdPct * symMult, 3);
|
|
1780
1875
|
const nightExit = !onlyEntries && autoAdapt && shouldExitBeforeNight(nowTs, effEntryTs, stepBars, BAR_INTERVAL);
|
|
1781
1876
|
const curPx = Number(r.price || pos.price) || 0;
|
|
1782
1877
|
const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
|
|
1783
1878
|
if (aQty > 0) {
|
|
1784
1879
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1785
1880
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1881
|
+
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
|
|
1786
1882
|
const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
|
|
1787
|
-
|
|
1883
|
+
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
|
|
1884
|
+
const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
1885
|
+
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && (r.expectedPct > curExitThr || holdThroughMinorDip));
|
|
1788
1886
|
const dg = divGuardOf(sym, curPx);
|
|
1789
1887
|
const divExit = Boolean(dg?.exitLong);
|
|
1790
|
-
const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
|
|
1888
|
+
const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
|
|
1791
1889
|
if (lots >= 1 && !keep) {
|
|
1792
1890
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
1793
1891
|
const exitReason = hitHardStopLoss
|
|
1794
1892
|
? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
|
|
1795
|
-
:
|
|
1796
|
-
?
|
|
1797
|
-
:
|
|
1798
|
-
?
|
|
1799
|
-
:
|
|
1893
|
+
: profitLockReason
|
|
1894
|
+
? profitLockReason
|
|
1895
|
+
: divExit
|
|
1896
|
+
? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
|
|
1897
|
+
: nightExit
|
|
1898
|
+
? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
|
|
1899
|
+
: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
|
|
1800
1900
|
orders.push({
|
|
1801
1901
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1802
1902
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
@@ -1808,20 +1908,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1808
1908
|
} else if (aQty < 0) {
|
|
1809
1909
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1810
1910
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
1911
|
+
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
|
|
1811
1912
|
const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
|
|
1812
|
-
|
|
1913
|
+
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
|
|
1914
|
+
const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
1915
|
+
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && (r.expectedPct < -curExitThr || holdThroughMinorSpike));
|
|
1813
1916
|
const dg = divGuardOf(sym, curPx);
|
|
1814
1917
|
const divExit = Boolean(dg?.exitShort);
|
|
1815
|
-
const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
|
|
1918
|
+
const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
|
|
1816
1919
|
if (lots >= 1 && !keep) {
|
|
1817
1920
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|
|
1818
1921
|
const exitReason = hitHardStopLoss
|
|
1819
1922
|
? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
|
|
1820
|
-
:
|
|
1821
|
-
?
|
|
1822
|
-
:
|
|
1823
|
-
?
|
|
1824
|
-
:
|
|
1923
|
+
: profitLockReason
|
|
1924
|
+
? profitLockReason
|
|
1925
|
+
: divExit
|
|
1926
|
+
? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
|
|
1927
|
+
: nightExit
|
|
1928
|
+
? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
|
|
1929
|
+
: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
|
|
1825
1930
|
orders.push({
|
|
1826
1931
|
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1827
1932
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
@@ -2128,11 +2233,21 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
2128
2233
|
}
|
|
2129
2234
|
|
|
2130
2235
|
const batchNowTs = Date.now();
|
|
2236
|
+
const batchOrderSig = (arr) => (Array.isArray(arr) ? arr.map((o) => `${o.action}:${o.symbol}:${o.lots}`).join('|') : '');
|
|
2237
|
+
const prevPending = acc.pendingBatch;
|
|
2238
|
+
const curBatchSig = batchOrderSig(orders);
|
|
2239
|
+
const isDuplicatePending = Boolean(
|
|
2240
|
+
curBatchSig &&
|
|
2241
|
+
prevPending?.status === 'pending' &&
|
|
2242
|
+
batchOrderSig(prevPending.orders) === curBatchSig &&
|
|
2243
|
+
batchNowTs - (prevPending.createdAt || 0) < 2 * 3600000,
|
|
2244
|
+
);
|
|
2131
2245
|
if (!onlyEntries) {
|
|
2132
2246
|
acc.lastFullStepWallTs = batchNowTs;
|
|
2247
|
+
if (scheduledBarTs > 0) acc.lastScheduledBarTs = scheduledBarTs;
|
|
2133
2248
|
}
|
|
2134
2249
|
acc.pendingBatch = {
|
|
2135
|
-
id: `batch_${batchNowTs}`,
|
|
2250
|
+
id: isDuplicatePending && prevPending?.id ? prevPending.id : `batch_${batchNowTs}`,
|
|
2136
2251
|
createdAt: batchNowTs,
|
|
2137
2252
|
bar: fc.bar,
|
|
2138
2253
|
budgetRub: budget,
|
|
@@ -2149,16 +2264,18 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
2149
2264
|
results: [],
|
|
2150
2265
|
};
|
|
2151
2266
|
saveRawAccounts(list);
|
|
2152
|
-
notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
|
|
2153
2267
|
if (acc.mode === 'auto' && !edgeWarn && acc.token && orders.length) {
|
|
2154
2268
|
return await executeBrokerOrders(id, { _lockHeld: true });
|
|
2155
2269
|
}
|
|
2270
|
+
if (acc.mode === 'confirm' && orders.length && !isDuplicatePending) {
|
|
2271
|
+
notifyBrokerBatch(currentUserId(), acc, acc.pendingBatch).catch(() => {});
|
|
2272
|
+
}
|
|
2156
2273
|
const st = brokersState();
|
|
2157
2274
|
hub.emit({ type: 'brokers:update', state: st });
|
|
2158
2275
|
hub.emit({ type: 'trading:pulse', kind: 'broker_done', ts: Date.now(), accountId: acc.id, name: acc.name, ordersCount: orders.length });
|
|
2159
2276
|
return st;
|
|
2160
2277
|
} finally {
|
|
2161
|
-
|
|
2278
|
+
releaseAccountLock(id, lockToken);
|
|
2162
2279
|
}
|
|
2163
2280
|
}
|
|
2164
2281
|
|
|
@@ -2258,7 +2375,7 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2258
2375
|
for (const ao of jActive?.orders || []) {
|
|
2259
2376
|
const sameInst = ao.instrumentUid === instrumentId || ao.figi === instrumentId || ao.ticker === ord.symbol;
|
|
2260
2377
|
const sameDir = String(ao.direction || '').includes(wantDir);
|
|
2261
|
-
if (sameInst && sameDir && ao.orderId) {
|
|
2378
|
+
if (sameInst && (sameDir || !isEntry) && ao.orderId) {
|
|
2262
2379
|
await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
2263
2380
|
method: 'POST', headers, body: JSON.stringify({ accountId: acc.accountId, orderId: ao.orderId }), timeoutMs: 8000,
|
|
2264
2381
|
}).catch(() => null);
|
|
@@ -2474,12 +2591,13 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2474
2591
|
}
|
|
2475
2592
|
|
|
2476
2593
|
export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
2594
|
+
let lockToken = null;
|
|
2477
2595
|
if (!_lockHeld) {
|
|
2478
2596
|
// Счёт может быть занят пересчётом заявок (после перезапуска — обучением прогноза): ждём до 15 с, а не молча пропускаем
|
|
2479
2597
|
const waitUntil = Date.now() + 15000;
|
|
2480
|
-
while (
|
|
2481
|
-
|
|
2482
|
-
|
|
2598
|
+
while (isAccountBusy(id) && Date.now() < waitUntil) await new Promise((r) => setTimeout(r, 300));
|
|
2599
|
+
lockToken = acquireAccountLock(id);
|
|
2600
|
+
if (!lockToken) throw new Error('Счёт сейчас занят пересчётом заявок — повторите через минуту');
|
|
2483
2601
|
}
|
|
2484
2602
|
try {
|
|
2485
2603
|
const list = loadRawAccounts();
|
|
@@ -2501,8 +2619,10 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2501
2619
|
saveRawAccounts(list);
|
|
2502
2620
|
const prevResults = new Map((batch.results || []).filter((r) => r.ok && r.clientOrderId).map((r) => [r.clientOrderId, r]));
|
|
2503
2621
|
const results = [];
|
|
2622
|
+
const newlySentOrders = [];
|
|
2504
2623
|
acc.entryTsBySymbol = acc.entryTsBySymbol || {};
|
|
2505
2624
|
acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
|
|
2625
|
+
acc.entryFailCooldownBySymbol = acc.entryFailCooldownBySymbol || {};
|
|
2506
2626
|
acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
|
|
2507
2627
|
const execNowTs = Date.now();
|
|
2508
2628
|
const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
|
|
@@ -2530,11 +2650,21 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2530
2650
|
}
|
|
2531
2651
|
}
|
|
2532
2652
|
const r = await sendOrderToBroker(acc, ord);
|
|
2653
|
+
if (r.lots > 0) {
|
|
2654
|
+
ord.lots = r.lots;
|
|
2655
|
+
ord.shares = r.lots * (ord.lotSize || 1);
|
|
2656
|
+
}
|
|
2657
|
+
if (r.limitPrice > 0) {
|
|
2658
|
+
ord.limitPrice = r.limitPrice;
|
|
2659
|
+
ord.amountRub = round((ord.shares || ord.lots) * r.limitPrice, 2);
|
|
2660
|
+
}
|
|
2533
2661
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
|
|
2662
|
+
newlySentOrders.push(ord);
|
|
2534
2663
|
if (ord.action === 'buy' || ord.action === 'short') {
|
|
2535
2664
|
acc.entryTsBySymbol[ord.symbol] = execNowTs;
|
|
2536
2665
|
acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
|
|
2537
2666
|
delete acc.exitCooldownBySymbol[ord.symbol];
|
|
2667
|
+
delete acc.entryFailCooldownBySymbol[ord.symbol];
|
|
2538
2668
|
} else if (ord.action === 'sell' || ord.action === 'cover') {
|
|
2539
2669
|
hadExits = true;
|
|
2540
2670
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
@@ -2547,6 +2677,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2547
2677
|
ord.sendAttempts = (ord.sendAttempts || 0) + 1;
|
|
2548
2678
|
if (e.definitive) ord.clientOrderId = crypto.randomUUID();
|
|
2549
2679
|
else ord.uncertainSend = true;
|
|
2680
|
+
if (ord.action === 'buy' || ord.action === 'short') {
|
|
2681
|
+
acc.entryFailCooldownBySymbol[ord.symbol] = execNowTs;
|
|
2682
|
+
}
|
|
2550
2683
|
results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
|
|
2551
2684
|
logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
|
|
2552
2685
|
}
|
|
@@ -2555,6 +2688,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2555
2688
|
batch.results = results;
|
|
2556
2689
|
batch.executedAt = Date.now();
|
|
2557
2690
|
saveRawAccounts(list);
|
|
2691
|
+
if (acc.mode === 'auto' && newlySentOrders.length > 0) {
|
|
2692
|
+
notifyBrokerBatch(currentUserId(), acc, { ...batch, orders: newlySentOrders }).catch(() => {});
|
|
2693
|
+
}
|
|
2558
2694
|
await syncBrokerAccount(id).catch(() => {});
|
|
2559
2695
|
if (acc.broker === 'tbank' && acc.token) {
|
|
2560
2696
|
await fetchBrokerHistory(id).catch(() => {});
|
|
@@ -2563,7 +2699,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2563
2699
|
hub.emit({ type: 'brokers:update', state: st });
|
|
2564
2700
|
return st;
|
|
2565
2701
|
} finally {
|
|
2566
|
-
if (!_lockHeld)
|
|
2702
|
+
if (!_lockHeld && lockToken) releaseAccountLock(id, lockToken);
|
|
2567
2703
|
}
|
|
2568
2704
|
}
|
|
2569
2705
|
|
|
@@ -2789,24 +2925,27 @@ async function tickBrokerSchedule() {
|
|
|
2789
2925
|
// Внутричасовой перехват H/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному горизонту H
|
|
2790
2926
|
const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
|
|
2791
2927
|
const minStepMs = stepBars * BAR_MS;
|
|
2792
|
-
const
|
|
2793
|
-
const
|
|
2928
|
+
const rawLastFullWallTs = acc.lastFullStepWallTs || 0;
|
|
2929
|
+
const batchCreatedTs = acc.pendingBatch?.createdAt || 0;
|
|
2930
|
+
const lastAnyWallTs = acc.pendingBatch?.executedAt || batchCreatedTs || 0;
|
|
2931
|
+
// Если отметка шага была записана без фактического обновления pendingBatch, не считаем такой шаг состоявшимся
|
|
2932
|
+
const lastFullWallTs = batchCreatedTs >= rawLastFullWallTs - 60000 ? rawLastFullWallTs : batchCreatedTs;
|
|
2933
|
+
const lastSchedBarTs = batchCreatedTs >= rawLastFullWallTs - 60000 ? (acc.lastScheduledBarTs || 0) : 0;
|
|
2794
2934
|
const hasOpenAlgoPos = (acc.lastSync?.positions || []).some((p) => p.qty && p.symbol !== 'LQDT' && p.symbol !== 'TMON@' && p.symbol !== 'TMON' && p.symbol !== 'SBMM' && algoQtyFor(acc, p.symbol, p.qty) !== 0);
|
|
2795
2935
|
const nowTs = Date.now();
|
|
2796
2936
|
const nightExitDue = autoAdapt && hasOpenAlgoPos && shouldExitBeforeNight(nowTs, 0, 3, BAR_INTERVAL) && (!lastFullWallTs || !shouldExitBeforeNight(lastFullWallTs, 0, 3, BAR_INTERVAL) || (nowTs - lastFullWallTs >= 15 * 60000));
|
|
2797
2937
|
const dueFullStep = Boolean(
|
|
2798
2938
|
nightExitDue || (
|
|
2799
2939
|
latest &&
|
|
2800
|
-
(latest -
|
|
2940
|
+
(latest - lastSchedBarTs >= minStepMs) &&
|
|
2801
2941
|
!(lastFullWallTs > 0 && nowTs - lastFullWallTs < minStepMs - 5 * 60000)
|
|
2802
2942
|
),
|
|
2803
2943
|
);
|
|
2804
2944
|
if (dueFullStep) {
|
|
2805
|
-
|
|
2806
|
-
acc.
|
|
2807
|
-
saveRawAccounts(list);
|
|
2945
|
+
if (isAccountBusy(acc.id)) continue;
|
|
2946
|
+
const stepBarTs = latest || acc.lastScheduledBarTs || nowTs;
|
|
2808
2947
|
if (acc.token) await syncBrokerAccount(acc.id).catch(() => {});
|
|
2809
|
-
await prepareBrokerOrders(acc.id).catch(() => {});
|
|
2948
|
+
await prepareBrokerOrders(acc.id, { scheduledBarTs: stepBarTs }).catch(() => {});
|
|
2810
2949
|
continue;
|
|
2811
2950
|
}
|
|
2812
2951
|
// Мгновенный перехват новых сигналов «купить» / «шорт» при наличии свободных рублей или резерва в LQDT (каждые 20 секунд, не дожидаясь конца 30-минутного шага!):
|
|
@@ -2814,7 +2953,7 @@ async function tickBrokerSchedule() {
|
|
|
2814
2953
|
const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
|
|
2815
2954
|
const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
|
|
2816
2955
|
const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
|
|
2817
|
-
if (acc.mode === 'auto' && acc.token && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
2956
|
+
if (acc.mode === 'auto' && acc.token && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
2818
2957
|
await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
|
|
2819
2958
|
}
|
|
2820
2959
|
}
|
|
@@ -2920,12 +3059,12 @@ async function reconcileBrokerStops(accId) {
|
|
|
2920
3059
|
return;
|
|
2921
3060
|
}
|
|
2922
3061
|
if (!acc.lastSync?.ok) return;
|
|
2923
|
-
|
|
2924
|
-
|
|
3062
|
+
const lockToken = acquireAccountLock(accId);
|
|
3063
|
+
if (!lockToken) return; // идёт исполнение пакета — сверимся в следующий раз
|
|
2925
3064
|
try {
|
|
2926
|
-
await reconcileBrokerStopsLocked(list, acc);
|
|
3065
|
+
await withWallTimeout(reconcileBrokerStopsLocked(list, acc), 35000, 'Превышено время сверки стоп-заявок');
|
|
2927
3066
|
} finally {
|
|
2928
|
-
|
|
3067
|
+
releaseAccountLock(accId, lockToken);
|
|
2929
3068
|
}
|
|
2930
3069
|
}
|
|
2931
3070
|
|
|
@@ -3044,8 +3183,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3044
3183
|
if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
|
|
3045
3184
|
const aQty = algoQtyOf(sym, pos.qty);
|
|
3046
3185
|
if (!aQty) continue;
|
|
3047
|
-
|
|
3048
|
-
if (acc.brokerStops && acc.brokerStopBySymbol?.[sym]) continue;
|
|
3186
|
+
const hasBrokerHardStop = Boolean(acc.brokerStops && acc.brokerStopBySymbol?.[sym]);
|
|
3049
3187
|
// Если по этой бумаге в стакане УЖЕ висит закрывающая заявка (sell для лонга или buy для шорта) — не дублируем
|
|
3050
3188
|
const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
|
|
3051
3189
|
if ((acc.lastSync?.activeOrders || []).some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
|
|
@@ -3062,26 +3200,30 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3062
3200
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
3063
3201
|
if (lots < 1) continue;
|
|
3064
3202
|
const curGainPct = ((curPx - avgPx) / avgPx) * 100;
|
|
3065
|
-
const
|
|
3066
|
-
|
|
3203
|
+
const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
|
|
3204
|
+
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
|
|
3205
|
+
if (hitHardStopLoss || profitLockReason) {
|
|
3067
3206
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
3068
3207
|
lockOrders.push({
|
|
3069
3208
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
3070
3209
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
3071
|
-
reason: `Авто-стоп позиции (${round(curGainPct, 2)}%)
|
|
3210
|
+
reason: hitHardStopLoss ? `Авто-стоп позиции (${round(curGainPct, 2)}%)` : profitLockReason,
|
|
3211
|
+
isStopLoss: hitHardStopLoss,
|
|
3072
3212
|
});
|
|
3073
3213
|
}
|
|
3074
3214
|
} else if (aQty < 0) {
|
|
3075
3215
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
3076
3216
|
if (lots < 1) continue;
|
|
3077
3217
|
const curGainPct = ((avgPx - curPx) / avgPx) * 100;
|
|
3078
|
-
const
|
|
3079
|
-
|
|
3218
|
+
const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
|
|
3219
|
+
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
|
|
3220
|
+
if (hitHardStopLoss || profitLockReason) {
|
|
3080
3221
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|
|
3081
3222
|
lockOrders.push({
|
|
3082
3223
|
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
3083
3224
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
3084
|
-
reason: `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)
|
|
3225
|
+
reason: hitHardStopLoss ? `Авто-стоп шорта от выноса вверх (${round(curGainPct, 2)}%)` : profitLockReason,
|
|
3226
|
+
isStopLoss: hitHardStopLoss,
|
|
3085
3227
|
});
|
|
3086
3228
|
}
|
|
3087
3229
|
}
|
|
@@ -3089,6 +3231,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3089
3231
|
|
|
3090
3232
|
if (!lockOrders.length) return;
|
|
3091
3233
|
const results = [];
|
|
3234
|
+
const sentLockOrders = [];
|
|
3092
3235
|
const execNowTs = Date.now();
|
|
3093
3236
|
acc.entryTsBySymbol = acc.entryTsBySymbol || {};
|
|
3094
3237
|
acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
|
|
@@ -3099,16 +3242,25 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3099
3242
|
await cancelBrokerOrder(accId, co.orderId).catch(() => {});
|
|
3100
3243
|
}
|
|
3101
3244
|
for (const ord of lockOrders) {
|
|
3102
|
-
logBrokerEvent(acc, 'protective_stop', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
|
|
3245
|
+
logBrokerEvent(acc, ord.isStopLoss ? 'protective_stop' : 'profit_lock', `${ord.reason}: ${ord.action} ${ord.symbol} ${ord.lots} л.`);
|
|
3103
3246
|
try {
|
|
3104
3247
|
if (acc.brokerStopBySymbol?.[ord.symbol]) {
|
|
3105
3248
|
await closeBrokerStopBeforeExit(acc, ord.symbol);
|
|
3106
3249
|
}
|
|
3107
3250
|
const r = await sendOrderToBroker(acc, ord);
|
|
3251
|
+
if (r.lots > 0) {
|
|
3252
|
+
ord.lots = r.lots;
|
|
3253
|
+
ord.shares = r.lots * (ord.lotSize || 1);
|
|
3254
|
+
}
|
|
3255
|
+
if (r.limitPrice > 0) {
|
|
3256
|
+
ord.limitPrice = r.limitPrice;
|
|
3257
|
+
ord.amountRub = round((ord.shares || ord.lots) * r.limitPrice, 2);
|
|
3258
|
+
}
|
|
3108
3259
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId });
|
|
3260
|
+
sentLockOrders.push(ord);
|
|
3109
3261
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
3110
3262
|
delete acc.peakPriceBySymbol[ord.symbol];
|
|
3111
|
-
acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
|
|
3263
|
+
if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
|
|
3112
3264
|
} catch (e) {
|
|
3113
3265
|
results.push({ symbol: ord.symbol, ok: false, error: e.message });
|
|
3114
3266
|
logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
|
|
@@ -3119,12 +3271,15 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3119
3271
|
id: `batch_stop_${execNowTs}`,
|
|
3120
3272
|
createdAt: execNowTs,
|
|
3121
3273
|
executedAt: execNowTs,
|
|
3122
|
-
bar: `${fmtBarMsk(execNowTs)} (
|
|
3274
|
+
bar: `${fmtBarMsk(execNowTs)} (Фиксация прибыли / Защита)`,
|
|
3123
3275
|
orders: lockOrders,
|
|
3124
3276
|
status: results.every((r) => r.ok) ? 'executed' : 'partial',
|
|
3125
3277
|
results,
|
|
3126
3278
|
};
|
|
3127
3279
|
saveRawAccounts(list);
|
|
3280
|
+
if (sentLockOrders.length > 0) {
|
|
3281
|
+
notifyBrokerBatch(currentUserId(), acc, { ...acc.pendingBatch, orders: sentLockOrders }).catch(() => {});
|
|
3282
|
+
}
|
|
3128
3283
|
await syncBrokerAccount(accId).catch(() => {});
|
|
3129
3284
|
const st = brokersState();
|
|
3130
3285
|
hub.emit({ type: 'brokers:update', state: st });
|
|
@@ -3151,7 +3306,9 @@ async function tickBrokerAutoRefresh() {
|
|
|
3151
3306
|
if (acc.broker === 'tbank' && (acc.brokerStops || Object.keys(acc.brokerStopBySymbol || {}).length)) {
|
|
3152
3307
|
await reconcileBrokerStops(acc.id).catch((e) => logBrokerEvent(acc, 'broker_stop_error', `сверка: ${e.message}`));
|
|
3153
3308
|
}
|
|
3154
|
-
|
|
3309
|
+
const latestEntryTs = Math.max(0, ...Object.values(acc.entryTsBySymbol || {}).map(Number).filter(Number.isFinite));
|
|
3310
|
+
const historyNeedSync = !acc.lastHistory?.at || Date.now() - acc.lastHistory.at >= 45000 || (latestEntryTs > (acc.lastHistory.at || 0));
|
|
3311
|
+
if (acc.broker === 'tbank' && historyNeedSync) {
|
|
3155
3312
|
await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
|
|
3156
3313
|
}
|
|
3157
3314
|
// Поддерживаем живой кэш прогноза всегда горячим в памяти сервера (даже когда браузер свёрнут)
|