neuroalpha 0.5.31 → 0.5.33
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/server/brokers/index.js +77 -40
- package/server/model/edge.js +14 -2
- package/server/model/forecast.js +10 -5
- package/server/model/live.js +1 -1
- package/server/model/research.js +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.33",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/server/brokers/index.js
CHANGED
|
@@ -1612,7 +1612,7 @@ export async function freezeBrokerBaseline(id) {
|
|
|
1612
1612
|
}
|
|
1613
1613
|
|
|
1614
1614
|
// Оценка срабатывания скользящего Take-Profit и защиты заработанной прибыли от отката в минус (по лучшей цене с момента входа peakPx)
|
|
1615
|
-
function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10) {
|
|
1615
|
+
function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10, heldMin = 999) {
|
|
1616
1616
|
if (!(avgPx > 0 && curPx > 0)) return null;
|
|
1617
1617
|
const isLong = side === 'long';
|
|
1618
1618
|
const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
|
|
@@ -1621,19 +1621,22 @@ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0
|
|
|
1621
1621
|
: (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
|
|
1622
1622
|
const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
|
|
1623
1623
|
const tierMult = symbolEdgeMult(sym);
|
|
1624
|
-
//
|
|
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):
|
|
1625
1627
|
// фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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) {
|
|
1629
1632
|
return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
|
|
1630
1633
|
}
|
|
1631
1634
|
}
|
|
1632
|
-
// 2) Защита заработанного плюса / безубытка (от +0.
|
|
1633
|
-
// не позволяем плюсовой сделке развернуться обратно в
|
|
1634
|
-
if (peakGainPct >= 0.
|
|
1635
|
-
const breakevenFloorPct = Math.max(
|
|
1636
|
-
if (curGainPct <= breakevenFloorPct) {
|
|
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) {
|
|
1637
1640
|
return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
|
|
1638
1641
|
}
|
|
1639
1642
|
}
|
|
@@ -1642,9 +1645,9 @@ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0
|
|
|
1642
1645
|
function sessionEdgeWarning(now = Date.now()) {
|
|
1643
1646
|
const d = new Date(now + MSK);
|
|
1644
1647
|
const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
1645
|
-
// открытие утренней сессии 06:50–07:
|
|
1646
|
-
if ((mins >= 6 * 60 + 50 && mins <= 7 * 60 +
|
|
1647
|
-
return 'Первые
|
|
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 'Первые минуты утренней/основной сессии или закрытие дневной (широкий спред и утренние гэпы в стакане)';
|
|
1648
1651
|
}
|
|
1649
1652
|
return null;
|
|
1650
1653
|
}
|
|
@@ -1656,7 +1659,7 @@ const entryImpulseWatch = new Map(); // `${accId}:${symbol}:${side}` -> { firstT
|
|
|
1656
1659
|
|
|
1657
1660
|
// Фильтр затухания минутного импульса перед входом (Impulse Exhaustion):
|
|
1658
1661
|
// не даёт продавать в шорт посередине вертикально растущей минутной свечи (или покупать в полёт падающего ножа),
|
|
1659
|
-
// дожидаясь остановки разгона и
|
|
1662
|
+
// дожидаясь остановки разгона и подтверждённого отката от пика/дна (с шагом проверки 20 секунд; без принудительного входа по таймауту)
|
|
1660
1663
|
async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
|
|
1661
1664
|
if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
|
|
1662
1665
|
for (const [k, v] of entryImpulseWatch) {
|
|
@@ -1674,7 +1677,7 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
|
|
|
1674
1677
|
ready.push(r);
|
|
1675
1678
|
continue;
|
|
1676
1679
|
}
|
|
1677
|
-
const bars1m = await fetchTbankCandles(sym, nowTs -
|
|
1680
|
+
const bars1m = await fetchTbankCandles(sym, nowTs - 15 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null);
|
|
1678
1681
|
if (!bars1m?.length) {
|
|
1679
1682
|
ready.push(r);
|
|
1680
1683
|
continue;
|
|
@@ -1686,16 +1689,25 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
|
|
|
1686
1689
|
const step = sp.minStep > 0 ? sp.minStep : 0.01;
|
|
1687
1690
|
const wKey = `${acc.id}:${sym}:${side}`;
|
|
1688
1691
|
const prevW = entryImpulseWatch.get(wKey) || null;
|
|
1692
|
+
let max1m = curPx, min1m = curPx > 0 ? curPx : Infinity;
|
|
1693
|
+
for (const b of bars1m) {
|
|
1694
|
+
if (b.high > max1m) max1m = b.high;
|
|
1695
|
+
if (b.low > 0 && b.low < min1m) min1m = b.low;
|
|
1696
|
+
}
|
|
1697
|
+
const windowRangePct = (Number.isFinite(min1m) && min1m > 0 && max1m > min1m) ? ((max1m - min1m) / min1m) * 100 : 0;
|
|
1698
|
+
const impulseMagPct = Math.max(Math.abs(Number(r.lastMovePct) || 0), windowRangePct);
|
|
1699
|
+
const minPullbackPct = Math.max(0.15, Math.min(0.55, impulseMagPct * 0.15));
|
|
1700
|
+
const isStrongSurge = impulseMagPct >= 1.5;
|
|
1689
1701
|
|
|
1690
1702
|
if (isShort) {
|
|
1691
|
-
const recentHigh = Math.max(prevW?.extremePx || 0, curPx,
|
|
1703
|
+
const recentHigh = Math.max(prevW?.extremePx || 0, curPx, max1m);
|
|
1692
1704
|
const pullbackPct = recentHigh > 0 ? ((recentHigh - curPx) / recentHigh) * 100 : 0;
|
|
1693
1705
|
const pullbackTicks = recentHigh > 0 ? (recentHigh - curPx) / step : 0;
|
|
1694
1706
|
const barTurnedDown = Boolean(last1m && prev1m && last1m.close <= last1m.open && last1m.high <= prev1m.high + step * 0.5);
|
|
1695
|
-
const peakedAndPulledBack = pullbackPct >=
|
|
1696
|
-
const tickedDownFromPrev = Boolean(prevW && curPx <= prevW.lastPx - step && pullbackTicks >=
|
|
1697
|
-
const
|
|
1698
|
-
if (
|
|
1707
|
+
const peakedAndPulledBack = pullbackPct >= minPullbackPct && pullbackTicks >= 2 && (!last1m || last1m.close <= last1m.open + step * 0.5);
|
|
1708
|
+
const tickedDownFromPrev = Boolean(!isStrongSurge && prevW && curPx <= prevW.lastPx - step && pullbackTicks >= 2 && pullbackPct >= 0.12);
|
|
1709
|
+
const readyShort = isStrongSurge ? (peakedAndPulledBack && barTurnedDown) : (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev);
|
|
1710
|
+
if (readyShort) {
|
|
1699
1711
|
entryImpulseWatch.delete(wKey);
|
|
1700
1712
|
r.price = curPx;
|
|
1701
1713
|
ready.push(r);
|
|
@@ -1704,14 +1716,14 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
|
|
|
1704
1716
|
deferred.push({ symbol: sym, side, curPx, extremePx: recentHigh });
|
|
1705
1717
|
}
|
|
1706
1718
|
} else {
|
|
1707
|
-
const recentLow = Math.min(prevW?.extremePx || Infinity,
|
|
1719
|
+
const recentLow = Math.min(prevW?.extremePx || Infinity, min1m);
|
|
1708
1720
|
const bouncePct = Number.isFinite(recentLow) && recentLow > 0 ? ((curPx - recentLow) / recentLow) * 100 : 0;
|
|
1709
1721
|
const bounceTicks = Number.isFinite(recentLow) && recentLow > 0 ? (curPx - recentLow) / step : 0;
|
|
1710
1722
|
const barTurnedUp = Boolean(last1m && prev1m && last1m.close >= last1m.open && last1m.low >= prev1m.low - step * 0.5);
|
|
1711
|
-
const bottomedAndBounced = bouncePct >=
|
|
1712
|
-
const tickedUpFromPrev = Boolean(prevW && curPx >= prevW.lastPx + step && bounceTicks >=
|
|
1713
|
-
const
|
|
1714
|
-
if (
|
|
1723
|
+
const bottomedAndBounced = bouncePct >= minPullbackPct && bounceTicks >= 2 && (!last1m || last1m.close >= last1m.open - step * 0.5);
|
|
1724
|
+
const tickedUpFromPrev = Boolean(!isStrongSurge && prevW && curPx >= prevW.lastPx + step && bounceTicks >= 2 && bouncePct >= 0.12);
|
|
1725
|
+
const readyLong = isStrongSurge ? (bottomedAndBounced && barTurnedUp) : (bottomedAndBounced || barTurnedUp || tickedUpFromPrev);
|
|
1726
|
+
if (readyLong) {
|
|
1715
1727
|
entryImpulseWatch.delete(wKey);
|
|
1716
1728
|
r.price = curPx;
|
|
1717
1729
|
ready.push(r);
|
|
@@ -1901,10 +1913,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1901
1913
|
// Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
|
|
1902
1914
|
const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
|
|
1903
1915
|
const divBlocked = [];
|
|
1904
|
-
const
|
|
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)
|
|
1905
1918
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
1906
1919
|
.sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
1907
|
-
const allShortSignals = (rawDir !== 'long' &&
|
|
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)
|
|
1908
1921
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
1909
1922
|
.sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
1910
1923
|
const nonShortableSkipped = [];
|
|
@@ -1967,7 +1980,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1967
1980
|
if (aQty > 0) {
|
|
1968
1981
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1969
1982
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1970
|
-
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
|
|
1983
|
+
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
1971
1984
|
const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
|
|
1972
1985
|
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
|
|
1973
1986
|
const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
@@ -1997,7 +2010,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1997
2010
|
} else if (aQty < 0) {
|
|
1998
2011
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1999
2012
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
2000
|
-
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
|
|
2013
|
+
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
2001
2014
|
const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
|
|
2002
2015
|
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
|
|
2003
2016
|
const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
@@ -2305,6 +2318,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2305
2318
|
if (!orders.length) {
|
|
2306
2319
|
if (!timeOk) {
|
|
2307
2320
|
diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
|
|
2321
|
+
} else if (edgeWarn) {
|
|
2322
|
+
diagNote = `На свече ${fc.bar} новые входы временно отложены: ${edgeWarn.toLowerCase()}.`;
|
|
2308
2323
|
} else if ((newBuys.length || newShorts.length) && availRub < 1500) {
|
|
2309
2324
|
const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
|
|
2310
2325
|
const cashNow = round(acc.lastSync?.cashRub || 0, 0);
|
|
@@ -2319,7 +2334,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2319
2334
|
diagNote = `Сигнал ${deferredByImpulse.map((d) => `${d.symbol} (${d.side === 'short' ? 'шорт' : 'покупка'}, текущая ${d.curPx} ₽)`).join(', ')} взят на прицел: ждём затухания встречного минутного импульса для входа по лучшей цене (проверка каждые 20 сек).`;
|
|
2320
2335
|
} else if (!newBuys.length && !newShorts.length) {
|
|
2321
2336
|
const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
2322
|
-
const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует 60
|
|
2337
|
+
const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '';
|
|
2323
2338
|
const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2324
2339
|
const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2325
2340
|
const thrRangeStr = dir !== 'long'
|
|
@@ -2370,7 +2385,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2370
2385
|
results: [],
|
|
2371
2386
|
};
|
|
2372
2387
|
saveRawAccounts(list);
|
|
2373
|
-
if (acc.mode === 'auto' &&
|
|
2388
|
+
if (acc.mode === 'auto' && acc.token && orders.length && (!edgeWarn || orders.every((o) => o.action === 'sell' || o.action === 'cover'))) {
|
|
2374
2389
|
return await executeBrokerOrders(id, { _lockHeld: true });
|
|
2375
2390
|
}
|
|
2376
2391
|
if (acc.mode === 'confirm' && orders.length && !isDuplicatePending) {
|
|
@@ -2431,6 +2446,10 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2431
2446
|
const jBook = await rBook.json().catch(() => null);
|
|
2432
2447
|
const bestBid = qVal(jBook?.bids?.[0]?.price);
|
|
2433
2448
|
const bestAsk = qVal(jBook?.asks?.[0]?.price);
|
|
2449
|
+
if (isEntry && ord.symbol !== 'LQDT' && (!(bestBid > 0) || !(bestAsk > 0))) {
|
|
2450
|
+
logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: пустая сторона стакана (bid ${bestBid || 0} / ask ${bestAsk || 0}, планка или аукцион)`);
|
|
2451
|
+
throw Object.assign(new Error(`В стакане ${ord.symbol} отсутствует ${!(bestAsk > 0) ? 'предложение (верхняя планка / аукцион)' : 'спрос (нижняя планка / аукцион)'} — вход пропущен`), { definitive: true });
|
|
2452
|
+
}
|
|
2434
2453
|
if (bestBid > 0 && bestAsk >= bestBid) {
|
|
2435
2454
|
const midPx = (bestBid + bestAsk) / 2;
|
|
2436
2455
|
const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
|
|
@@ -2800,7 +2819,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2800
2819
|
hadExits = true;
|
|
2801
2820
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
2802
2821
|
delete acc.peakPriceBySymbol[ord.symbol];
|
|
2803
|
-
if (ord.
|
|
2822
|
+
if (ord.symbol !== 'LQDT') {
|
|
2823
|
+
acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
|
|
2824
|
+
}
|
|
2804
2825
|
}
|
|
2805
2826
|
} catch (e) {
|
|
2806
2827
|
// Брокер явно отклонил заявку — при повторе нужен новый ID. Сетевая ошибка или таймаут — ID сохраняется:
|
|
@@ -3216,33 +3237,45 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3216
3237
|
const aQty = algoQtyFor(acc, sym, Number(pos.qty) || 0);
|
|
3217
3238
|
if (!aQty) continue;
|
|
3218
3239
|
const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
|
|
3240
|
+
const wantOpenDir = aQty > 0 ? 'buy' : 'sell';
|
|
3219
3241
|
if (activeOrders.some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
|
|
3242
|
+
const activeOpenOrd = activeOrders.find((o) => o.symbol === sym && o.direction === wantOpenDir);
|
|
3243
|
+
const ordAgeMs = activeOpenOrd ? (Date.now() - (Date.parse(activeOpenOrd.orderDate || 0) || Number(acc.entryTsBySymbol?.[sym]) || 0)) : Infinity;
|
|
3244
|
+
const isFillingNow = Boolean(activeOpenOrd && ordAgeMs < 60000);
|
|
3220
3245
|
const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
|
|
3221
3246
|
const avgPx = Number(pos.avgPrice) || 0;
|
|
3222
3247
|
if (lots < 1 || avgPx <= 0) continue;
|
|
3223
3248
|
const side = aQty > 0 ? 'long' : 'short';
|
|
3224
3249
|
const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
|
|
3225
|
-
want.set(sym, { lots, side, stopPx, sp });
|
|
3250
|
+
want.set(sym, { lots, side, stopPx, sp, filling: isFillingNow });
|
|
3226
3251
|
}
|
|
3227
3252
|
let changed = false;
|
|
3228
3253
|
// 1) снять свои стоп-заявки без позиции, сработавшие (их нет среди активных) или с другим объёмом / ценой / стороной
|
|
3229
3254
|
for (const [sym, t] of Object.entries(acc.brokerStopBySymbol)) {
|
|
3230
3255
|
const w = want.get(sym);
|
|
3231
3256
|
const alive = t?.stopOrderId && active.has(t.stopOrderId);
|
|
3257
|
+
if (alive && w?.filling && w.side === t.side) continue;
|
|
3232
3258
|
const same = alive && w && w.lots === t.lots && w.side === t.side && Math.abs(w.stopPx - t.stopPx) < (w.sp.minStep || 0.01) / 2;
|
|
3233
3259
|
if (same) continue;
|
|
3234
3260
|
if (alive) {
|
|
3235
3261
|
try { await tbankCancelStop(acc, t.stopOrderId); } catch (e) { logBrokerEvent(acc, 'broker_stop_error', `${sym}: ${e.message}`); continue; }
|
|
3236
3262
|
}
|
|
3237
|
-
logBrokerEvent(acc, alive ? 'broker_stop_cancel' : 'broker_stop_gone', `${sym}: ${!
|
|
3238
|
-
// стоп
|
|
3239
|
-
if (!alive)
|
|
3263
|
+
logBrokerEvent(acc, alive ? 'broker_stop_cancel' : 'broker_stop_gone', `${sym}: ${!alive ? 'стоп-заявка сработала или снята у брокера' : !w ? 'позиции стратегии нет' : 'объём или цена позиции изменились'}`);
|
|
3264
|
+
// стоп сработал у брокера: включаем 60-минутную паузу повторного входа и блокируем перевыставление стопа до обновления портфеля
|
|
3265
|
+
if (!alive) {
|
|
3266
|
+
const nowStopTs = Date.now();
|
|
3267
|
+
acc.brokerStopCooldown[sym] = nowStopTs;
|
|
3268
|
+
acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
|
|
3269
|
+
acc.exitCooldownBySymbol[sym] = nowStopTs;
|
|
3270
|
+
if (acc.entryTsBySymbol) delete acc.entryTsBySymbol[sym];
|
|
3271
|
+
if (acc.peakPriceBySymbol) delete acc.peakPriceBySymbol[sym];
|
|
3272
|
+
}
|
|
3240
3273
|
delete acc.brokerStopBySymbol[sym];
|
|
3241
3274
|
changed = true;
|
|
3242
3275
|
}
|
|
3243
3276
|
// 2) выставить недостающие
|
|
3244
3277
|
for (const [sym, w] of want) {
|
|
3245
|
-
if (acc.brokerStopBySymbol[sym]) continue;
|
|
3278
|
+
if (acc.brokerStopBySymbol[sym] || w.filling) continue;
|
|
3246
3279
|
const cd = acc.brokerStopCooldown[sym];
|
|
3247
3280
|
if (cd && (Date.now() - cd < 120000 || Number(acc.lastSync?.at || 0) < cd + 30000)) continue;
|
|
3248
3281
|
if (cd) { delete acc.brokerStopCooldown[sym]; changed = true; }
|
|
@@ -3326,12 +3359,14 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3326
3359
|
const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenKind && op.date > 0);
|
|
3327
3360
|
const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
|
|
3328
3361
|
if (avgPx <= 0) continue;
|
|
3362
|
+
const effEntryTs = Math.max(Number(acc.entryTsBySymbol?.[sym]) || 0, Number(lastHistEntryOp?.date) || 0);
|
|
3363
|
+
const heldMin = effEntryTs > 0 ? Math.max(0, (Date.now() - effEntryTs) / 60000) : 999;
|
|
3329
3364
|
|
|
3330
3365
|
if (aQty > 0) {
|
|
3331
3366
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
3332
3367
|
if (lots < 1) continue;
|
|
3333
3368
|
const curGainPct = ((curPx - avgPx) / avgPx) * 100;
|
|
3334
|
-
const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
|
|
3369
|
+
const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
|
|
3335
3370
|
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
|
|
3336
3371
|
if (hitHardStopLoss || profitLockReason) {
|
|
3337
3372
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
@@ -3346,7 +3381,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3346
3381
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
3347
3382
|
if (lots < 1) continue;
|
|
3348
3383
|
const curGainPct = ((avgPx - curPx) / avgPx) * 100;
|
|
3349
|
-
const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
|
|
3384
|
+
const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
|
|
3350
3385
|
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
|
|
3351
3386
|
if (hitHardStopLoss || profitLockReason) {
|
|
3352
3387
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|
|
@@ -3391,7 +3426,9 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3391
3426
|
sentLockOrders.push(ord);
|
|
3392
3427
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
3393
3428
|
delete acc.peakPriceBySymbol[ord.symbol];
|
|
3394
|
-
if (ord.
|
|
3429
|
+
if (ord.symbol !== 'LQDT') {
|
|
3430
|
+
acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
|
|
3431
|
+
}
|
|
3395
3432
|
} catch (e) {
|
|
3396
3433
|
results.push({ symbol: ord.symbol, ok: false, error: e.message });
|
|
3397
3434
|
logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
|
package/server/model/edge.js
CHANGED
|
@@ -49,6 +49,12 @@ export function isRegimeShocked(ds, i, t, side = 'long') {
|
|
|
49
49
|
if (p0 > 0 && p1 > 0 && m0 > 0 && m1 > 0) {
|
|
50
50
|
const ex1d = (p1 / p0 - 1) - (m1 / m0 - 1);
|
|
51
51
|
if (side === 'short' ? ex1d > REGIME_SHOCK_1D : ex1d < -REGIME_SHOCK_1D) return true;
|
|
52
|
+
// Защита от биржевой планки (верхней/нижней границы ценового коридора Мосбиржи):
|
|
53
|
+
// если при миллионном обороте за 10-минутную свечу все сделки идут по одной предельной цене (high === low) после сильного сдвига (>5% к индексу),
|
|
54
|
+
// встречная сторона в стакане отсутствует
|
|
55
|
+
if (tk.high?.[t] > 0 && tk.high[t] === tk.low[t] && tk.value?.[t] > 1e6) {
|
|
56
|
+
if (side === 'short' ? ex1d > 0.05 : ex1d < -0.05) return true;
|
|
57
|
+
}
|
|
52
58
|
}
|
|
53
59
|
if (side === 'long' && t >= 3) {
|
|
54
60
|
const t3 = t - 3;
|
|
@@ -93,9 +99,15 @@ const MSK = 3 * 3600000;
|
|
|
93
99
|
const DAY = 86400000;
|
|
94
100
|
export const SESSION_END_MINS_MSK = 23 * 60 + 40;
|
|
95
101
|
|
|
96
|
-
export const canEnterIntraday = (ts, horizonBars, barInterval = 10) => {
|
|
102
|
+
export const canEnterIntraday = (ts, horizonBars, barInterval = 10, side = null, symbol = null) => {
|
|
97
103
|
const minsMsk = Math.floor(((ts + MSK) % DAY) / 60000);
|
|
98
|
-
|
|
104
|
+
if (minsMsk > SESSION_END_MINS_MSK - horizonBars * barInterval) return false;
|
|
105
|
+
// В первые 20 минут утренней сессии (свечи 07:00 и 07:10 МСК) в бумагах 2-го эшелона стакан ещё пустой,
|
|
106
|
+
// а ночные корпоративные новости и утренние гэпы искажают часовой остаток z — шорты 2-го эшелона в это окно блокируются
|
|
107
|
+
if (side === 'short' && minsMsk >= 7 * 60 && minsMsk < 7 * 60 + 20 && (!symbol || !TIER1_LIQUID_SYMBOLS.has(symbol))) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
99
111
|
};
|
|
100
112
|
|
|
101
113
|
export const shouldExitBeforeNight = (ts, entryTs = 0, stepBars = 3, barInterval = 10) => {
|
package/server/model/forecast.js
CHANGED
|
@@ -5,7 +5,7 @@ import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
|
|
|
5
5
|
import { buildFold, MODELS, HEADS, KINDS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
|
|
6
6
|
import { loadProfiles } from './profiles.js';
|
|
7
7
|
import { isMarketDown } from './research.js';
|
|
8
|
-
import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M } from './edge.js';
|
|
8
|
+
import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M, canEnterIntraday } from './edge.js';
|
|
9
9
|
export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults };
|
|
10
10
|
import { newsForSymbols, blockingNews } from '../news/index.js';
|
|
11
11
|
import { liveDividendGuard } from './dividends.js';
|
|
@@ -403,13 +403,18 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
403
403
|
const tierMult = edgeMults[i] || symbolEdgeMult(tk.symbol);
|
|
404
404
|
const symNeedL = Math.max(needL * tierMult, 0.0030);
|
|
405
405
|
const symNeedS = Math.max(needS * tierMult, 0.0031);
|
|
406
|
-
// Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу) и структурного слома режима (≥20% за день или обвал ≥9,5% за 30м):
|
|
406
|
+
// Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу), биржевых планок и структурного слома режима (≥20% за день или обвал ≥9,5% за 30м):
|
|
407
407
|
// обычные рабочие импульсы 1,5–3,5% НЕ блокируются, так как именно на возврате к среднему после локального выброса модель формирует сигнал!
|
|
408
408
|
const EXTREME_BAR_MOVE_PCT = 4.5;
|
|
409
|
-
const
|
|
410
|
-
const
|
|
409
|
+
const isFlatLimitBar = tk.high?.[idx] > 0 && tk.high[idx] === tk.low[idx] && tk.value?.[idx] > 1e6;
|
|
410
|
+
const isLimitUpLocked = isFlatLimitBar && excess1d > 0.05;
|
|
411
|
+
const isLimitDownLocked = isFlatLimitBar && excess1d < -0.05;
|
|
412
|
+
const barDecisionTs = ds.timeline[idx] || Date.now();
|
|
413
|
+
const isMorningOpenShortBlocked = autoAdapt && !canEnterIntraday(barDecisionTs, H, 10, 'short', tk.symbol);
|
|
414
|
+
const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M || isLimitDownLocked;
|
|
415
|
+
const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D || isLimitUpLocked;
|
|
411
416
|
const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
|
|
412
|
-
const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
|
|
417
|
+
const isExtremeSpike = Boolean(isRegimeSqueeze || isMorningOpenShortBlocked || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
|
|
413
418
|
const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
|
|
414
419
|
const liveShortOk = !isExtremeSpike;
|
|
415
420
|
// сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
|
package/server/model/live.js
CHANGED
|
@@ -461,7 +461,7 @@ class LiveEngine {
|
|
|
461
461
|
};
|
|
462
462
|
const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
|
|
463
463
|
const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
|
|
464
|
-
const candS = (dir === 'long' || !timeOk) ? [] : r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
|
|
464
|
+
const candS = (dir === 'long' || !timeOk) ? [] : r.rows.filter((x) => x.shortSignal && open(x) && (!autoAdapt || canEnterIntraday(ts, H, BAR_INTERVAL, 'short', x.symbol))).sort((a, b) => a.expected - b.expected);
|
|
465
465
|
const usedNow = [...s.held.entries()].filter(([sym]) => !closing.has(sym)).reduce((sum, [, h]) => sum + h.alloc, 0);
|
|
466
466
|
if (autoAdapt && usedNow > s.equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
|
|
467
467
|
const bestNewEdge = Math.max(candL.length ? candL[0].expected : 0, candS.length ? Math.abs(candS[0].expected) : 0);
|
package/server/model/research.js
CHANGED
|
@@ -43,7 +43,8 @@ export function isMarketDown(ds, t) {
|
|
|
43
43
|
// 12: калибровка индивидуальных порогов 2–3 эшелона по ликвидности, защита от шорт-сквизов (≥20% за день) и обвалов (≥9,5% за 30м), отбор доступных шортов до Top-K
|
|
44
44
|
// 13: полная докачка левого края истории свечей (раньше при уже накопленных свечах 2022 год не догружался)
|
|
45
45
|
// 14: фиксация начала датасета современного 17-часового режима Мосбиржи (18.01.2024) и якоря фолдов после редомициляции (13.06.2024)
|
|
46
|
-
|
|
46
|
+
// 15: защита от биржевых планок (high === low) и блокировка утренних шортов 2-го эшелона в первые 20 минут сессии (07:00–07:20 МСК)
|
|
47
|
+
const DATA_VERSION = 15;
|
|
47
48
|
|
|
48
49
|
const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
|
|
49
50
|
function reuseParamsKey(params) {
|
|
@@ -466,7 +467,7 @@ async function run(runId, p, state) {
|
|
|
466
467
|
const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
|
|
467
468
|
const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
|
|
468
469
|
const okLong = (i) => !divRule(i, t)?.blockLong && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
|
|
469
|
-
const okShort = (i) => !divRule(i, t)?.blockShort && (!shortable || shortable.has(tokens[i].symbol)) && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'short'));
|
|
470
|
+
const okShort = (i) => !divRule(i, t)?.blockShort && (!shortable || shortable.has(tokens[i].symbol)) && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || (!isRegimeShocked(ds, i, t, 'short') && canEnterIntraday(barTs, H, BAR_INTERVAL, 'short', tokens[i].symbol)));
|
|
470
471
|
let order, entries;
|
|
471
472
|
if (dir !== 'long') {
|
|
472
473
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
@@ -1124,7 +1125,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1124
1125
|
const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
|
|
1125
1126
|
const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
|
|
1126
1127
|
const okLong = (i) => !divRule(i, t)?.blockLong && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
|
|
1127
|
-
const okShort = (i) => !divRule(i, t)?.blockShort && (!autoAdapt || !isRegimeShocked(ds, i, t, 'short'));
|
|
1128
|
+
const okShort = (i) => !divRule(i, t)?.blockShort && (!autoAdapt || (!isRegimeShocked(ds, i, t, 'short') && canEnterIntraday(barTs, H, BAR_INTERVAL, 'short', tokens[i].symbol)));
|
|
1128
1129
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
1129
1130
|
const need = effMinEdge * 2 * realCost * ENTRY_EDGE_MULT, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
|
|
1130
1131
|
let order, entries;
|