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 +1 -1
- package/server/brokers/index.js +61 -14
- package/server/model/policy.js +20 -0
- package/server/model/simulator.js +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/server/brokers/index.js
CHANGED
|
@@ -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 ч)
|
|
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
|
|
2153
|
-
|
|
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
|
|
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
|
-
:
|
|
2179
|
-
?
|
|
2180
|
-
:
|
|
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
|
|
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
|
-
:
|
|
2210
|
-
?
|
|
2211
|
-
:
|
|
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
|
-
|
|
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}` : ''}`);
|
package/server/model/policy.js
CHANGED
|
@@ -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
|
|
249
|
-
const
|
|
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;
|