neuroalpha 0.5.16 → 0.5.19

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.
@@ -8,7 +8,7 @@ import { getSettings, normalizeParams } from '../settings.js';
8
8
  import { latestBarTs } from '../data/moex.js';
9
9
  import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
10
10
  import { forecastNow } from '../model/forecast.js';
11
- import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/edge.js';
11
+ import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
12
12
  import { liveDividendGuard } from '../model/dividends.js';
13
13
  import { MODELS } from '../model/models.js';
14
14
  import { blockingNews } from '../news/index.js';
@@ -294,7 +294,8 @@ function computeExecutionAudit(a) {
294
294
  // отсекаем старую историю автоследования (до последнего удержания комиссии автоследования)
295
295
  const lastFollowTs = Math.max(0, ...sorted.filter((o) => o.kind === 'fee' && /автослед/i.test(o.title || '')).map((o) => o.date));
296
296
  const algoOps = sorted.filter((o) => o.date > lastFollowTs);
297
- const trades = algoOps.filter((o) => (o.kind === 'buy' || o.kind === 'sell') && o.qty > 0 && o.price > 0 && o.symbol && o.symbol !== 'LQDT');
297
+ const isCashFund = (sym) => sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM';
298
+ const trades = algoOps.filter((o) => (o.kind === 'buy' || o.kind === 'sell') && o.qty > 0 && o.price > 0 && o.symbol && !isCashFund(o.symbol));
298
299
  if (!trades.length) return null;
299
300
 
300
301
  let turnoverRub = 0;
@@ -313,7 +314,7 @@ function computeExecutionAudit(a) {
313
314
  }
314
315
  }
315
316
  }
316
- const feesRub = algoOps.filter((o) => o.kind === 'fee').reduce((s, o) => s + Math.abs(o.paymentRub || 0), 0);
317
+ const feesRub = algoOps.filter((o) => o.kind === 'fee' && !isCashFund(o.symbol)).reduce((s, o) => s + Math.abs(o.paymentRub || 0), 0);
317
318
  const commPct = turnoverRub > 0 ? round((feesRub / turnoverRub) * 100, 3) : 0.04;
318
319
  const slipAllPct = (rubW1 + rubW2) > 0 ? round((slipW1 + slipW2) / (rubW1 + rubW2), 3) : null;
319
320
  const slipTier1Pct = rubW1 > 0 ? round(slipW1 / rubW1, 3) : null;
@@ -1484,8 +1485,8 @@ export async function freezeBrokerBaseline(id) {
1484
1485
  function sessionEdgeWarning(now = Date.now()) {
1485
1486
  const d = new Date(now + MSK);
1486
1487
  const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
1487
- // утреннее открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
1488
- if ((mins >= 9 * 60 + 50 && mins <= 10 * 60 + 10) || (mins >= 18 * 60 + 35 && mins <= 18 * 60 + 55)) {
1488
+ // открытие утренней сессии 06:50–07:05, открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
1489
+ if ((mins >= 6 * 60 + 50 && mins <= 7 * 60 + 5) || (mins >= 9 * 60 + 50 && mins <= 10 * 60 + 10) || (mins >= 18 * 60 + 35 && mins <= 18 * 60 + 55)) {
1489
1490
  return 'Первые или последние 10 минут сессии (широкий спред в стакане)';
1490
1491
  }
1491
1492
  return null;
@@ -1556,12 +1557,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1556
1557
  const vipBoost = Boolean(plan.allowVipBoost);
1557
1558
  const rawDir = params.modelDirection || 'long';
1558
1559
  const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
1559
- // В реальном стакане брокера (где каждый вход-выход стоит 0.08% комиссии + спред) используем гистерезис удержания 0.58
1560
- // (около +0.145%), чтобы не закрывать открытую позицию преждевременно при положительном прогнозе +0.16%…+0.23%!
1561
- const exitRatio = autoAdapt ? 0.58 : Number(params.modelExitRatio ?? 0.5);
1560
+ // Единые пороги удержания с эталонным симулятором (research.js): первые 65 минут — мягкий порог 0.35, затем плотный порог 0.95 (с учётом эшелона)
1561
+ const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
1562
1562
  const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
1563
- const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.25, 3);
1563
+ const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.35, 3);
1564
1564
  const nowTs = Date.now();
1565
+ const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
1566
+ const H = barsFromHours(effHorizon);
1567
+ const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
1568
+ const timeOk = !autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL);
1565
1569
  const entryTsMap = acc.entryTsBySymbol || {};
