neuroalpha 0.6.5 → 0.6.7

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.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
@@ -13,7 +13,7 @@ import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
13
13
  import { forecastNow } from '../model/forecast.js';
14
14
  import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
15
15
  import { liveDividendGuard } from '../model/dividends.js';
16
- import { STRATEGY_POLICY, sessionEdgeWarning, evaluateProfitLock, protectiveStopPct, tierSizeMult, MAX_LEVERAGE } from '../model/policy.js';
16
+ import { STRATEGY_POLICY, sessionEdgeWarning, evaluateProfitLock, protectiveStopPct, exitHoldRatio, tierSizeMult, MAX_LEVERAGE } from '../model/policy.js';
17
17
  import { MODELS } from '../model/models.js';
18
18
  import { blockingNews } from '../news/index.js';
19
19
  import { round, MSK, DAY, fmtBarMsk } from '../util.js';
@@ -2147,10 +2147,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2147
2147
  const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
2148
2148
  const heldMin = heldMs / 60000;
2149
2149
  const horizonMin = Math.max(90, effHorizon * 60);
2150
- // Плавный переход порога удержания от мягкого (0.35 до 45 мин) к плотному (0.95 к концу горизонта 2.5 ч) без резкой «ступеньки» на 65-й минуте
2150
+ // Плавный переход порога удержания от мягкого (0.35 до 45 мин) к динамическому плотному (0.95 × clamp(realizedBarPct / sigmaBarPct, 0.75, 1.25) к концу горизонта 2.5 ч)
2151
2151
  const symMult = r.edgeMult || symbolEdgeMult(sym);
2152
- const rampProgress = autoAdapt ? (heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / (horizonMin - 45))) : 1;
2153
- const rampedExitThr = earlyExitThresholdPct + (exitThresholdPct - earlyExitThresholdPct) * rampProgress;
2152
+ const holdRatio = exitHoldRatio(heldMin, horizonMin, {
2153
+ sigmaBarPct: Number(r.day?.sigmaBarPct || r.sigmaPct) || 0,
2154
+ realizedBarPct: Number(r.day?.realizedBarPct) || 0,
2155
+ baseEndRatio: exitRatio,
2156
+ autoAdapt,
2157
+ });
2158
+ const rampedExitThr = round((fc.thresholdPct || 0) * holdRatio, 3);
2154
2159
  const curExitThr = round(rampedExitThr * symMult, 3);
2155
2160
  const softExitThr = round(earlyExitThresholdPct * symMult, 3);
2156
2161
  const nightExit = !onlyEntries && autoAdapt && shouldExitBeforeNight(nowTs, effEntryTs, stepBars, BAR_INTERVAL);
@@ -2166,18 +2171,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2166
2171
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && (r.expectedPct > curExitThr || holdThroughMinorDip));
2167
2172
  const dg = divGuardOf(sym, curPx);
2168
2173
  const divExit = Boolean(dg?.exitLong);
