neuroalpha 0.5.30 → 0.5.32
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 +122 -15
- 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.32",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/server/brokers/index.js
CHANGED
|
@@ -1642,9 +1642,9 @@ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0
|
|
|
1642
1642
|
function sessionEdgeWarning(now = Date.now()) {
|
|
1643
1643
|
const d = new Date(now + MSK);
|
|
1644
1644
|
const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
1645
|
-
// открытие утренней сессии 06:50–07:
|
|
1646
|
-
if ((mins >= 6 * 60 + 50 && mins <= 7 * 60 +
|
|
1647
|
-
return 'Первые
|
|
1645
|
+
// открытие утренней сессии 06:50–07:25 (отыгрыш ночных гэпов и формирование стакана), открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
|
|
1646
|
+
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)) {
|
|
1647
|
+
return 'Первые минуты утренней/основной сессии или закрытие дневной (широкий спред и утренние гэпы в стакане)';
|
|
1648
1648
|
}
|
|
1649
1649
|
return null;
|
|
1650
1650
|
}
|
|
@@ -1652,6 +1652,86 @@ function sessionEdgeWarning(now = Date.now()) {
|
|
|
1652
1652
|
// ---------- формирование и исполнение пакета лимитных заявок ----------
|
|
1653
1653
|
const ACCOUNT_LOCK_TTL_MS = 75000;
|
|
1654
1654
|
const accountBusyLocks = new Map(); // id -> { since, token }
|
|
1655
|
+
const entryImpulseWatch = new Map(); // `${accId}:${symbol}:${side}` -> { firstTs, lastTs, lastPx, extremePx }
|
|
1656
|
+
|
|
1657
|
+
// Фильтр затухания минутного импульса перед входом (Impulse Exhaustion):
|
|
1658
|
+
// не даёт продавать в шорт посередине вертикально растущей минутной свечи (или покупать в полёт падающего ножа),
|
|
1659
|
+
// дожидаясь остановки разгона и подтверждённого отката от пика/дна (с шагом проверки 20 секунд; без принудительного входа по таймауту)
|
|
1660
|
+
async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
|
|
1661
|
+
if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
|
|
1662
|
+
for (const [k, v] of entryImpulseWatch) {
|
|
1663
|
+
if (nowTs - v.lastTs > 15 * 60000) entryImpulseWatch.delete(k);
|
|
1664
|
+
}
|
|
1665
|
+
const ready = [];
|
|
1666
|
+
const deferred = [];
|
|
1667
|
+
for (const r of cands) {
|
|
1668
|
+
const sym = r.symbol;
|
|
1669
|
+
const isShort = side === 'short';
|
|
1670
|
+
const hasCounterImpulse = isShort
|
|
1671
|
+
? ((r.lastMovePct || 0) > 0.15 || (r.selfPct || 0) < -0.10)
|
|
1672
|
+
: ((r.lastMovePct || 0) < -0.15 || (r.selfPct || 0) > 0.10);
|
|
1673
|
+
if (!hasCounterImpulse) {
|
|
1674
|
+
ready.push(r);
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
const bars1m = await fetchTbankCandles(sym, nowTs - 15 * 60000, nowTs, { includeIncomplete: true, intervalMin: 1 }).catch(() => null);
|
|
1678
|
+
if (!bars1m?.length) {
|
|
1679
|
+
ready.push(r);
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
const last1m = bars1m[bars1m.length - 1];
|
|
1683
|
+
const prev1m = bars1m.length >= 2 ? bars1m[bars1m.length - 2] : null;
|
|
1684
|
+
const curPx = Number(last1m?.close || r.price) || 0;
|
|
1685
|
+
const sp = specs[sym] || { minStep: 0.01 };
|
|
1686
|
+
const step = sp.minStep > 0 ? sp.minStep : 0.01;
|
|
1687
|
+
const wKey = `${acc.id}:${sym}:${side}`;
|
|
1688
|
+
const prevW = entryImpulseWatch.get(wKey) || null;
|
|
1689
|
+
let max1m = curPx, min1m = curPx > 0 ? curPx : Infinity;
|
|
1690
|
+
for (const b of bars1m) {
|
|
1691
|
+
if (b.high > max1m) max1m = b.high;
|
|
1692
|
+
if (b.low > 0 && b.low < min1m) min1m = b.low;
|
|
1693
|
+
}
|
|
1694
|
+
const windowRangePct = (Number.isFinite(min1m) && min1m > 0 && max1m > min1m) ? ((max1m - min1m) / min1m) * 100 : 0;
|
|
1695
|
+
const impulseMagPct = Math.max(Math.abs(Number(r.lastMovePct) || 0), windowRangePct);
|
|
1696
|
+
const minPullbackPct = Math.max(0.15, Math.min(0.55, impulseMagPct * 0.15));
|
|
1697
|
+
const isStrongSurge = impulseMagPct >= 1.5;
|
|
1698
|
+
|
|
1699
|
+
if (isShort) {
|
|
1700
|
+
const recentHigh = Math.max(prevW?.extremePx || 0, curPx, max1m);
|
|
1701
|
+
const pullbackPct = recentHigh > 0 ? ((recentHigh - curPx) / recentHigh) * 100 : 0;
|
|
1702
|
+
const pullbackTicks = recentHigh > 0 ? (recentHigh - curPx) / step : 0;
|
|
1703
|
+
const barTurnedDown = Boolean(last1m && prev1m && last1m.close <= last1m.open && last1m.high <= prev1m.high + step * 0.5);
|
|
1704
|
+
const peakedAndPulledBack = pullbackPct >= minPullbackPct && pullbackTicks >= 2 && (!last1m || last1m.close <= last1m.open + step * 0.5);
|
|
1705
|
+
const tickedDownFromPrev = Boolean(!isStrongSurge && prevW && curPx <= prevW.lastPx - step && pullbackTicks >= 2 && pullbackPct >= 0.12);
|
|
1706
|
+
const readyShort = isStrongSurge ? (peakedAndPulledBack && barTurnedDown) : (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev);
|
|
1707
|
+
if (readyShort) {
|
|
1708
|
+
entryImpulseWatch.delete(wKey);
|
|
1709
|
+
r.price = curPx;
|
|
1710
|
+
ready.push(r);
|
|
1711
|
+
} else {
|
|
1712
|
+
entryImpulseWatch.set(wKey, { firstTs: prevW?.firstTs || nowTs, lastTs: nowTs, lastPx: curPx, extremePx: recentHigh });
|
|
1713
|
+
deferred.push({ symbol: sym, side, curPx, extremePx: recentHigh });
|
|
1714
|
+
}
|
|
1715
|
+
} else {
|
|
1716
|
+
const recentLow = Math.min(prevW?.extremePx || Infinity, min1m);
|
|
1717
|
+
const bouncePct = Number.isFinite(recentLow) && recentLow > 0 ? ((curPx - recentLow) / recentLow) * 100 : 0;
|
|
1718
|
+
const bounceTicks = Number.isFinite(recentLow) && recentLow > 0 ? (curPx - recentLow) / step : 0;
|
|
1719
|
+
const barTurnedUp = Boolean(last1m && prev1m && last1m.close >= last1m.open && last1m.low >= prev1m.low - step * 0.5);
|
|
1720
|
+
const bottomedAndBounced = bouncePct >= minPullbackPct && bounceTicks >= 2 && (!last1m || last1m.close >= last1m.open - step * 0.5);
|
|
1721
|
+
const tickedUpFromPrev = Boolean(!isStrongSurge && prevW && curPx >= prevW.lastPx + step && bounceTicks >= 2 && bouncePct >= 0.12);
|
|
1722
|
+
const readyLong = isStrongSurge ? (bottomedAndBounced && barTurnedUp) : (bottomedAndBounced || barTurnedUp || tickedUpFromPrev);
|
|
1723
|
+
if (readyLong) {
|
|
1724
|
+
entryImpulseWatch.delete(wKey);
|
|
1725
|
+
r.price = curPx;
|
|
1726
|
+
ready.push(r);
|
|
1727
|
+
} else {
|
|
1728
|
+
entryImpulseWatch.set(wKey, { firstTs: prevW?.firstTs || nowTs, lastTs: nowTs, lastPx: curPx, extremePx: recentLow });
|
|
1729
|
+
deferred.push({ symbol: sym, side, curPx, extremePx: recentLow });
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
return { ready, deferred };
|
|
1734
|
+
}
|
|
1655
1735
|
|
|
1656
1736
|
function isAccountBusy(id) {
|
|
1657
1737
|
const cur = accountBusyLocks.get(id);
|
|
@@ -1830,10 +1910,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1830
1910
|
// Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
|
|
1831
1911
|
const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
|
|
1832
1912
|
const divBlocked = [];
|
|
1833
|
-
const
|
|
1913
|
+
const entryTimeOk = timeOk && !edgeWarn;
|
|
1914
|
+
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)
|
|
1834
1915
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
1835
1916
|
.sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
1836
|
-
const allShortSignals = (rawDir !== 'long' &&
|
|
1917
|
+
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)
|
|
1837
1918
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
1838
1919
|
.sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
1839
1920
|
const nonShortableSkipped = [];
|
|
@@ -2052,8 +2133,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2052
2133
|
if (bQ > 0 && !closing.has(r.symbol)) return false;
|
|
2053
2134
|
return !(aQ < 0 && !closing.has(r.symbol));
|
|
2054
2135
|
});
|
|
2055
|
-
const
|
|
2056
|
-
const
|
|
2136
|
+
const candidateBuys = filterBrokerDynamicTopK(rawNewBuys, minBuyEdgePct, Math.max(0, K - activeLongs));
|
|
2137
|
+
const candidateShorts = filterBrokerDynamicTopK(rawNewShorts, minShortEdgePct, Math.max(0, K - activeShorts));
|
|
2138
|
+
const [buyExhaustion, shortExhaustion] = await Promise.all([
|
|
2139
|
+
filterByImpulseExhaustion(acc, candidateBuys, 'buy', specs, nowTs),
|
|
2140
|
+
filterByImpulseExhaustion(acc, candidateShorts, 'short', specs, nowTs),
|
|
2141
|
+
]);
|
|
2142
|
+
const newBuys = buyExhaustion.ready;
|
|
2143
|
+
const newShorts = shortExhaustion.ready;
|
|
2144
|
+
const deferredByImpulse = [...buyExhaustion.deferred, ...shortExhaustion.deferred];
|
|
2057
2145
|
const totalNew = newBuys.length + newShorts.length;
|
|
2058
2146
|
const activeTotal = Math.max(2, activeLongs + activeShorts + totalNew);
|
|
2059
2147
|
const dynSlotRub = (isOwnerPlan && autoAdapt)
|
|
@@ -2227,6 +2315,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2227
2315
|
if (!orders.length) {
|
|
2228
2316
|
if (!timeOk) {
|
|
2229
2317
|
diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
|
|
2318
|
+
} else if (edgeWarn) {
|
|
2319
|
+
diagNote = `На свече ${fc.bar} новые входы временно отложены: ${edgeWarn.toLowerCase()}.`;
|
|
2230
2320
|
} else if ((newBuys.length || newShorts.length) && availRub < 1500) {
|
|
2231
2321
|
const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
|
|
2232
2322
|
const cashNow = round(acc.lastSync?.cashRub || 0, 0);
|
|
@@ -2237,6 +2327,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2237
2327
|
} else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
|
|
2238
2328
|
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
2239
2329
|
diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
|
|
2330
|
+
} else if (!newBuys.length && !newShorts.length && deferredByImpulse.length > 0) {
|
|
2331
|
+
diagNote = `Сигнал ${deferredByImpulse.map((d) => `${d.symbol} (${d.side === 'short' ? 'шорт' : 'покупка'}, текущая ${d.curPx} ₽)`).join(', ')} взят на прицел: ждём затухания встречного минутного импульса для входа по лучшей цене (проверка каждые 20 сек).`;
|
|
2240
2332
|
} else if (!newBuys.length && !newShorts.length) {
|
|
2241
2333
|
const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
2242
2334
|
const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует 60-минутная пауза после недавнего выхода из позиции, чтобы не платить повторную комиссию.` : '';
|
|
@@ -2290,7 +2382,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2290
2382
|
results: [],
|
|
2291
2383
|
};
|
|
2292
2384
|
saveRawAccounts(list);
|
|
2293
|
-
if (acc.mode === 'auto' &&
|
|
2385
|
+
if (acc.mode === 'auto' && acc.token && orders.length && (!edgeWarn || orders.every((o) => o.action === 'sell' || o.action === 'cover'))) {
|
|
2294
2386
|
return await executeBrokerOrders(id, { _lockHeld: true });
|
|
2295
2387
|
}
|
|
2296
2388
|
if (acc.mode === 'confirm' && orders.length && !isDuplicatePending) {
|
|
@@ -2351,6 +2443,10 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2351
2443
|
const jBook = await rBook.json().catch(() => null);
|
|
2352
2444
|
const bestBid = qVal(jBook?.bids?.[0]?.price);
|
|
2353
2445
|
const bestAsk = qVal(jBook?.asks?.[0]?.price);
|
|
2446
|
+
if (isEntry && ord.symbol !== 'LQDT' && (!(bestBid > 0) || !(bestAsk > 0))) {
|
|
2447
|
+
logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: пустая сторона стакана (bid ${bestBid || 0} / ask ${bestAsk || 0}, планка или аукцион)`);
|
|
2448
|
+
throw Object.assign(new Error(`В стакане ${ord.symbol} отсутствует ${!(bestAsk > 0) ? 'предложение (верхняя планка / аукцион)' : 'спрос (нижняя планка / аукцион)'} — вход пропущен`), { definitive: true });
|
|
2449
|
+
}
|
|
2354
2450
|
if (bestBid > 0 && bestAsk >= bestBid) {
|
|
2355
2451
|
const midPx = (bestBid + bestAsk) / 2;
|
|
2356
2452
|
const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
|
|
@@ -2392,16 +2488,20 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2392
2488
|
}
|
|
2393
2489
|
} else {
|
|
2394
2490
|
// Входящие заявки (buy / short):
|
|
2395
|
-
// • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx,
|
|
2491
|
+
// • при узком спреде (<= 0,06% или <= 2 шагов цены — голубые фишки 1-го эшелона) берём по лучшей встречной цене в пределах защитного лимита finalLimitPx,
|
|
2492
|
+
// КРОМЕ случая, когда в первых 5 уровнях стакана встречный объём давит в >= 2,2 раза сильнее нашей стороны (тогда встаём пассивно на своей стороне спреда, чтобы остаток импульса налил нам по лучшей цене!);
|
|
2396
2493
|
// • при более широком спреде (2–3 эшелон) встаём на середине спреда (или на 1 шаг внутри спреда), не переплачивая маркетмейкеру!
|
|
2397
|
-
const
|
|
2494
|
+
const bidTop5 = (jBook?.bids || []).slice(0, 5).reduce((s, lv) => s + (Number(lv.quantity) || 0), 0);
|
|
2495
|
+
const askTop5 = (jBook?.asks || []).slice(0, 5).reduce((s, lv) => s + (Number(lv.quantity) || 0), 0);
|
|
2496
|
+
const opposingBookPressure = isBuy ? (askTop5 > bidTop5 * 2.2) : (bidTop5 > askTop5 * 2.2);
|
|
2497
|
+
const tightSpread = (spreadPct <= 0.06 || (bestAsk - bestBid) <= 2.1 * step) && !opposingBookPressure;
|
|
2398
2498
|
if (tightSpread) {
|
|
2399
2499
|
const activePx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
|
|
2400
2500
|
if (activePx > 0) finalLimitPx = isBuy ? Math.min(finalLimitPx, activePx) : Math.max(finalLimitPx, activePx);
|
|
2401
2501
|
} else {
|
|
2402
2502
|
const passivePx = isBuy
|
|
2403
|
-
? Math.min(finalLimitPx, roundToStep(Math.min(midPx, bestBid + step), step, sp.decimals))
|
|
2404
|
-
: Math.max(finalLimitPx, roundToStep(Math.max(midPx, bestAsk - step), step, sp.decimals));
|
|
2503
|
+
? Math.min(finalLimitPx, roundToStep(opposingBookPressure ? bestBid : Math.min(midPx, bestBid + step), step, sp.decimals))
|
|
2504
|
+
: Math.max(finalLimitPx, roundToStep(opposingBookPressure ? bestAsk : Math.max(midPx, bestAsk - step), step, sp.decimals));
|
|
2405
2505
|
if (passivePx > 0) finalLimitPx = passivePx;
|
|
2406
2506
|
}
|
|
2407
2507
|
}
|
|
@@ -3150,9 +3250,16 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3150
3250
|
if (alive) {
|
|
3151
3251
|
try { await tbankCancelStop(acc, t.stopOrderId); } catch (e) { logBrokerEvent(acc, 'broker_stop_error', `${sym}: ${e.message}`); continue; }
|
|
3152
3252
|
}
|
|
3153
|
-
logBrokerEvent(acc, alive ? 'broker_stop_cancel' : 'broker_stop_gone', `${sym}: ${!
|
|
3154
|
-
// стоп
|
|
3155
|
-
if (!alive)
|
|
3253
|
+
logBrokerEvent(acc, alive ? 'broker_stop_cancel' : 'broker_stop_gone', `${sym}: ${!alive ? 'стоп-заявка сработала или снята у брокера' : !w ? 'позиции стратегии нет' : 'объём или цена позиции изменились'}`);
|
|
3254
|
+
// стоп сработал у брокера: включаем 60-минутную паузу повторного входа и блокируем перевыставление стопа до обновления портфеля
|
|
3255
|
+
if (!alive) {
|
|
3256
|
+
const nowStopTs = Date.now();
|
|
3257
|
+
acc.brokerStopCooldown[sym] = nowStopTs;
|
|
3258
|
+
acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
|
|
3259
|
+
acc.exitCooldownBySymbol[sym] = nowStopTs;
|
|
3260
|
+
if (acc.entryTsBySymbol) delete acc.entryTsBySymbol[sym];
|
|
3261
|
+
if (acc.peakPriceBySymbol) delete acc.peakPriceBySymbol[sym];
|
|
3262
|
+
}
|
|
3156
3263
|
delete acc.brokerStopBySymbol[sym];
|
|
3157
3264
|
changed = true;
|
|
3158
3265
|
}
|
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;
|