1566
1570
  const exitCooldownMap = acc.exitCooldownBySymbol || {};
1567
1571
  // Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
@@ -1574,12 +1578,12 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1574
1578
  const reqBuyEdgeOf = (sym) => round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
1575
1579
  const reqShortEdgeOf = (sym) => round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
1576
1580
  const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
1577
- // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, как в research.js):
1578
- // если за последние 14 дней по бумаге было >= 3 закрытых сделок на счёте и их суммарный итог отрицательный (< -1.0%),
1579
- // временно исключаем такую бумагу из новых входов, чтобы не наступать на одни и те же грабли в неликвиде!
1581
+ // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
1582
+ // если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
1583
+ // временно исключаем такую бумагу из новых входов!
1580
1584
  const quarantined = new Set();
1581
1585
  if (autoAdapt && Array.isArray(acc.lastHistory?.operations)) {
1582
- const cutoffTs = nowTs - 14 * DAY;
1586
+ const cutoffTs = nowTs - 21 * DAY;
1583
1587
  const recentOps = acc.lastHistory.operations
1584
1588
  .filter((op) => op.date >= cutoffTs && op.symbol && (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'fee'))
1585
1589
  .sort((a, b) => a.date - b.date);
@@ -1595,7 +1599,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1595
1599
  }
1596
1600
  for (const [sym, st] of Object.entries(statBySym)) {
1597
1601
  const closedRounds = Math.min(st.buys, st.sells);
1598
- if (closedRounds >= 3 && st.buyRub > 10000 && (st.netRub / st.buyRub) < -0.009) {
1602
+ const netPct = st.buyRub > 10000 ? (st.netRub / st.buyRub) : 0;
1603
+ if (st.buyRub > 10000 && isStockQuarantined(closedRounds, netPct)) {
1599
1604
  quarantined.add(sym);
1600
1605
  }
1601
1606
  }
@@ -1636,10 +1641,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1636
1641
  // Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
1637
1642
  const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
1638
1643
  const divBlocked = [];
1639
- const buyCands = fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
1644
+ const buyCands = !timeOk ? [] : fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
1640
1645
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1641
1646
  .sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
1642
- const allShortSignals = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
1647
+ const allShortSignals = (rawDir !== 'long' && timeOk) ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
1643
1648
  .filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
1644
1649
  .sort((a, b) => a.expectedPct - b.expectedPct) : [];
1645
1650
  const nonShortableSkipped = [];
@@ -1688,51 +1693,58 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1688
1693
  const effEntryTs = Math.max(entryTsMap[sym] || 0, lastHistEntryTs);
1689
1694
  const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
1690
1695
  // Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
1691
- // чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95)
1692
- const curExitThr = (autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct;
1696
+ // чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95) с учётом эшелона
1697
+ const curExitThr = round(((autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct) * symbolEdgeMult(sym), 3);
1698
+ const nightExit = !onlyEntries && autoAdapt && shouldExitBeforeNight(nowTs, effEntryTs, stepBars, BAR_INTERVAL);
1693
1699
  const curPx = Number(r.price || pos.price) || 0;
1694
1700
  const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
1695
1701
  if (aQty > 0) {
1696
1702
  const lots = Math.floor(aQty / sp.lotSize);
1697
1703
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1698
- const hitHardStopLoss = curGainPct <= -2.6;
1704
+ const hitHardStopLoss = curGainPct <= -5.0;
1699
1705
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1700
1706
  const dg = divGuardOf(sym, curPx);
1701
1707
  const divExit = Boolean(dg?.exitLong);
1702
- const keep = keepByForecast && !hitHardStopLoss && !divExit;
1708
+ const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
1703
1709
  if (lots >= 1 && !keep) {
1704
1710
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
1705
1711
  const exitReason = hitHardStopLoss
1706
1712
  ? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
1707
1713
  : divExit
1708
1714
  ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
1709
- : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1715
+ : nightExit
1716
+ ? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
1717
+ : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1710
1718
  orders.push({
1711
1719
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1712
1720
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1713
1721
  reason: exitReason,
1722
+ isStopLoss: hitHardStopLoss,
1714
1723
  });
1715
1724
  closing.add(sym);
1716
1725
  }
1717
1726
  } else if (aQty < 0) {
1718
1727
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1719
1728
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1720
- const hitHardStopLoss = curGainPct <= -2.2;
1729
+ const hitHardStopLoss = curGainPct <= -4.5;
1721
1730
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1722
1731
  const dg = divGuardOf(sym, curPx);
1723
1732
  const divExit = Boolean(dg?.exitShort);
1724
- const keep = keepByForecast && !hitHardStopLoss && !divExit;
1733
+ const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
1725
1734
  if (lots >= 1 && !keep) {
1726
1735
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
1727
1736
  const exitReason = hitHardStopLoss
1728
1737
  ? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
1729
1738
  : divExit
1730
1739
  ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
1731
- : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1740
+ : nightExit
1741
+ ? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
1742
+ : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1732
1743
  orders.push({
1733
1744
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1734
1745
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1735
1746
  reason: exitReason,
1747
+ isStopLoss: hitHardStopLoss,
1736
1748
  });
1737
1749
  closing.add(sym);
1738
1750
  }
@@ -1957,21 +1969,28 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1957
1969
  }
1958
1970
  }
1959
1971
 
1960
- // При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых заявок,
1961
- // а свободных рублей выше резерва — размещаем излишек в фонд ликвидности LQDT, чтобы кэш приносил ставку денежного рынка (~16–18% годовых)
1962
- if (!onlyEntries && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length === 0 && !activePendingOrders.length && !edgeWarn) {
1963
- const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.001, decimals: 3 };
1964
- const lqdtPx = Number(heldMap.get('LQDT')?.price || lqdtSp.lastPrice || 0);
1965
- const reserveRub = Math.max(5000, round(budget * 0.03, 0));
1966
- const sweepRub = Math.min(availRub - reserveRub, (acc.lastSync?.cashRub || 0) - reserveRub);
1967
- if (lqdtPx > 0 && sweepRub >= 20000) {
1968
- const ord = calcLotOrder('LQDT', sweepRub, lqdtPx, 'buy', 0.02, { ...specs, LQDT: lqdtSp });
1969
- if (ord) {
1970
- availRub = Math.max(0, availRub - ord.amountRub);
1971
- orders.push({
1972
- ...ord,
1973
- reason: `Фонд денежного рынка LQDT: размещение свободных ${fmtRubSimple(ord.amountRub)} ₽ под дневную ставку ликвидности до появления сигнала`,
1974
- });
1972
+ // При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых заявок:
1973
+ // учитываем, что в Т-Банке сделка с LQDT облагается комиссией 0.04% на сторону (0.085% за круг со спредом), что окупается только за >= 2 дня удержания.
1974
+ // Поэтому покупаем LQDT только перед выходными (пятница вечер после закрытия внутридневного окна входов) либо если на счёте не было сделок по акциям более 24 часов!
1975
+ if (!onlyEntries && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length === 0 && !activePendingOrders.length && !edgeWarn && !orders.length) {
1976
+ const mskNow = new Date(nowTs + MSK);
1977
+ const isFridayNight = mskNow.getUTCDay() === 5 && !timeOk;
1978
+ const histOpsAll = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
1979
+ const hadStockTrade24h = histOpsAll.some((op) => (op.kind === 'buy' || op.kind === 'sell') && op.symbol && op.symbol !== 'LQDT' && op.symbol !== 'TMON@' && (nowTs - (op.date || 0) < DAY));
1980
+ if (isFridayNight || !hadStockTrade24h) {
1981
+ const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.0001, decimals: 4 };
1982
+ const lqdtPx = Number(heldMap.get('LQDT')?.price || lqdtSp.lastPrice || 0);
1983
+ const reserveRub = Math.max(5000, round(budget * 0.03, 0));
1984
+ const sweepRub = Math.min(availRub - reserveRub, (acc.lastSync?.cashRub || 0) - reserveRub);
1985
+ if (lqdtPx > 0 && sweepRub >= 20000) {
1986
+ const ord = calcLotOrder('LQDT', sweepRub, lqdtPx, 'buy', 0.02, { ...specs, LQDT: lqdtSp });
1987
+ if (ord) {
1988
+ availRub = Math.max(0, availRub - ord.amountRub);
1989
+ orders.push({
1990
+ ...ord,
1991
+ reason: `Фонд денежного рынка LQDT: размещение свободных ${fmtRubSimple(ord.amountRub)} ₽ под многодневную ставку ликвидности до появления сигнала`,
1992
+ });
1993
+ }
1975
1994
  }
1976
1995
  }
1977
1996
  }
@@ -1989,7 +2008,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1989
2008
  planThrottleNote = 'Тариф «VIP» (лимиты по договору): активны покупки и шорты с повышенной концентрацией слота.';
1990
2009
  }
1991
2010
  if (!orders.length) {
1992
- if ((newBuys.length || newShorts.length) && availRub < 1500) {
2011
+ if (!timeOk) {
2012
+ diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
2013
+ } else if ((newBuys.length || newShorts.length) && availRub < 1500) {
1993
2014
  const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
1994
2015
  const cashNow = round(acc.lastSync?.cashRub || 0, 0);
1995
2016
  diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
@@ -2002,9 +2023,14 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
2002
2023
  } else if (!newBuys.length && !newShorts.length) {
2003
2024
  const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
2004
2025
  const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует 60-минутная пауза после недавнего выхода из позиции, чтобы не платить повторную комиссию.` : '';
2026
+ const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
2027
+ const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
2028
+ const thrRangeStr = dir !== 'long'
2029
+ ? `+${round(minBuyEdgePct, 2)}% / +${buyThrT2}% в покупку, −${round(minShortEdgePct, 2)}% / −${shortThrT2}% в шорт`
2030
+ : `+${round(minBuyEdgePct, 2)}% (1-й эшелон) / +${buyThrT2}% (2–3-й эшелон)`;
2005
2031
  diagNote = cdNote
2006
2032
  ? `На свече ${fc.bar} новых входов нет.${cdNote}${noShortNote}`
2007
- : `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
2033
+ : `На свече ${fc.bar} ни одна доступная бумага не превысила индивидуальный порог выгоды (${thrRangeStr})${noShortNote}.`;
2008
2034
  }
2009
2035
  }
2010
2036
  if (divBlocked.length) {
@@ -2407,7 +2433,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2407
2433
  hadExits = true;
2408
2434
  delete acc.entryTsBySymbol[ord.symbol];
2409
2435
  delete acc.peakPriceBySymbol[ord.symbol];
2410
- acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2436
+ if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2411
2437
  }
2412
2438
  } catch (e) {
2413
2439
  // Брокер явно отклонил заявку — при повторе нужен новый ID. Сетевая ошибка или таймаут — ID сохраняется:
@@ -2633,7 +2659,7 @@ async function tickBrokerSchedule() {
2633
2659
  const nowMsk = new Date(Date.now() + MSK);
2634
2660
  const dow = nowMsk.getUTCDay();
2635
2661
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
2636
- if (dow === 0 || dow === 6 || mins < 9 * 60 + 50 || mins > 23 * 60 + 55) return;
2662
+ if (dow === 0 || dow === 6 || mins < 6 * 60 + 50 || mins > 23 * 60 + 55) return;
2637
2663
  schedulerBusy = true;
2638
2664
  try {
2639
2665
  const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
@@ -2711,8 +2737,8 @@ async function resumeBrokerAccountsOnBoot() {
2711
2737
  // • перед любой закрывающей заявкой по бумаге её стоп-заявка снимается (closeBrokerStopBeforeExit);
2712
2738
  // • внутренний защитный стоп по бумаге со стоп-заявкой у брокера не выставляется;
2713
2739
  // • трогаются только стоп-заявки, выставленные самой программой (их ID хранятся в brokerStopBySymbol).
2714
- const BROKER_STOP_LONG_PCT = 2.6;
2715
- const BROKER_STOP_SHORT_PCT = 2.2;
2740
+ const BROKER_STOP_LONG_PCT = 5.0;
2741
+ const BROKER_STOP_SHORT_PCT = 4.5;
2716
2742
  const tbankBase = (acc) => (acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest');
2717
2743
  const tbankHeaders = (acc) => ({ Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' });
2718
2744
  const qPrice = (v) => { const units = Math.trunc(v); return { units: String(units), nano: Math.round((v - units) * 1e9) }; };
@@ -2796,11 +2822,16 @@ async function reconcileBrokerStopsLocked(list, acc) {
2796
2822
  const base = tbankBase(acc);
2797
2823
  const headers = tbankHeaders(acc);
2798
2824
  const want = new Map();
2825
+ const activeOrders = acc.lastSync?.activeOrders || [];
2799
2826
  for (const pos of acc.lastSync.positions || []) {
2800
2827
  const sym = pos.symbol;
2828
+ if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
2801
2829
  const sp = specs[sym];
2802
- if (!sym || !sp) continue;
2830
+ if (!sp) continue;
2803
2831
  const aQty = algoQtyFor(acc, sym, Number(pos.qty) || 0);
2832
+ if (!aQty) continue;
2833
+ const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
2834
+ if (activeOrders.some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
2804
2835
  const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
2805
2836
  const avgPx = Number(pos.avgPrice) || 0;
2806
2837
  if (lots < 1 || avgPx <= 0) continue;
@@ -2869,7 +2900,7 @@ async function checkIntradayProfitLock(accId) {
2869
2900
  const nowMsk = new Date(Date.now() + MSK);
2870
2901
  const dow = nowMsk.getUTCDay();
2871
2902
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
2872
- if (dow === 0 || dow === 6 || mins < 9 * 60 + 55 || mins > 23 * 60 + 48) return;
2903
+ if (dow === 0 || dow === 6 || mins < 6 * 60 + 55 || mins > 23 * 60 + 48) return;
2873
2904
  if (acc.skipEdgeMinutes && sessionEdgeWarning()) return;
2874
2905
 
2875
2906
  const positions = acc.lastSync?.positions || [];
@@ -2895,7 +2926,7 @@ async function checkIntradayProfitLock(accId) {
2895
2926
  const lockOrders = [];
2896
2927
  for (const pos of positions) {
2897
2928
  const sym = pos.symbol;
2898
- if (!sym) continue;
2929
+ if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
2899
2930
  const aQty = algoQtyOf(sym, pos.qty);
2900
2931
  if (!aQty) continue;
2901
2932
  // позицию уже защищает стоп-заявка у брокера — внутренний стоп не дублируем
@@ -2916,7 +2947,7 @@ async function checkIntradayProfitLock(accId) {
2916
2947
  const lots = Math.floor(aQty / sp.lotSize);
2917
2948
  if (lots < 1) continue;
2918
2949
  const curGainPct = ((curPx - avgPx) / avgPx) * 100;
2919
- const hitHardStopLoss = curGainPct <= -2.6;
2950
+ const hitHardStopLoss = curGainPct <= -BROKER_STOP_LONG_PCT;
2920
2951
  if (hitHardStopLoss) {
2921
2952
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
2922
2953
  lockOrders.push({
@@ -2929,7 +2960,7 @@ async function checkIntradayProfitLock(accId) {
2929
2960
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
2930
2961
  if (lots < 1) continue;
2931
2962
  const curGainPct = ((avgPx - curPx) / avgPx) * 100;
2932
- const hitHardStopLoss = curGainPct <= -2.2;
2963
+ const hitHardStopLoss = curGainPct <= -BROKER_STOP_SHORT_PCT;
2933
2964
  if (hitHardStopLoss) {
2934
2965
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
2935
2966
  lockOrders.push({
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import { saveCandles, loadCandles, candleRange, kvGet, kvSet } from '../db.js';
3
3
  import { DAY, MSK } from '../util.js';
4
4
 
@@ -119,16 +119,17 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
119
119
  const mskDate = new Date(now + MSK);
120
120
  const mskDow = mskDate.getUTCDay();
121
121
  const mskHour = mskDate.getUTCHours();
122
- const isTradingHours = mskDow >= 1 && mskDow <= 5 && mskHour >= 9 && mskHour < 24;
123
- const checkCooldownMs = isTradingHours ? 8 * 60000 : 4 * 3600000;
124
- const dbIsAlreadyFresh = (now - range.max < 15 * 60000) || (!isTradingHours && now - range.max < 18 * 3600000);
122
+ const mskMins = mskHour * 60 + mskDate.getUTCMinutes();
123
+ const isTradingHours = mskDow >= 1 && mskDow <= 5 && mskMins >= 6 * 60 + 50 && mskHour < 24;
124
+ const checkCooldownMs = isTradingHours ? 4 * 60000 : 4 * 3600000;
125
+ const dbIsAlreadyFresh = (now - range.max < 10 * 60000) || (!isTradingHours && now - range.max < 18 * 3600000);
125
126
 
126
127
  if (!dbIsAlreadyFresh && range.max < Math.min(tillTs, now) - barMs(interval) && (now - lastCheck >= checkCooldownMs)) {
127
128
  // Записываем время проверки хвоста ВСЕГДА (даже если новых свечей у бумаги на бирже ещё не появилось),
128
129
  // чтобы не опрашивать MOEX ISS по всем 100 бумагам каждые 2 минуты!
129
130
  lastTailCheckAt.set(tailKey, now);
130
131
  kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
131
- await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now ? tsToDate(tillTs) : null, {
132
+ await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now - DAY ? tsToDate(tillTs) : null, {
132
133
  signal,
133
134
  onBatch: (page) => saveCandles(symbol, interval, page),
134
135
  });
package/server/db.js CHANGED
@@ -205,7 +205,7 @@ if (!db.prepare('SELECT id FROM users WHERE id = 1').get()) {
205
205
  } else {
206
206
  // Общеизвестный код привязки владельца заменяется на случайный (привязанные ранее чаты сохраняются)
207
207
  db.prepare("UPDATE users SET notify_code = ? WHERE id = 1 AND (notify_code IS NULL OR notify_code = '' OR UPPER(notify_code) = 'NA-OWNER1')").run(randomNotifyCode());
208
- db.prepare(`UPDATE users SET plan = 'owner' WHERE id = 1 AND role = 'admin' AND plan IN ('free', 'pro')`).run();
208
+ db.prepare(`UPDATE users SET plan = 'owner', plan_until = 0 WHERE id = 1 AND role = 'admin' AND plan <> 'owner'`).run();
209
209
  db.prepare("UPDATE users SET name = 'Пользователь' WHERE id = 1 AND name = 'Владелец платформы'").run();
210
210
  }
211
211
  db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_api_key ON users(api_key) WHERE api_key IS NOT NULL');
package/server/index.js CHANGED
@@ -128,9 +128,9 @@ app.get('/admin', (req, res) => {
128
128
  return res.sendFile(path.join(ROOT, 'public', 'admin.html'));
129
129
  });
130
130
  app.get('/', (req, res, next) => {
131
- // Неавторизованным гостям показывается лендинг со входом (витрина PaaS),
132
- // а авторизованным пользователям открывается их изолированный облачный терминал (или витрина при ?landing=1)
133
- if (!req.user || (process.env.CLOUD_MODE === '1' && req.query.landing === '1')) {
131
+ // На центральном сервере (IS_CENTRAL) у клиентов на главном домене работает витрина и Личный кабинет (с биллингом и переходом на их PaaS-машину),
132
+ // а локальный торговый терминал самого центра открывается только для администратора (или в dev/instance режиме)
133
+ if (!req.user || (process.env.CLOUD_MODE === '1' && req.query.landing === '1') || (IS_CENTRAL && (req.user.role !== 'admin' || req.query.payment_check === '1') && req.query.terminal !== '1')) {
134
134
  return res.sendFile(path.join(ROOT, 'public', 'landing.html'));
135
135
  }
136
136
  setAppCsp(res);