2169
- const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
2174
+ const badNews = nb?.get(sym)?.bad;
2175
+ const newsExit = Boolean(autoAdapt && badNews && badNews.imp >= 0.80 && badNews.tone <= -0.60 && effEntryTs > 0 && badNews.ts > effEntryTs);
2176
+ const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !newsExit && !nightExit;
2170
2177
  if (lots >= 1 && !keep) {
2171
2178
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
2179
+ if (newsExit && !hitHardStopLoss && !profitLockReason && !divExit) {
2180
+ logBrokerEvent(acc, 'news_exit', `${sym}: закрытие покупки по свежей новости (важность ${badNews.imp}, тон ${badNews.tone}) — ${badNews.gist}`);
2181
+ }
2172
2182
  const exitReason = hitHardStopLoss
2173
2183
  ? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
2174
2184
  : profitLockReason
2175
2185
  ? profitLockReason
2176
2186
  : divExit
2177
2187
  ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
2178
- : nightExit
2179
- ? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
2180
- : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
2188
+ : newsExit
2189
+ ? `Выход из покупки по важной негативной новости: ${badNews.gist}`
2190
+ : nightExit
2191
+ ? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
2192
+ : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
2181
2193
  orders.push({
2182
2194
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
2183
2195
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -2197,18 +2209,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
2197
2209
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && (r.expectedPct < -curExitThr || holdThroughMinorSpike));
2198
2210
  const dg = divGuardOf(sym, curPx);
2199
2211
  const divExit = Boolean(dg?.exitShort);
2200
- const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !nightExit;
2212
+ const goodNews = nb?.get(sym)?.good;
2213
+ const newsExit = Boolean(autoAdapt && goodNews && goodNews.imp >= 0.80 && goodNews.tone >= 0.60 && effEntryTs > 0 && goodNews.ts > effEntryTs);
2214
+ const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !newsExit && !nightExit;
2201
2215
  if (lots >= 1 && !keep) {
2202
2216
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
2217
+ if (newsExit && !hitHardStopLoss && !profitLockReason && !divExit) {
2218
+ logBrokerEvent(acc, 'news_exit', `${sym}: закрытие шорта по свежей новости (важность ${goodNews.imp}, тон +${goodNews.tone}) — ${goodNews.gist}`);
2219
+ }
2203
2220
  const exitReason = hitHardStopLoss
2204
2221
  ? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
2205
2222
  : profitLockReason
2206
2223
  ? profitLockReason
2207
2224
  : divExit
2208
2225
  ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
2209
- : nightExit
2210
- ? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
2211
- : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
2226
+ : newsExit
2227
+ ? `Закрытие шорта по важной позитивной новости: ${goodNews.gist}`
2228
+ : nightExit
2229
+ ? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
2230
+ : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
2212
2231
  orders.push({
2213
2232
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
2214
2233
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
@@ -2629,7 +2648,8 @@ function formatTbankOrderError(status, rawText = '', ord = {}) {
2629
2648
  return 'По данной бумаге брокер временно запретил короткие продажи (шорт)';
2630
2649
  }
2631
2650
  if (code.includes('30099') || /outside the limits/i.test(code)) {
2632
- return 'Цена бумаги вышла за границы дневного ценового коридора у брокера (код 30099: Мосбиржа расширила планку, шлюз брокера ещё обновляет лимиты)';
2651
+ const corr = (ord.limitDown > 0 || ord.limitUp > 0) ? `, заявка ${ord.limitPrice || ''} ₽ при коридоре ${ord.limitDown || '—'}..${ord.limitUp || '—'} ₽` : '';
2652
+ return `Цена бумаги вышла за границы дневного ценового коридора у брокера (код 30099${corr}: Мосбиржа расширила планку, шлюз брокера ещё обновляет лимиты)`;
2633
2653
  }
2634
2654
  return `T-Invest PostOrder HTTP ${status}${msg ? `: ${msg}` : ''}${desc ? ` (код ${desc})` : ''}`;
2635
2655
  }
@@ -2647,6 +2667,7 @@ async function sendOrderToBroker(acc, ord) {
2647
2667
  // • если спред в стакане в момент входа разъехался шире половины ожидаемого хода сигнала (или >0,35%) — отклоняем вход в тонкий стакан!
2648
2668
  let finalLimitPx = Number(ord.limitPrice) || 0;
2649
2669
  let lotsToOrder = Number(ord.lots) || 0;
2670
+ let limitUp = 0, limitDown = 0;
2650
2671
  const sp = kvGet('tbankShortableMap')?.specMap?.[ord.symbol] || { minStep: 0.01, decimals: 2 };
2651
2672
  const rBook = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetOrderBook`, {
2652
2673
  method: 'POST', headers, body: JSON.stringify({ instrumentId, depth: 10 }), timeoutMs: 3500,
@@ -2655,6 +2676,16 @@ async function sendOrderToBroker(acc, ord) {
2655
2676
  const jBook = await rBook.json().catch(() => null);
2656
2677
  const bestBid = qVal(jBook?.bids?.[0]?.price);
2657
2678
  const bestAsk = qVal(jBook?.asks?.[0]?.price);
2679
+ limitUp = qVal(jBook?.limitUp);
2680
+ limitDown = qVal(jBook?.limitDown);
2681
+ if (isEntry && ord.symbol !== 'LQDT') {
2682
+ if (!isBuy && limitUp > 0 && bestBid >= limitUp) {
2683
+ throw Object.assign(new Error(`Цена бумаги ${ord.symbol} (${bestBid} ₽) у верхней границы дневного ценового коридора у брокера (limitUp ${limitUp} ₽, код 30099)`), { definitive: true });
2684
+ }
2685
+ if (isBuy && limitDown > 0 && bestAsk > 0 && bestAsk <= limitDown) {
2686
+ throw Object.assign(new Error(`Цена бумаги ${ord.symbol} (${bestAsk} ₽) у нижней границы дневного ценового коридора у брокера (limitDown ${limitDown} ₽, код 30099)`), { definitive: true });
2687
+ }
2688
+ }
2658
2689
  if (isEntry && ord.symbol !== 'LQDT' && (!(bestBid > 0) || !(bestAsk > 0))) {
2659
2690
  logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: пустая сторона стакана (bid ${bestBid || 0} / ask ${bestAsk || 0}, планка или аукцион)`);
2660
2691
  throw Object.assign(new Error(`В стакане ${ord.symbol} отсутствует ${!(bestAsk > 0) ? 'предложение (верхняя планка / аукцион)' : 'спрос (нижняя планка / аукцион)'} — вход пропущен`), { definitive: true });
@@ -2718,6 +2749,22 @@ async function sendOrderToBroker(acc, ord) {
2718
2749
  }
2719
2750
  }
2720
2751
  }
2752
+ // Защита от выхода лимитной цены за границы дневного коридора Т-Банка (limitDown..limitUp из GetOrderBook):
2753
+ // если сама рыночная цена внутри коридора, а за границу вылез только защитный запас limitOffsetPct — прижимаем цену к границе коридора;
2754
+ // если же при входе сама цена уже за коридором против позиции — отклоняем с кодом 30099 до вызова PostOrder.
2755
+ const step = sp.minStep || 0.01;
2756
+ if (limitUp > 0 && finalLimitPx > limitUp) {
2757
+ if (isEntry && !isBuy) {
2758
+ throw Object.assign(new Error(`Цена заявки ${ord.symbol} (${finalLimitPx} ₽) выше верхней границы ценового коридора у брокера (limitUp ${limitUp} ₽, код 30099)`), { definitive: true });
2759
+ }
2760
+ if (!(limitDown > limitUp)) finalLimitPx = roundToStep(limitUp, step, sp.decimals);
2761
+ }
2762
+ if (limitDown > 0 && finalLimitPx < limitDown) {
2763
+ if (isEntry && isBuy) {
2764
+ throw Object.assign(new Error(`Цена заявки ${ord.symbol} (${finalLimitPx} ₽) ниже нижней границы ценового коридора у брокера (limitDown ${limitDown} ₽, код 30099)`), { definitive: true });
2765
+ }
2766
+ if (!(limitUp > 0 && limitDown > limitUp)) finalLimitPx = roundToStep(limitDown, step, sp.decimals);
2767
+ }
2721
2768
  }
2722
2769
  const units = Math.trunc(finalLimitPx);
2723
2770
  const nano = Math.round((finalLimitPx - units) * 1e9);
@@ -2793,7 +2840,7 @@ async function sendOrderToBroker(acc, ord) {
2793
2840
  if (!r.ok) {
2794
2841
  const errText = await r.text().catch(() => '');
2795
2842
  // 5xx — исход не известен (заявка могла быть принята), 4xx — брокер точно отклонил
2796
- throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
2843
+ throw Object.assign(new Error(formatTbankOrderError(r.status, errText, { ...ord, limitPrice: finalLimitPx, limitUp, limitDown })), { definitive: r.status < 500 });
2797
2844
  }
2798
2845
  const j = await r.json();
2799
2846
  logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ (сигнал ${ord.refPrice || finalLimitPx} ₽) · ${body.orderId}${ord.reason ? ` · ${ord.reason}` : ''}`);
@@ -34,6 +34,10 @@ export const STRATEGY_POLICY = Object.freeze({
34
34
  // паузы повторного входа после выхода, минуты
35
35
  cooldownMin: 30,
36
36
  stopCooldownMin: 60,
37
+ // динамическая подстройка верхнего порога удержания (0.95) по фактической внутридневной волатильности акции (realizedBarPct / sigmaBarPct):
38
+ // в спокойный день (множитель до 0.75×) не вытряхивает позицию раньше времени, в турбулентный день (до 1.25×) быстрее фиксирует выход при затухании прогноза.
39
+ // Проверка 09.11.2026 (06.2024–11.2026, плечо 3×): CAGR 58,6%/52,1% по половинам (весь 55,1%, PF 1,97, DD 6,4%) против 57,5%/48,1% (весь 52,6%, PF 1,89, DD 6,8%).
40
+ exitVolClamp: Object.freeze([0.75, 1.25]),
37
41
  });
38
42
 
39
43
  // ---------- окна начала и конца сессий (широкий спред, гэпы, аукционы) ----------
@@ -95,6 +99,22 @@ export function protectiveStopPct(side, { sigmaBarPct = 0, realizedBarPct = 0, h
95
99
  return side === 'long' ? (st.longPct ?? 7.0) : (st.shortPct ?? 7.0);
96
100
  }
97
101
 
102
+ // ---------- порог удержания (выхода по ослаблению прогноза) ----------
103
+ // Первые 45 минут действует мягкий порог 0.35, затем к концу горизонта (horizonMin) он плавно поднимается к baseEndRatio (0.95),
104
+ // масштабированному по отношению фактического сегодняшнего размаха свечей бумаги (realizedBarPct) к её обычной волатильности (sigmaBarPct) в коридоре [0.75..1.25].
105
+ export function exitHoldRatio(heldMin, horizonMin, { sigmaBarPct = 0, realizedBarPct = 0, baseEndRatio = 0.95, autoAdapt = true } = {}, policy = STRATEGY_POLICY) {
106
+ if (!autoAdapt) return baseEndRatio;
107
+ const startRatio = 0.35;
108
+ const ramp = heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / Math.max(1, horizonMin - 45));
109
+ let endRatio = baseEndRatio;
110
+ const clamp = policy?.exitVolClamp;
111
+ if (Array.isArray(clamp) && clamp.length === 2 && sigmaBarPct > 0 && realizedBarPct > 0) {
112
+ const vr = Math.max(clamp[0], Math.min(clamp[1], realizedBarPct / sigmaBarPct));
113
+ endRatio = baseEndRatio * vr;
114
+ }
115
+ return startRatio + (endRatio - startRatio) * ramp;
116
+ }
117
+
98
118
  // ---------- плечо и множители размера по эшелонам ----------
99
119
  export const MAX_LEVERAGE = 7;
100
120
  export function tierSizeMult(symbol, leverage = 1, policy = STRATEGY_POLICY) {
@@ -17,7 +17,7 @@ import {
17
17
  isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isMarketDown,
18
18
  } from './edge.js';
19
19
  import { nextDividendAt, dividendRules } from './dividends.js';
20
- import { STRATEGY_POLICY, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
20
+ import { STRATEGY_POLICY, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, exitHoldRatio, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
21
21
 
22
22
  // Выравнивание внешнего ряда (свечи IMOEXF, дневные цены LQDT) по шкале датасета с переносом последней цены
23
23
  export function alignSeries(timeline, rows) {
@@ -245,8 +245,10 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
245
245
  if (pos.side === 'long' ? fl.crash : fl.squeeze) { exitsAt.push([i, 'Защита от сквиза/обвала', 'regime']); continue; }
246
246
  if (policy.signalExit === false) continue;
247
247
  const heldMin = barsHeldNext * BAR_INTERVAL;
248
- const ramp = heldMin <= 45 ? 0 : Math.min(1, (heldMin - 45) / (horizonMin - 45));
249
- const thr = (0.35 + (0.95 - 0.35) * ramp) * need * mult[i] * tierScale(i);
248
+ const sigmaBarPct = sg[i] * 100;
249
+ const ctx = dayContext(ds, i, t, sigmaBarPct);
250
+ const holdRatio = exitHoldRatio(heldMin, horizonMin, { sigmaBarPct, realizedBarPct: ctx.realizedBarPct, baseEndRatio: 0.95, autoAdapt: true }, policy);
251
+ const thr = holdRatio * need * mult[i] * tierScale(i);
250
252
  const soft = 0.35 * need * mult[i] * tierScale(i);
251
253
  const e = edge(i);
252
254
  const signed = pos.side === 'long' ? e : -e;