neuroalpha 0.5.32 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.5.32",
3
+ "version": "0.5.33",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
@@ -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
- // 1) Скользящий Take-Profit при сильном перевыполнении цели (от +1.20% на 1-м эшелоне и от +1.44% на 2-м эшелоне):
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
- if (peakGainPct >= 1.20 * tierMult) {
1627
- const trailFloorPct = Math.max(peakGainPct * 0.70, peakGainPct - 0.45, roundTripFeePct + 0.25);
1628
- if (curGainPct <= trailFloorPct && curGainPct >= roundTripFeePct + 0.10) {
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.75% на 1-м эшелоне и от +0.90% на 2-м эшелоне):
1633
- // не позволяем плюсовой сделке развернуться обратно в убыток
1634
- if (peakGainPct >= 0.75 * tierMult) {
1635
- const breakevenFloorPct = Math.max(roundTripFeePct + 0.18, peakGainPct * 0.40);
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
  }
@@ -1977,7 +1980,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
1977
1980
  if (aQty > 0) {
1978
1981
  const lots = Math.floor(aQty / sp.lotSize);
1979
1982
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1980
- 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;
1981
1984
  const hitHardStopLoss = curGainPct <= -5.0 || Boolean(autoAdapt && r.regimeCrash);
1982
1985
  // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону лонга (> softExitThr), не режем позицию на локальном минутном откате в мелкий минус
1983
1986
  const holdThroughMinorDip = autoAdapt && heldMin < horizonMin && r.liquid && r.expectedPct > softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
@@ -2007,7 +2010,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2007
2010
  } else if (aQty < 0) {
2008
2011
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
2009
2012
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
2010
- 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;
2011
2014
  const hitHardStopLoss = curGainPct <= -4.5 || Boolean(autoAdapt && r.regimeSqueeze);
2012
2015
  // Если горизонт 2.5 ч ещё не вышел и прогноз смотрит в сторону шорта (< -softExitThr), не закрываем шорт на минутном шипе в мелкий минус
2013
2016
  const holdThroughMinorSpike = autoAdapt && heldMin < horizonMin && dir !== 'long' && r.liquid && r.expectedPct < -softExitThr && curGainPct >= -1.0 && curGainPct < roundTripFeePct + 0.12;
@@ -2331,7 +2334,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2331
2334
  diagNote = `Сигнал ${deferredByImpulse.map((d) => `${d.symbol} (${d.side === 'short' ? 'шорт' : 'покупка'}, текущая ${d.curPx} ₽)`).join(', ')} взят на прицел: ждём затухания встречного минутного импульса для входа по лучшей цене (проверка каждые 20 сек).`;
2332
2335
  } else if (!newBuys.length && !newShorts.length) {
2333
2336
  const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
2334
- 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 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '';
2335
2338
  const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
2336
2339
  const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
2337
2340
  const thrRangeStr = dir !== 'long'
@@ -2816,7 +2819,9 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2816
2819
  hadExits = true;
2817
2820
  delete acc.entryTsBySymbol[ord.symbol];
2818
2821
  delete acc.peakPriceBySymbol[ord.symbol];
2819
- if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2822
+ if (ord.symbol !== 'LQDT') {
2823
+ acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
2824
+ }
2820
2825
  }
2821
2826
  } catch (e) {
2822
2827
  // Брокер явно отклонил заявку — при повторе нужен новый ID. Сетевая ошибка или таймаут — ID сохраняется:
@@ -3232,19 +3237,24 @@ async function reconcileBrokerStopsLocked(list, acc) {
3232
3237
  const aQty = algoQtyFor(acc, sym, Number(pos.qty) || 0);
3233
3238
  if (!aQty) continue;
3234
3239
  const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
3240
+ const wantOpenDir = aQty > 0 ? 'buy' : 'sell';
3235
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);
3236
3245
  const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
3237
3246
  const avgPx = Number(pos.avgPrice) || 0;
3238
3247
  if (lots < 1 || avgPx <= 0) continue;
3239
3248
  const side = aQty > 0 ? 'long' : 'short';
3240
3249
  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 });
3250
+ want.set(sym, { lots, side, stopPx, sp, filling: isFillingNow });
3242
3251
  }
3243
3252
  let changed = false;
3244
3253
  // 1) снять свои стоп-заявки без позиции, сработавшие (их нет среди активных) или с другим объёмом / ценой / стороной
3245
3254
  for (const [sym, t] of Object.entries(acc.brokerStopBySymbol)) {
3246
3255
  const w = want.get(sym);
3247
3256
  const alive = t?.stopOrderId && active.has(t.stopOrderId);
3257
+ if (alive && w?.filling && w.side === t.side) continue;
3248
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;
3249
3259
  if (same) continue;
3250
3260
  if (alive) {
@@ -3265,7 +3275,7 @@ async function reconcileBrokerStopsLocked(list, acc) {
3265
3275
  }
3266
3276
  // 2) выставить недостающие
3267
3277
  for (const [sym, w] of want) {
3268
- if (acc.brokerStopBySymbol[sym]) continue;
3278
+ if (acc.brokerStopBySymbol[sym] || w.filling) continue;
3269
3279
  const cd = acc.brokerStopCooldown[sym];
3270
3280
  if (cd && (Date.now() - cd < 120000 || Number(acc.lastSync?.at || 0) < cd + 30000)) continue;
3271
3281
  if (cd) { delete acc.brokerStopCooldown[sym]; changed = true; }
@@ -3349,12 +3359,14 @@ async function checkIntradayProfitLock(accId) {
3349
3359
  const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenKind && op.date > 0);
3350
3360
  const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
3351
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;
3352
3364
 
3353
3365
  if (aQty > 0) {
3354
3366
  const lots = Math.floor(aQty / sp.lotSize);
3355
3367
  if (lots < 1) continue;
3356
3368
  const curGainPct = ((curPx - avgPx) / avgPx) * 100;
3357
- 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);
3358
3370
  const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_LONG_PCT;
3359
3371
  if (hitHardStopLoss || profitLockReason) {
3360
3372
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
@@ -3369,7 +3381,7 @@ async function checkIntradayProfitLock(accId) {
3369
3381
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
3370
3382
  if (lots < 1) continue;
3371
3383
  const curGainPct = ((avgPx - curPx) / avgPx) * 100;
3372
- 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);
3373
3385
  const hitHardStopLoss = !hasBrokerHardStop && curGainPct <= -BROKER_STOP_SHORT_PCT;
3374
3386
  if (hitHardStopLoss || profitLockReason) {
3375
3387
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
@@ -3414,7 +3426,9 @@ async function checkIntradayProfitLock(accId) {
3414
3426
  sentLockOrders.push(ord);
3415
3427
  delete acc.entryTsBySymbol[ord.symbol];
3416
3428
  delete acc.peakPriceBySymbol[ord.symbol];
3417
- if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
3429
+ if (ord.symbol !== 'LQDT') {
3430
+ acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
3431
+ }
3418
3432
  } catch (e) {
3419
3433
  results.push({ symbol: ord.symbol, ok: false, error: e.message });
3420
3434
  logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);