neuroalpha 0.5.32 → 0.5.34
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 +52 -26
- package/server/news/index.js +25 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.34",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/server/brokers/index.js
CHANGED
|
@@ -524,6 +524,7 @@ export function saveBrokerAccount(input = {}) {
|
|
|
524
524
|
entryTsBySymbol: acctChanged ? {} : (prev.entryTsBySymbol || {}),
|
|
525
525
|
exitCooldownBySymbol: acctChanged ? {} : (prev.exitCooldownBySymbol || {}),
|
|
526
526
|
entryFailCooldownBySymbol: acctChanged ? {} : (prev.entryFailCooldownBySymbol || {}),
|
|
527
|
+
entryFailReasonBySymbol: acctChanged ? {} : (prev.entryFailReasonBySymbol || {}),
|
|
527
528
|
peakPriceBySymbol: acctChanged ? {} : (prev.peakPriceBySymbol || {}),
|
|
528
529
|
baseRunId: nextBaseRunId,
|
|
529
530
|
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
@@ -1612,7 +1613,7 @@ export async function freezeBrokerBaseline(id) {
|
|
|
1612
1613
|
}
|
|
1613
1614
|
|
|
1614
1615
|
// Оценка срабатывания скользящего Take-Profit и защиты заработанной прибыли от отката в минус (по лучшей цене с момента входа peakPx)
|
|
1615
|
-
function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10) {
|
|
1616
|
+
function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0.10, heldMin = 999) {
|
|
1616
1617
|
if (!(avgPx > 0 && curPx > 0)) return null;
|
|
1617
1618
|
const isLong = side === 'long';
|
|
1618
1619
|
const curGainPct = isLong ? ((curPx - avgPx) / avgPx) * 100 : ((avgPx - curPx) / avgPx) * 100;
|
|
@@ -1621,19 +1622,22 @@ function evaluateProfitLock(sym, side, avgPx, curPx, peakPx, roundTripFeePct = 0
|
|
|
1621
1622
|
: (isLong ? Math.max(avgPx, curPx) : Math.min(avgPx, curPx));
|
|
1622
1623
|
const peakGainPct = isLong ? ((effPeakPx - avgPx) / avgPx) * 100 : ((avgPx - effPeakPx) / avgPx) * 100;
|
|
1623
1624
|
const tierMult = symbolEdgeMult(sym);
|
|
1624
|
-
//
|
|
1625
|
+
// Минимальный чистый порог фиксации с учётом спреда стакана и двойной комиссии (чтобы выход не закрывался в 0.00% после исполнения лимитки)
|
|
1626
|
+
const minNetFloorPct = Math.max(roundTripFeePct + 0.30, round(0.38 * tierMult, 2));
|
|
1627
|
+
// 1) Скользящий Take-Profit при сильном перевыполнении цели (в первые 15 мин требуем более сильный импульс 1.55×tierMult, далее 1.20×tierMult):
|
|
1625
1628
|
// фиксируем не менее 70% набранного хода (или при откате на 0.45% с вершины)
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
+
const tpTriggerPct = (heldMin < 15 ? 1.55 : 1.20) * tierMult;
|
|
1630
|
+
if (peakGainPct >= tpTriggerPct) {
|
|
1631
|
+
const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, minNetFloorPct);
|
|
1632
|
+
if (curGainPct <= trailFloorPct && curGainPct >= minNetFloorPct) {
|
|
1629
1633
|
return `Фиксация прибыли (Take-Profit: пик +${round(peakGainPct, 2)}% → +${round(curGainPct, 2)}%)`;
|
|
1630
1634
|
}
|
|
1631
1635
|
}
|
|
1632
|
-
// 2) Защита заработанного плюса / безубытка (от +0.
|
|
1633
|
-
// не позволяем плюсовой сделке развернуться обратно в
|
|
1634
|
-
if (peakGainPct >= 0.
|
|
1635
|
-
const breakevenFloorPct = Math.max(
|
|
1636
|
-
if (curGainPct <= breakevenFloorPct) {
|
|
1636
|
+
// 2) Защита заработанного плюса / безубытка (после первых 15 минут удержания, от +0.85% на 1-м эшелоне и от +1.02% на 2-м эшелоне):
|
|
1637
|
+
// не позволяем зрелой плюсовой сделке развернуться обратно в убыток, но и не режем позицию стартовым шумом стакана в первые минуты
|
|
1638
|
+
if (heldMin >= 15 && peakGainPct >= 0.85 * tierMult) {
|
|
1639
|
+
const breakevenFloorPct = Math.max(minNetFloorPct, peakGainPct * 0.45);
|
|
1640
|
+
if (curGainPct <= breakevenFloorPct && curGainPct >= roundTripFeePct + 0.22) {
|
|
1637
1641
|
return `Защита прибыли от отката (пик +${round(peakGainPct, 2)}% → фиксация +${round(curGainPct, 2)}%)`;
|
|
1638
1642
|
}
|
|
1639
1643
|
}
|
|
@@ -1834,6 +1838,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1834
1838
|
const entryTsMap = acc.entryTsBySymbol || {};
|
|
1835
1839
|
const exitCooldownMap = acc.exitCooldownBySymbol || {};
|
|
1836
1840
|
const entryFailMap = acc.entryFailCooldownBySymbol || {};
|
|
1841
|
+
const entryFailReasonMap = acc.entryFailReasonBySymbol || {};
|
|
1837
1842
|
// Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
|
|
1838
1843
|
// Отдельного вечернего ужесточения нет: проверка 23.10.2026 (первые 70% и последние 30% истории) показала,
|
|
1839
1844
|
// что порог ×1,25 после 19:00 МСК снижает итог на обеих частях — правила совпадают с бэктестом.
|
|
@@ -1843,10 +1848,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1843
1848
|
const minShortEdgePct = round(baseShortNeedPct, 3);
|
|
1844
1849
|
const reqBuyEdgeOf = (sym) => rowMap.get(sym)?.buyThresholdPct ?? round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
|
|
1845
1850
|
const reqShortEdgeOf = (sym) => rowMap.get(sym)?.shortThresholdPct ?? round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
|
|
1846
|
-
const
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
);
|
|
1851
|
+
const isExitCooldown = (sym) => Boolean(autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000));
|
|
1852
|
+
const isEntryFailCooldown = (sym) => Boolean(entryFailMap[sym] && (nowTs - entryFailMap[sym] < 5 * 60000));
|
|
1853
|
+
const isInCooldown = (sym) => isExitCooldown(sym) || isEntryFailCooldown(sym);
|
|
1850
1854
|
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
|
|
1851
1855
|
// если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
|
|
1852
1856
|
// временно исключаем такую бумагу из новых входов!
|
|
@@ -1906,7 +1910,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1906
1910
|
const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
|
|
1907
1911
|
const nb = params.newsFilter ? blockingNews(6) : null;
|
|
1908
1912
|
|
|
1909
|
-
const
|
|
1913
|
+
const isRawSignalRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol));
|
|
1914
|
+
const cooldownSkipped = fc.rows.filter((r) => isRawSignalRow(r) && isExitCooldown(r.symbol));
|
|
1915
|
+
const entryFailSkipped = fc.rows.filter((r) => isRawSignalRow(r) && !isExitCooldown(r.symbol) && isEntryFailCooldown(r.symbol));
|
|
1910
1916
|
// Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
|
|
1911
1917
|
const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
|
|
1912
1918
|
const divBlocked = [];
|
|
@@ -1977,7 +1983,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1977
1983
|
if (aQty > 0) {
|
|
1978
1984
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1979
1985
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1980
|
-
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
|
|
1986
|
+
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
1981
1987
|
const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
|
|
1982
1988
|
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
|
|
1983
1989
|
const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
@@ -2007,7 +2013,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2007
2013
|
} else if (aQty < 0) {
|
|
2008
2014
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
2009
2015
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
2010
|
-
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct) : null;
|
|
2016
|
+
const profitLockReason = autoAdapt ? evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin) : null;
|
|
2011
2017
|
const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
|
|
2012
2018
|
// Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
|
|
2013
2019
|
const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
|
|
@@ -2331,14 +2337,17 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2331
2337
|
diagNote = `Сигнал ${deferredByImpulse.map((d) => `${d.symbol} (${d.side === 'short' ? 'шорт' : 'покупка'}, текущая ${d.curPx} ₽)`).join(', ')} взят на прицел: ждём затухания встречного минутного импульса для входа по лучшей цене (проверка каждые 20 сек).`;
|
|
2332
2338
|
} else if (!newBuys.length && !newShorts.length) {
|
|
2333
2339
|
const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
2334
|
-
const
|
|
2340
|
+
const failNote = entryFailSkipped.length
|
|
2341
|
+
? ` Заявка по ${entryFailSkipped.map((r) => `${r.symbol} (${entryFailReasonMap[r.symbol] || 'отказ шлюза брокера'})`).join('; ')} временно отложена на 5 мин после ошибки приёма у брокера.`
|
|
2342
|
+
: '';
|
|
2343
|
+
const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '';
|
|
2335
2344
|
const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2336
2345
|
const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2337
2346
|
const thrRangeStr = dir !== 'long'
|
|
2338
2347
|
? `+${round(minBuyEdgePct, 2)}% / +${buyThrT2}% в покупку, −${round(minShortEdgePct, 2)}% / −${shortThrT2}% в шорт`
|
|
2339
2348
|
: `+${round(minBuyEdgePct, 2)}% (1-й эшелон) / +${buyThrT2}% (2–3-й эшелон)`;
|
|
2340
|
-
diagNote = cdNote
|
|
2341
|
-
? `На свече ${fc.bar} новых входов нет.${cdNote}${noShortNote}`
|
|
2349
|
+
diagNote = (failNote || cdNote)
|
|
2350
|
+
? `На свече ${fc.bar} новых входов нет.${failNote}${cdNote}${noShortNote}`
|
|
2342
2351
|
: `На свече ${fc.bar} ни одна доступная бумага не превысила индивидуальный порог выгоды (${thrRangeStr})${noShortNote}.`;
|
|
2343
2352
|
}
|
|
2344
2353
|
}
|
|
@@ -2419,6 +2428,9 @@ function formatTbankOrderError(status, rawText = '', ord = {}) {
|
|
|
2419
2428
|
if (code.includes('30052') || /short.*disabled|short.*not/i.test(code)) {
|
|
2420
2429
|
return 'По данной бумаге брокер временно запретил короткие продажи (шорт)';
|
|
2421
2430
|
}
|
|
2431
|
+
if (code.includes('30099') || /outside the limits/i.test(code)) {
|
|
2432
|
+
return 'Цена бумаги вышла за границы дневного ценового коридора у брокера (код 30099: Мосбиржа расширила планку, шлюз брокера ещё обновляет лимиты)';
|
|
2433
|
+
}
|
|
2422
2434
|
return `T-Invest PostOrder HTTP ${status}${msg ? `: ${msg}` : ''}${desc ? ` (код ${desc})` : ''}`;
|
|
2423
2435
|
}
|
|
2424
2436
|
|
|
@@ -2770,6 +2782,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2770
2782
|
acc.entryTsBySymbol = acc.entryTsBySymbol || {};
|
|
2771
2783
|
acc.exitCooldownBySymbol = acc.exitCooldownBySymbol || {};
|
|
2772
2784
|
acc.entryFailCooldownBySymbol = acc.entryFailCooldownBySymbol || {};
|
|
2785
|
+
acc.entryFailReasonBySymbol = acc.entryFailReasonBySymbol || {};
|
|
2773
2786
|
acc.peakPriceBySymbol = acc.peakPriceBySymbol || {};
|
|
2774
2787
|
const execNowTs = Date.now();
|
|
2775
2788
|
const exitOrders = batch.orders.filter((o) => o.action === 'sell' || o.action === 'cover');
|
|
@@ -2812,11 +2825,14 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2812
2825
|
acc.peakPriceBySymbol[ord.symbol] = Number(ord.limitPrice || ord.refPrice) || 0;
|
|
2813
2826
|
delete acc.exitCooldownBySymbol[ord.symbol];
|
|
2814
2827
|
delete acc.entryFailCooldownBySymbol[ord.symbol];
|
|
2828
|
+
delete acc.entryFailReasonBySymbol[ord.symbol];
|
|
2815
2829
|
} else if (ord.action === 'sell' || ord.action === 'cover') {
|
|
2816
2830
|
hadExits = true;
|
|
2817
2831
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
2818
2832
|
delete acc.peakPriceBySymbol[ord.symbol];
|
|
2819
|
-
if (ord.
|
|
2833
|
+
if (ord.symbol !== 'LQDT') {
|
|
2834
|
+
acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
|
|
2835
|
+
}
|
|
2820
2836
|
}
|
|
2821
2837
|
} catch (e) {
|
|
2822
2838
|
// Брокер явно отклонил заявку — при повторе нужен новый ID. Сетевая ошибка или таймаут — ID сохраняется:
|
|
@@ -2826,6 +2842,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2826
2842
|
else ord.uncertainSend = true;
|
|
2827
2843
|
if (ord.action === 'buy' || ord.action === 'short') {
|
|
2828
2844
|
acc.entryFailCooldownBySymbol[ord.symbol] = execNowTs;
|
|
2845
|
+
acc.entryFailReasonBySymbol[ord.symbol] = e.message;
|
|
2829
2846
|
}
|
|
2830
2847
|
results.push({ symbol: ord.symbol, ok: false, error: e.message, uncertain: !e.definitive });
|
|
2831
2848
|
logBrokerEvent(acc, 'order_error', `${ord.action} ${ord.symbol} ${ord.lots} л.: ${e.message}`);
|
|
@@ -3232,19 +3249,24 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3232
3249
|
const aQty = algoQtyFor(acc, sym, Number(pos.qty) || 0);
|
|
3233
3250
|
if (!aQty) continue;
|
|
3234
3251
|
const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
|
|
3252
|
+
const wantOpenDir = aQty > 0 ? 'buy' : 'sell';
|
|
3235
3253
|
if (activeOrders.some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
|
|
3254
|
+
const activeOpenOrd = activeOrders.find((o) => o.symbol === sym && o.direction === wantOpenDir);
|
|
3255
|
+
const ordAgeMs = activeOpenOrd ? (Date.now() - (Date.parse(activeOpenOrd.orderDate || 0) || Number(acc.entryTsBySymbol?.[sym]) || 0)) : Infinity;
|
|
3256
|
+
const isFillingNow = Boolean(activeOpenOrd && ordAgeMs < 60000);
|
|
3236
3257
|
const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
|
|
3237
3258
|
const avgPx = Number(pos.avgPrice) || 0;
|
|
3238
3259
|
if (lots < 1 || avgPx <= 0) continue;
|
|
3239
3260
|
const side = aQty > 0 ? 'long' : 'short';
|
|
3240
3261
|
const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
|
|
3241
|
-
want.set(sym, { lots, side, stopPx, sp });
|
|
3262
|
+
want.set(sym, { lots, side, stopPx, sp, filling: isFillingNow });
|
|
3242
3263
|
}
|
|
3243
3264
|
let changed = false;
|
|
3244
3265
|
// 1) снять свои стоп-заявки без позиции, сработавшие (их нет среди активных) или с другим объёмом / ценой / стороной
|
|
3245
3266
|
for (const [sym, t] of Object.entries(acc.brokerStopBySymbol)) {
|
|
3246
3267
|
const w = want.get(sym);
|
|
3247
3268
|
const alive = t?.stopOrderId && active.has(t.stopOrderId);
|
|
3269
|
+
if (alive && w?.filling && w.side === t.side) continue;
|
|
3248
3270
|
const same = alive && w && w.lots === t.lots && w.side === t.side && Math.abs(w.stopPx - t.stopPx) < (w.sp.minStep || 0.01) / 2;
|
|
3249
3271
|
if (same) continue;
|
|
3250
3272
|
if (alive) {
|
|
@@ -3265,7 +3287,7 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3265
3287
|
}
|
|
3266
3288
|
// 2) выставить недостающие
|
|
3267
3289
|
for (const [sym, w] of want) {
|
|
3268
|
-
if (acc.brokerStopBySymbol[sym]) continue;
|
|
3290
|
+
if (acc.brokerStopBySymbol[sym] || w.filling) continue;
|
|
3269
3291
|
const cd = acc.brokerStopCooldown[sym];
|
|
3270
3292
|
if (cd && (Date.now() - cd < 120000 || Number(acc.lastSync?.at || 0) < cd + 30000)) continue;
|
|
3271
3293
|
if (cd) { delete acc.brokerStopCooldown[sym]; changed = true; }
|
|
@@ -3349,12 +3371,14 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3349
3371
|
const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenKind && op.date > 0);
|
|
3350
3372
|
const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
|
|
3351
3373
|
if (avgPx <= 0) continue;
|
|
3374
|
+
const effEntryTs = Math.max(Number(acc.entryTsBySymbol?.[sym]) || 0, Number(lastHistEntryOp?.date) || 0);
|
|
3375
|
+
const heldMin = effEntryTs > 0 ? Math.max(0, (Date.now() - effEntryTs) / 60000) : 999;
|
|
3352
3376
|
|
|
3353
3377
|
if (aQty > 0) {
|
|
3354
3378
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
3355
3379
|
if (lots < 1) continue;
|
|
3356
3380
|
const curGainPct = ((curPx - avgPx) / avgPx) * 100;
|
|
3357
|
-
const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
|
|
3381
|
+
const profitLockReason = evaluateProfitLock(sym, 'long', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
|
|
3358
3382
|
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
|
|
3359
3383
|
if (hitHardStopLoss || profitLockReason) {
|
|
3360
3384
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
@@ -3369,7 +3393,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3369
3393
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
3370
3394
|
if (lots < 1) continue;
|
|
3371
3395
|
const curGainPct = ((avgPx - curPx) / avgPx) * 100;
|
|
3372
|
-
const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct);
|
|
3396
|
+
const profitLockReason = evaluateProfitLock(sym, 'short', avgPx, curPx, Number(peakMap[sym]) || 0, roundTripFeePct, heldMin);
|
|
3373
3397
|
const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
|
|
3374
3398
|
if (hitHardStopLoss || profitLockReason) {
|
|
3375
3399
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|
|
@@ -3414,7 +3438,9 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3414
3438
|
sentLockOrders.push(ord);
|
|
3415
3439
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
3416
3440
|
delete acc.peakPriceBySymbol[ord.symbol];
|
|
3417
|
-
if (ord.
|
|
3441
|
+
if (ord.symbol !== 'LQDT') {
|
|
3442
|
+
acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
|
|
3443
|
+
}
|
|
3418
3444
|
} catch (e) {
|
|
3419
3445
|
results.push({ symbol: ord.symbol, ok: false, error: e.message });
|
|
3420
3446
|
logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
|
package/server/news/index.js
CHANGED
|
@@ -281,8 +281,9 @@ export function newsForSymbols(hours = 24) {
|
|
|
281
281
|
// Для живой проверки с newsFilter: самые важные свежие новости (важность ≥ 0.6, |тон| ≥ 0.4)
|
|
282
282
|
// по тикерам: symbol → { bad: { gist, imp, tone, ts }, good: {…} }
|
|
283
283
|
export function blockingNews(hours = 6, atTs = Date.now()) {
|
|
284
|
+
const sinceTs = atTs - hours * 3600000;
|
|
284
285
|
const rows = db.prepare(`SELECT ts, title, gist, imp, tags FROM news
|
|
285
|
-
WHERE marked = 1 AND imp >= 0.6 AND tags IS NOT NULL AND tags != '[]' AND ts >= ? AND ts <= ?`).all(
|
|
286
|
+
WHERE marked = 1 AND imp >= 0.6 AND tags IS NOT NULL AND tags != '[]' AND ts >= ? AND ts <= ?`).all(sinceTs, atTs);
|
|
286
287
|
const m = new Map();
|
|
287
288
|
for (const r of rows) {
|
|
288
289
|
for (const [sym, tone] of parseTags(r)) {
|
|
@@ -293,6 +294,29 @@ export function blockingNews(hours = 6, atTs = Date.now()) {
|
|
|
293
294
|
m.set(sym, e);
|
|
294
295
|
}
|
|
295
296
|
}
|
|
297
|
+
// Официальные сообщения Мосбиржи о расширении верхней/нижней границы ценового коридора акции (планки) —
|
|
298
|
+
// блокируют вход против пробоя планки даже до разметки ИИ (и защищают от отказа шлюза брокера с кодом 30099)
|
|
299
|
+
const corridorRows = db.prepare(`SELECT ts, title, tags FROM news
|
|
300
|
+
WHERE ts >= ? AND ts <= ? AND title LIKE '%границы ценового коридора%ценной бумаги%' AND title NOT LIKE '%коридора РЕПО%'`).all(sinceTs, atTs);
|
|
301
|
+
for (const r of corridorRows) {
|
|
302
|
+
const t = String(r.title || '');
|
|
303
|
+
if (/коридора\s+репо/i.test(t)) continue;
|
|
304
|
+
const isUpper = /верхней границы ценового коридора/i.test(t);
|
|
305
|
+
const isLower = /нижней границы ценового коридора/i.test(t);
|
|
306
|
+
if (!isUpper && !isLower) continue;
|
|
307
|
+
const syms = new Set(parseTags(r).map(([s]) => s).filter(Boolean));
|
|
308
|
+
const matchSym = /ценной бумаги\s+([A-Z0-9]{2,8})\b/i.exec(t);
|
|
309
|
+
if (matchSym?.[1]) syms.add(matchSym[1].toUpperCase());
|
|
310
|
+
for (const sym of syms) {
|
|
311
|
+
const e = m.get(sym) || {};
|
|
312
|
+
const side = isUpper ? 'good' : 'bad';
|
|
313
|
+
const tone = isUpper ? 0.9 : -0.9;
|
|
314
|
+
const imp = 0.95;
|
|
315
|
+
const gist = isUpper ? `Мосбиржа расширила верхнюю планку коридора ${sym}` : `Мосбиржа расширила нижнюю планку коридора ${sym}`;
|
|
316
|
+
if (!e[side] || imp >= e[side].imp) e[side] = { tone, imp, gist, ts: r.ts };
|
|
317
|
+
m.set(sym, e);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
296
320
|
return m;
|
|
297
321
|
}
|
|
298
322
|
|