neuroalpha 0.5.22 → 0.5.27
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/public/app.js +54 -6
- package/server/api/routes.js +2 -2
- package/server/brokers/index.js +92 -41
- package/server/data/moex.js +29 -20
- package/server/db.js +4 -0
- package/server/model/dataset.js +59 -19
- package/server/model/edge.js +39 -7
- package/server/model/forecast.js +246 -28
- package/server/model/live.js +30 -8
- package/server/model/models.js +27 -5
- package/server/model/research.js +124 -37
- package/server/news/index.js +2 -2
- package/server/strategist/index.js +3 -3
- package/server/strategist/tools.js +4 -0
package/server/model/live.js
CHANGED
|
@@ -6,7 +6,7 @@ import { hub } from '../hub.js';
|
|
|
6
6
|
import { latestBarTs } from '../data/moex.js';
|
|
7
7
|
import { loadDataset, barsFromHours, BAR_MS, BAR_INTERVAL } from './dataset.js';
|
|
8
8
|
import { predictAt, lastClosedIndex } from './forecast.js';
|
|
9
|
-
import { symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
|
|
9
|
+
import { ENTRY_EDGE_MULT, symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked } from './edge.js';
|
|
10
10
|
import { computeReport, downsample } from './metrics.js';
|
|
11
11
|
import { MODELS } from './models.js';
|
|
12
12
|
import { blockingNews } from '../news/index.js';
|
|
@@ -336,7 +336,7 @@ class LiveEngine {
|
|
|
336
336
|
const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
|
|
337
337
|
const invVol = s.params.modelAllocMode === 'invvol';
|
|
338
338
|
const effMinEdge = autoAdapt ? 2.5 : s.params.modelMinEdge;
|
|
339
|
-
const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct);
|
|
339
|
+
const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct) * ENTRY_EDGE_MULT;
|
|
340
340
|
const buyOrders = s.pending.filter((x) => x.action === 'buy' || x.action === 'short');
|
|
341
341
|
if (autoAdapt && buyOrders.length > 1) {
|
|
342
342
|
buyOrders.sort((a, b) => Math.abs(b.expectedPct || 0) - Math.abs(a.expectedPct || 0));
|
|
@@ -434,9 +434,14 @@ class LiveEngine {
|
|
|
434
434
|
closePos(sym, 'Закрытие перед ночным клирингом');
|
|
435
435
|
continue;
|
|
436
436
|
}
|
|
437
|
+
const tkIdx = bySym.get(sym);
|
|
438
|
+
if (autoAdapt && tkIdx !== undefined && isRegimeShocked(ds, tkIdx, idx, h.side || 'long')) {
|
|
439
|
+
closePos(sym, 'Защита от экстремального сквиза/обвала');
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
437
442
|
const x = row.get(sym);
|
|
438
443
|
const heldBars = Math.round((ts - h.entryTs) / BAR_MS);
|
|
439
|
-
const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * symbolEdgeMult(sym);
|
|
444
|
+
const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * (x?.edgeMult || symbolEdgeMult(sym));
|
|
440
445
|
if (!(x && x.liquid && (h.side === 'short' ? x.expected < -req : x.expected > req))) closePos(sym);
|
|
441
446
|
}
|
|
442
447
|
const timeOk = !autoAdapt || canEnterIntraday(ts, H, BAR_INTERVAL);
|
|
@@ -454,16 +459,33 @@ class LiveEngine {
|
|
|
454
459
|
}
|
|
455
460
|
return out;
|
|
456
461
|
};
|
|
462
|
+
const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
|
|
463
|
+
const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
|
|
464
|
+
const candS = (dir === 'long' || !timeOk) ? [] : r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
|
|
465
|
+
const usedNow = [...s.held.entries()].filter(([sym]) => !closing.has(sym)).reduce((sum, [, h]) => sum + h.alloc, 0);
|
|
466
|
+
if (autoAdapt && usedNow > s.equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
|
|
467
|
+
const bestNewEdge = Math.max(candL.length ? candL[0].expected : 0, candS.length ? Math.abs(candS[0].expected) : 0);
|
|
468
|
+
const exitNeed = r.threshold * exitRatio;
|
|
469
|
+
if (bestNewEdge >= r.threshold * 2.2) {
|
|
470
|
+
let weakestSym = null, weakestE = Infinity;
|
|
471
|
+
for (const [sym, h] of s.held) {
|
|
472
|
+
if (closing.has(sym)) continue;
|
|
473
|
+
const xCur = row.get(sym);
|
|
474
|
+
const curSignedE = xCur ? (h.side === 'short' ? -xCur.expected : xCur.expected) : 0;
|
|
475
|
+
if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(r.threshold, curSignedE) * 2.4) {
|
|
476
|
+
weakestE = curSignedE;
|
|
477
|
+
weakestSym = sym;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (weakestSym) closePos(weakestSym, 'Ротация в более сильного лидера');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
457
483
|
let entries;
|
|
458
484
|
if (dir === 'long') {
|
|
459
485
|
const free = K - s.held.size + s.pending.filter((o) => o.action === 'sell').length;
|
|
460
|
-
const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && !s.held.has(x.symbol) && !closing.has(x.symbol)).sort((a, b) => b.expected - a.expected);
|
|
461
486
|
if (mktDown && free > 0) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
|
|
462
487
|
entries = take(candL, 'buy', free).map((x) => [x, 'buy']);
|
|
463
488
|
} else {
|
|
464
|
-
const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
|
|
465
|
-
const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
|
|
466
|
-
const candS = !timeOk ? [] : r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
|
|
467
489
|
if (mktDown) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
|
|
468
490
|
const nL = K - sideHeld('long').length;
|
|
469
491
|
const nS = K - sideHeld('short').length;
|
|
@@ -472,7 +494,7 @@ class LiveEngine {
|
|
|
472
494
|
for (const [x, action] of entries) {
|
|
473
495
|
const infl = x.influences?.length ? x.influences.map((n) => n.symbol).join(', ') : '';
|
|
474
496
|
const exp = `${x.expectedPct > 0 ? '+' : ''}${x.expectedPct}`;
|
|
475
|
-
const why = `Стратегия «${MODELS[r.chosen].label}»: ${action === 'short' ? 'продажа: ' : ''}ожидание ${exp}% за ${round(
|
|
497
|
+
const why = `Стратегия «${MODELS[r.chosen].label}»: ${action === 'short' ? 'продажа: ' : ''}ожидание ${exp}% за ${round(effHorizon, 2)} ч относительно рынка${infl ? `; влияют ${infl}` : ''}`;
|
|
476
498
|
s.pending.push({ action, symbol: x.symbol, reason: why, expectedPct: x.expectedPct, weight: x.weight ?? 1 });
|
|
477
499
|
step.decisions.push({ action, symbol: symName(x), text: `${action === 'short' ? 'продать без покупки' : 'купить'} — ожидание ${exp}%${infl ? `, влияют ${infl}` : ''}` });
|
|
478
500
|
}
|
package/server/model/models.js
CHANGED
|
@@ -262,7 +262,7 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
|
|
|
262
262
|
out[u] = a >= 0 ? sum / Math.sqrt(sc.len) : 0;
|
|
263
263
|
}
|
|
264
264
|
});
|
|
265
|
-
list.push({ id: m.id, label: m.label, b, zs });
|
|
265
|
+
list.push({ id: m.id, label: m.label, b, msig, zs });
|
|
266
266
|
}
|
|
267
267
|
if (list.length) macro = { list };
|
|
268
268
|
}
|
|
@@ -505,15 +505,37 @@ export const modelNames = () => Object.keys(MODELS);
|
|
|
505
505
|
// Объяснение прогноза «внимания»: вклад каждого соседа в прогноз акции j
|
|
506
506
|
export function explainAttention(m, f, u, j, top = 3) {
|
|
507
507
|
const N = f.N;
|
|
508
|
+
const k = u * N + j;
|
|
508
509
|
const contrib = new Float64Array(N);
|
|
509
|
-
|
|
510
|
+
let sAtt = 0;
|
|
510
511
|
HEADS.forEach((h, hi) => {
|
|
511
512
|
const A = m.As[BETAS.findIndex((b) => b.v === h.beta)];
|
|
512
513
|
const X = f.X[h.kind === 'price' ? 0 : 1][h.scale];
|
|
513
|
-
for (let i = 0; i < N; i++)
|
|
514
|
+
for (let i = 0; i < N; i++) {
|
|
515
|
+
const c = m.coef[hi] * A[j * N + i] * X[u * N + i];
|
|
516
|
+
contrib[i] += c;
|
|
517
|
+
sAtt += c;
|
|
518
|
+
}
|
|
514
519
|
});
|
|
515
|
-
let
|
|
516
|
-
for (let q = 0; q < 3; q++)
|
|
520
|
+
let selfAtt = 0, sIn = 0;
|
|
521
|
+
for (let q = 0; q < 3; q++) {
|
|
522
|
+
const xq = f.X[0][q][k];
|
|
523
|
+
selfAtt += m.coef[HEADS.length + q] * xq;
|
|
524
|
+
if (m.inertiaCoef) sIn += (m.inertiaCoef[q] || 0) * xq;
|
|
525
|
+
}
|
|
526
|
+
sAtt += selfAtt;
|
|
527
|
+
const ML = f.macro ? f.macro.list : [];
|
|
528
|
+
for (let mi = 0; mi < ML.length; mi++) {
|
|
529
|
+
let zz = 0;
|
|
530
|
+
const zs = ML[mi].zs;
|
|
531
|
+
for (let q = 0; q < 3; q++) zz += m.coef[HEADS.length + 3 + mi * 3 + q] * zs[q * f.W + u];
|
|
532
|
+
sAtt += ML[mi].b[j] * zz;
|
|
533
|
+
}
|
|
534
|
+
const conflictScale = (m.inertiaCoef && sAtt * sIn < 0) ? 0.5 : 1;
|
|
535
|
+
const wAtt = (m.inertiaCoef ? 0.50 : 1) * conflictScale;
|
|
536
|
+
const wIn = (m.inertiaCoef ? 0.50 : 0) * conflictScale;
|
|
537
|
+
for (let i = 0; i < N; i++) contrib[i] *= wAtt;
|
|
538
|
+
const self = wAtt * selfAtt + wIn * sIn;
|
|
517
539
|
const idx = [...contrib.keys()].filter((i) => i !== j).sort((a, b) => Math.abs(contrib[b]) - Math.abs(contrib[a])).slice(0, top);
|
|
518
540
|
return { neighbors: idx.map((i) => ({ i, contrib: contrib[i] })), self };
|
|
519
541
|
}
|
package/server/model/research.js
CHANGED
|
@@ -9,7 +9,7 @@ import { ensureProfiles } from './profiles.js';
|
|
|
9
9
|
import { spearman } from './linalg.js';
|
|
10
10
|
import { listRuns, getRun, createRun, finishRun, saveTrade, saveRunExtra, kvGet } from '../db.js';
|
|
11
11
|
import { strategyDefaults } from '../config.js';
|
|
12
|
-
import { ENTRY_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
|
|
12
|
+
import { ENTRY_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked } from './edge.js';
|
|
13
13
|
import { buildDividendCalendar, nextDividendAt, dividendRules, DIV_TAX } from './dividends.js';
|
|
14
14
|
|
|
15
15
|
const DAY = 86400000;
|
|
@@ -40,7 +40,10 @@ export function isMarketDown(ds, t) {
|
|
|
40
40
|
// 9: исторический состав индекса (без ошибки выжившего), дивиденды префов, правила v0.5.x
|
|
41
41
|
// 10: запрет переноса внутридневных сделок через ночь, период полураспада карантина 15 дн., строгий 1-й эшелон
|
|
42
42
|
// 11: индивидуальный расчёт порога каждой акции по её фактическому рублёвому обороту и остаточной волатильности
|
|
43
|
-
|
|
43
|
+
// 12: калибровка индивидуальных порогов 2–3 эшелона по ликвидности, защита от шорт-сквизов (≥20% за день) и обвалов (≥9,5% за 30м), отбор доступных шортов до Top-K
|
|
44
|
+
// 13: полная докачка левого края истории свечей (раньше при уже накопленных свечах 2022 год не догружался)
|
|
45
|
+
// 14: фиксация начала датасета современного 17-часового режима Мосбиржи (18.01.2024) и якоря фолдов после редомициляции (13.06.2024)
|
|
46
|
+
const DATA_VERSION = 14;
|
|
44
47
|
|
|
45
48
|
const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
|
|
46
49
|
function reuseParamsKey(params) {
|
|
@@ -158,13 +161,14 @@ async function run(runId, p, state) {
|
|
|
158
161
|
// НИКОГДА не сдвигала fromTs и не вызывала повторную загрузку котировок!
|
|
159
162
|
const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
|
|
160
163
|
const rawResearchFrom = todayStartMsk - p.modelHistoryDays * DAY;
|
|
161
|
-
// Стабильная привязка
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
const
|
|
164
|
+
// Стабильная привязка к современному режиму торгов Мосбиржи (утренняя + основная + вечерняя сессии, 94 свечи/день с 18.01.2024)
|
|
165
|
+
// и якорю сетки 20-дневных окон после завершения волны редомициляции ключевых эмитентов (1718272800000 = 13.06.2024 13:00 МСК):
|
|
166
|
+
// смена календарного дня запуска или наличие в SQLite архивных свечей 2022–2023 гг. НИКОГДА не сдвигает сетку фолдов и не искажает barsPerDay!
|
|
167
|
+
const MODERN_REGIME_START_TS = 1705560600000;
|
|
168
|
+
const EPOCH_ANCHOR_TS = 1718272800000;
|
|
165
169
|
const gridBlockMs = 20 * DAY;
|
|
166
|
-
const researchFrom = EPOCH_ANCHOR_TS + Math.round((rawResearchFrom - EPOCH_ANCHOR_TS) / gridBlockMs) * gridBlockMs;
|
|
167
|
-
const fromTs =
|
|
170
|
+
const researchFrom = Math.max(EPOCH_ANCHOR_TS, EPOCH_ANCHOR_TS + Math.round((rawResearchFrom - EPOCH_ANCHOR_TS) / gridBlockMs) * gridBlockMs);
|
|
171
|
+
const fromTs = MODERN_REGIME_START_TS;
|
|
168
172
|
|
|
169
173
|
emit({ stage: 'data', pct: 0, text: 'Загружаем словарь рынка (состав индекса широкого рынка)…' });
|
|
170
174
|
const ds = await loadDataset({
|
|
@@ -177,7 +181,7 @@ async function run(runId, p, state) {
|
|
|
177
181
|
const bpd = ds.barsPerDay;
|
|
178
182
|
const trainLen = Math.round(p.modelTrainDays * bpd);
|
|
179
183
|
const testLen = Math.round(20 * bpd);
|
|
180
|
-
let testStart = Math.max(timeline.findIndex((t) => t >= researchFrom),
|
|
184
|
+
let testStart = Math.max(timeline.findIndex((t) => t >= researchFrom), FEATURE_WARMUP + maxH + 100);
|
|
181
185
|
if (testStart < 0 || testStart >= T - H - 10) throw new Error('Недостаточно истории: увеличьте период исследования или уменьшите окно обучения');
|
|
182
186
|
const firstTest = testStart;
|
|
183
187
|
|
|
@@ -461,8 +465,8 @@ async function run(runId, p, state) {
|
|
|
461
465
|
}
|
|
462
466
|
const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
|
|
463
467
|
const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
|
|
464
|
-
const okLong = (i) => !divRule(i, t)?.blockLong;
|
|
465
|
-
const okShort = (i) => !divRule(i, t)?.blockShort;
|
|
468
|
+
const okLong = (i) => !divRule(i, t)?.blockLong && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
|
|
469
|
+
const okShort = (i) => !divRule(i, t)?.blockShort && (!shortable || shortable.has(tokens[i].symbol)) && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'short'));
|
|
466
470
|
let order, entries;
|
|
467
471
|
if (dir !== 'long') {
|
|
468
472
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
@@ -474,12 +478,15 @@ async function run(runId, p, state) {
|
|
|
474
478
|
const e = edge(i);
|
|
475
479
|
const barsHeld = tIn - h.entryT;
|
|
476
480
|
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
|
|
477
|
-
|
|
481
|
+
const shocked = autoAdapt && isRegimeShocked(ds, i, t, h.side);
|
|
482
|
+
if (shocked || !(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) {
|
|
483
|
+
closePos(i, h, tIn, false, shocked ? 'Защита от экстремального сквиза/обвала' : 'Прогноз ослаб');
|
|
484
|
+
}
|
|
478
485
|
}
|
|
479
486
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
480
487
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
481
|
-
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) =>
|
|
482
|
-
const candS = !timeOk ? [] : [...pr.keys()].filter((i) =>
|
|
488
|
+
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
489
|
+
const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
|
|
483
490
|
// Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
|
|
484
491
|
// если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
|
|
485
492
|
// освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
|
|
@@ -516,11 +523,27 @@ async function run(runId, p, state) {
|
|
|
516
523
|
const e = edge(i);
|
|
517
524
|
const barsHeld = tIn - h.entryT;
|
|
518
525
|
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
|
|
519
|
-
|
|
526
|
+
const shocked = autoAdapt && isRegimeShocked(ds, i, t, 'long');
|
|
527
|
+
if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn, false, shocked ? 'Защита от экстремального обвала' : '');
|
|
520
528
|
}
|
|
521
|
-
const free = K - held.size;
|
|
522
529
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
523
|
-
const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) =>
|
|
530
|
+
const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
531
|
+
const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
532
|
+
if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
|
|
533
|
+
const bestNewEdge = edge(rawCand[0]);
|
|
534
|
+
if (bestNewEdge >= need * 2.2) {
|
|
535
|
+
let weakestIdx = -1, weakestE = Infinity;
|
|
536
|
+
for (const [i] of held) {
|
|
537
|
+
const curSignedE = edge(i);
|
|
538
|
+
if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
|
|
539
|
+
weakestE = curSignedE;
|
|
540
|
+
weakestIdx = i;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn, false, 'Ротация в более сильного лидера');
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const free = K - held.size;
|
|
524
547
|
const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
|
|
525
548
|
order = [...held.keys(), ...cand];
|
|
526
549
|
} else {
|
|
@@ -912,7 +935,7 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
|
|
|
912
935
|
return round(((lo + hi) / 2) * 100, 3);
|
|
913
936
|
};
|
|
914
937
|
// Подбор порога входа под реальные издержки: живое исполнение, издержки 0,09% на сторону (комиссия 0,04% + проскальзывание ~0,05%)
|
|
915
|
-
const thresholdSweep = [1, 1.5, 1.65, 2, 2.5, 3].map((m) => {
|
|
938
|
+
const thresholdSweep = [1, 1.5, 1.6, 1.65, 2, 2.5, 3].map((m) => {
|
|
916
939
|
const sim = simulate(chosen, 0.0009, p.modelShortFeePct, 0, { ...LIVE, needMult: m });
|
|
917
940
|
const f = computeReport(sim.trades, sim.curve, p.capital, 0).full;
|
|
918
941
|
return { needMult: m, current: m === ENTRY_EDGE_MULT, entryEdgePct: round(2.5 * 2 * base * m * 100, 3), returnPct: f.returnPct, cagrPct: cagr(f.returnPct, yearsOf(sim.curve)), profitFactor: f.profitFactor, maxDrawdownPct: f.maxDrawdownPct, trades: f.trades, avgTradePct: f.avgTradePct, last12Pct: segment(sim, lastTs - 365 * 86400000).returnPct, last6Pct: segment(sim, lastTs - 182 * 86400000).returnPct };
|
|
@@ -924,7 +947,7 @@ function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
|
|
|
924
947
|
market: { full: mSeg(-Infinity), last12: mSeg(lastTs - 365 * 86400000), last6: mSeg(lastTs - 182 * 86400000) },
|
|
925
948
|
shortableKnown: Boolean(shortable),
|
|
926
949
|
caveats: [
|
|
927
|
-
'Состав рынка —
|
|
950
|
+
'Состав рынка — исторический состав индекса MOEXBMI на каждую дату плюс текущий состав; отсекаются только бумаги с покрытием данных менее 15% периода.',
|
|
928
951
|
'Константы стратегии подбирались на этой же истории, поэтому «последние 6 и 12 месяцев» не являются честно отложенной выборкой. Честная проверка — торговля после фиксации версии.',
|
|
929
952
|
'Список бумаг для шорта — текущий, а не на каждую дату.',
|
|
930
953
|
],
|
|
@@ -974,10 +997,11 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
974
997
|
await pause();
|
|
975
998
|
const p = normalizeParams(runRow.params || {});
|
|
976
999
|
const shifts = [-8, -4, 0, 4, 8];
|
|
977
|
-
const
|
|
1000
|
+
const autoAdapt = p.modelAutoAdapt !== false;
|
|
1001
|
+
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
1002
|
+
const H = barsFromHours(effHorizon);
|
|
978
1003
|
const tillTs = Date.now();
|
|
979
|
-
const
|
|
980
|
-
const fromTs = todayStartMsk - 1650 * DAY;
|
|
1004
|
+
const fromTs = 1705560600000;
|
|
981
1005
|
const periodTill = runRow.metrics?.period?.till || tillTs;
|
|
982
1006
|
const emitStab = (m) => { if (!st.cancelled) hub.emit({ type: 'stability:progress', runId: targetId, ...m }); };
|
|
983
1007
|
emitStab({ pct: 2, text: 'Проверка устойчивости: чтение локальных цен…' });
|
|
@@ -991,11 +1015,18 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
991
1015
|
const trainLen = Math.round(p.modelTrainDays * bpd), testLen = Math.round(20 * bpd);
|
|
992
1016
|
const chosen = p.modelType in MODELS ? p.modelType : 'attention';
|
|
993
1017
|
const realCost = (p.commissionPct + p.slippagePct) / 100;
|
|
994
|
-
const
|
|
995
|
-
const
|
|
1018
|
+
const rawTopK = Number(p.modelTopK ?? 2);
|
|
1019
|
+
const K = rawTopK > 0 ? rawTopK : 4;
|
|
1020
|
+
const dir = p.modelDirection, slots = dir === 'long' ? K : 2 * K;
|
|
996
1021
|
const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
|
|
997
1022
|
const exitRatio = Number(p.modelExitRatio ?? 0.5);
|
|
998
1023
|
const mktFilter = p.modelMarketFilter === 'ma', invVol = p.modelAllocMode === 'invvol';
|
|
1024
|
+
const baseSymMult = Float64Array.from(tokens, (tk) => symbolEdgeMult(tk.symbol));
|
|
1025
|
+
const divCal = buildDividendCalendar(ds);
|
|
1026
|
+
const divRule = (i, t) => {
|
|
1027
|
+
const nd = nextDividendAt(divCal, i, t);
|
|
1028
|
+
return nd ? dividendRules(nd.daysToEx, nd.y) : null;
|
|
1029
|
+
};
|
|
999
1030
|
const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
|
|
1000
1031
|
const canEnter = (i, t) => liquid(i, t) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1);
|
|
1001
1032
|
const tsOf = (t) => timeline[Math.min(t, T - 1)];
|
|
@@ -1019,7 +1050,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1019
1050
|
const maxH = autoAdapt ? Math.round(H * 1.4) : H;
|
|
1020
1051
|
const folds = Math.max(1, Math.ceil((T - maxH - testStart) / testLen));
|
|
1021
1052
|
let fi = 0;
|
|
1022
|
-
const preds = new Map(), sigmaAt = new Map();
|
|
1053
|
+
const preds = new Map(), sigmaAt = new Map(), multAt = new Map();
|
|
1023
1054
|
while (testStart < T - maxH - 2) {
|
|
1024
1055
|
if (st.cancelled) throw new Error('Отменено');
|
|
1025
1056
|
const testEnd = Math.min(testStart + testLen, T - maxH - 2);
|
|
@@ -1028,12 +1059,14 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1028
1059
|
emitStab({ pct, text: `Проверка устойчивости: сдвиг ${si + 1} из ${shifts.length} (${shLabel}), шаг ${fi + 1} из ${folds}…` });
|
|
1029
1060
|
await tick();
|
|
1030
1061
|
const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
|
|
1062
|
+
const foldEdgeMults = computeTokenEdgeMults(tokens, f.sigma, trainStart, testStart);
|
|
1031
1063
|
const m = await MODELS[chosen].fit(f);
|
|
1032
1064
|
for (let t = testStart; t < testEnd; t++) {
|
|
1033
1065
|
const u = t - f.lo;
|
|
1034
1066
|
if (!f.yok[u]) continue;
|
|
1035
1067
|
preds.set(t, MODELS[chosen].predict(m, f, u));
|
|
1036
1068
|
sigmaAt.set(t, f.sigma);
|
|
1069
|
+
multAt.set(t, foldEdgeMults);
|
|
1037
1070
|
}
|
|
1038
1071
|
testStart = testEnd;
|
|
1039
1072
|
fi++;
|
|
@@ -1051,7 +1084,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1051
1084
|
const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
|
|
1052
1085
|
const curExitRatio = autoAdapt ? 0.95 : exitRatio;
|
|
1053
1086
|
const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
|
|
1054
|
-
const maxSlotWeight = dir === 'long' ? Math.min(0.
|
|
1087
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
|
|
1055
1088
|
let closedThisBar = new Set();
|
|
1056
1089
|
const closePos = (i, h, t) => {
|
|
1057
1090
|
const exitPrice = px(i, t);
|
|
@@ -1075,9 +1108,14 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1075
1108
|
for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
|
|
1076
1109
|
}
|
|
1077
1110
|
const pr = preds.get(t), sg = sigmaAt.get(t);
|
|
1111
|
+
const symMult = multAt.get(t) || baseSymMult;
|
|
1078
1112
|
const bear = isMarketDown(ds, t);
|
|
1079
1113
|
const mktDown = mktFilter && bear;
|
|
1080
1114
|
const barTs = tsOf(t);
|
|
1115
|
+
for (const [i, h] of [...held]) {
|
|
1116
|
+
const r = divRule(i, t);
|
|
1117
|
+
if (r && (h.side === 'long' ? r.exitLong : r.exitShort)) closePos(i, h, tIn);
|
|
1118
|
+
}
|
|
1081
1119
|
if (autoAdapt) {
|
|
1082
1120
|
for (const [i, h] of [...held]) {
|
|
1083
1121
|
if (shouldExitBeforeNight(barTs, tsOf(h.entryT), stepBars, BAR_INTERVAL)) closePos(i, h, tIn);
|
|
@@ -1085,29 +1123,64 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1085
1123
|
}
|
|
1086
1124
|
const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
|
|
1087
1125
|
const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
|
|
1126
|
+
const okLong = (i) => !divRule(i, t)?.blockLong && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
|
|
1127
|
+
const okShort = (i) => !divRule(i, t)?.blockShort && (!autoAdapt || !isRegimeShocked(ds, i, t, 'short'));
|
|
1088
1128
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
1089
1129
|
const need = effMinEdge * 2 * realCost * ENTRY_EDGE_MULT, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
|
|
1090
1130
|
let order, entries;
|
|
1091
1131
|
if (dir !== 'long') {
|
|
1092
1132
|
for (const [i, h] of [...held]) {
|
|
1093
1133
|
const e = edge(i), barsHeld = tIn - h.entryT;
|
|
1094
|
-
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) *
|
|
1095
|
-
|
|
1134
|
+
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
|
|
1135
|
+
const shocked = autoAdapt && isRegimeShocked(ds, i, t, h.side);
|
|
1136
|
+
if (shocked || !(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn);
|
|
1096
1137
|
}
|
|
1097
1138
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
1098
1139
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
1099
|
-
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) =>
|
|
1100
|
-
const candS = !timeOk ? [] : [...pr.keys()].filter((i) =>
|
|
1140
|
+
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
1141
|
+
const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
|
|
1142
|
+
const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
1143
|
+
if (autoAdapt && usedNow > equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
|
|
1144
|
+
const bestNewEdge = Math.max(candL.length ? edge(candL[0]) : 0, candS.length ? Math.abs(edge(candS[0])) : 0);
|
|
1145
|
+
if (bestNewEdge >= need * 2.2) {
|
|
1146
|
+
let weakestIdx = -1, weakestE = Infinity;
|
|
1147
|
+
for (const [i, h] of held) {
|
|
1148
|
+
const curSignedE = h.side === 'long' ? edge(i) : -edge(i);
|
|
1149
|
+
if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
|
|
1150
|
+
weakestE = curSignedE;
|
|
1151
|
+
weakestIdx = i;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1101
1157
|
const nL = K - countSide('long'), nS = K - countSide('short');
|
|
1102
1158
|
entries = [...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]), ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)])];
|
|
1103
1159
|
} else if (effMinEdge > 0) {
|
|
1104
1160
|
for (const [i, h] of [...held]) {
|
|
1105
1161
|
const e = edge(i), barsHeld = tIn - h.entryT;
|
|
1106
|
-
const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
|
|
1107
|
-
|
|
1162
|
+
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
|
|
1163
|
+
const shocked = autoAdapt && isRegimeShocked(ds, i, t, 'long');
|
|
1164
|
+
if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn);
|
|
1108
1165
|
}
|
|
1109
1166
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
1110
|
-
const
|
|
1167
|
+
const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
1168
|
+
const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
1169
|
+
if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
|
|
1170
|
+
const bestNewEdge = edge(rawCand[0]);
|
|
1171
|
+
if (bestNewEdge >= need * 2.2) {
|
|
1172
|
+
let weakestIdx = -1, weakestE = Infinity;
|
|
1173
|
+
for (const [i] of held) {
|
|
1174
|
+
const curSignedE = edge(i);
|
|
1175
|
+
if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
|
|
1176
|
+
weakestE = curSignedE;
|
|
1177
|
+
weakestIdx = i;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
const cand = rawCand.slice(0, Math.max(0, K - held.size));
|
|
1111
1184
|
order = [...held.keys(), ...cand];
|
|
1112
1185
|
} else {
|
|
1113
1186
|
const cand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => !closedThisBar.has(i) && stockAllowed(i) && pr[i] > 0 && canEnter(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
|
|
@@ -1125,11 +1198,25 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1125
1198
|
}
|
|
1126
1199
|
const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
1127
1200
|
let avail = Math.max(0, equity * 0.99 - usedStart);
|
|
1201
|
+
const activeTotal = Math.max(2, held.size + newEntries.length);
|
|
1202
|
+
const dynSlotWeight = autoAdapt
|
|
1203
|
+
? Math.min(dir === 'long' ? 0.58 : 0.75, Math.max(maxSlotWeight, (dir === 'long' ? 1.8 : 3.0) / Math.min(slots, activeTotal + 1)))
|
|
1204
|
+
: maxSlotWeight;
|
|
1205
|
+
if (autoAdapt && newEntries.length > 1) {
|
|
1206
|
+
newEntries.sort((a, b) => Math.abs(b[2] ?? 0) - Math.abs(a[2] ?? 0));
|
|
1207
|
+
}
|
|
1208
|
+
const edgeWeights = newEntries.map(([i, , edgeAt]) => {
|
|
1209
|
+
const effE = edgeAt !== null && edgeAt !== undefined ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
|
|
1210
|
+
return Math.max(0.0001, effE);
|
|
1211
|
+
});
|
|
1212
|
+
let remWeightSum = edgeWeights.reduce((a, b) => a + b, 0);
|
|
1128
1213
|
for (let idx = 0; idx < newEntries.length; idx++) {
|
|
1129
|
-
const [i, side
|
|
1130
|
-
const effEdge =
|
|
1131
|
-
const conv = autoAdapt && need > 0 && effEdge > 0 ? Math.min(1.
|
|
1132
|
-
const
|
|
1214
|
+
const [i, side] = newEntries[idx];
|
|
1215
|
+
const effEdge = edgeWeights[idx];
|
|
1216
|
+
const conv = autoAdapt && need > 0 && effEdge > 0 ? Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (effEdge / need))) : 1;
|
|
1217
|
+
const propShare = autoAdapt && remWeightSum > 0 ? avail * (effEdge / remWeightSum) : avail / Math.max(1, newEntries.length - idx);
|
|
1218
|
+
remWeightSum = Math.max(0, remWeightSum - effEdge);
|
|
1219
|
+
const baseAlloc = autoAdapt ? Math.min(equity * dynSlotWeight * conv, propShare, avail) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
|
|
1133
1220
|
let alloc = baseAlloc;
|
|
1134
1221
|
if (invVol && avgInv > 0 && sg[i] > 0) {
|
|
1135
1222
|
const w = Math.max(0.4, Math.min(1.8, (1 / sg[i]) / avgInv));
|
package/server/news/index.js
CHANGED
|
@@ -280,9 +280,9 @@ export function newsForSymbols(hours = 24) {
|
|
|
280
280
|
|
|
281
281
|
// Для живой проверки с newsFilter: самые важные свежие новости (важность ≥ 0.6, |тон| ≥ 0.4)
|
|
282
282
|
// по тикерам: symbol → { bad: { gist, imp, tone, ts }, good: {…} }
|
|
283
|
-
export function blockingNews(hours = 6) {
|
|
283
|
+
export function blockingNews(hours = 6, atTs = Date.now()) {
|
|
284
284
|
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 >= ?`).all(
|
|
285
|
+
WHERE marked = 1 AND imp >= 0.6 AND tags IS NOT NULL AND tags != '[]' AND ts >= ? AND ts <= ?`).all(atTs - hours * 3600000, atTs);
|
|
286
286
|
const m = new Map();
|
|
287
287
|
for (const r of rows) {
|
|
288
288
|
for (const [sym, tone] of parseTags(r)) {
|
|
@@ -205,9 +205,9 @@ function systemPrompt() {
|
|
|
205
205
|
- **Карта рынка и авто-эмбеддинг (modelEmbedDim = 0 Авто):** окно обучения фиксировано на 90 торговых днях. На каждом шаге размерность карты рынка автоматически определяется по спектральному порогу Марченко–Пастура (отсечение рыночного шума от истинных факторов).
|
|
206
206
|
- **12 голов внимания и мульти-горизонт (1,5–3,5 часа):** модель смотрит на 3 масштаба времени (1ч, 3ч, день), цену и денежный поток, узких и широких соседей по карте, предсказывая чистое остаточное движение каждой акции относительно индекса Мосбиржи.
|
|
207
207
|
- **Пороги входа и удержания (индивидуальные по эшелонам ликвидности):**
|
|
208
|
-
- Базовый порог выгоды на вход = 2,5 × (удвоенная сумма комиссии и проскальзывания) × 1,
|
|
209
|
-
- Для акций 2–3-го эшелона с более широким спредом в стакане (MRKU, LSRG, SFIN, SOFL, FIXR, NKNCP, SVAV, PIKK, SMLT и др.) порог автоматически повышается в 1,20× — до
|
|
210
|
-
- Авто-балансировка по тренду индекса: на растущем рынке (индекс выше 20-дневной средней) покупки открываются от +0,
|
|
208
|
+
- Базовый порог выгоды на вход = 2,5 × (удвоенная сумма комиссии и проскальзывания) × 1,60 (запас над реальными издержками по стресс-тесту и фактическим сделкам). Для высоколиквидных голубых фишек 1-го эшелона (SBER, LKOH, GAZP, T, YDEX, GMKN, FLOT и др.) при издержках 0,05% за сделку это +0,40% (с индивидуальной подстройкой под оборот и волатильность бумаги).
|
|
209
|
+
- Для акций 2–3-го эшелона с более широким спредом в стакане (MRKU, LSRG, SFIN, SOFL, FIXR, NKNCP, SVAV, PIKK, SMLT и др.) порог автоматически повышается в 1,20× — до ~+0,48% (с индивидуальной поправкой на рублёвую ликвидность стакана).
|
|
210
|
+
- Авто-балансировка по тренду индекса: на растущем рынке (индекс выше 20-дневной средней) покупки открываются от базового порога (+0,40% 1-й эшелон и +0,48% 2–3-й эшелон), а контртрендовые шорты ужесточаются в 1,25× (до −0,50% для 1-го эшелона и −0,60% для 2–3-го эшелона). На падающем рынке — наоборот. Всегда называй пользователю именно индивидуальный порог конкретной бумаги (\`порог_покупки_проц\` / \`порог_шорта_проц\`).
|
|
211
211
|
- Удержание позиции: первые 65 минут после входа действует мягкий порог удержания (0,35× от порога входа), затем — плотный порог фиксации прибыли.
|
|
212
212
|
- Реальные издержки на счёте Т-Банка (по фактическим сделкам): комиссия ~0,04% + отклонение цены исполнения от цены сигнала ~0,03–0,08% на сторону.
|
|
213
213
|
- **Dynamic Top-K (modelTopK = 0 Авто) и Conviction Sizing:** фиксированного числа акций нет — робот держит от 0 до 4 лонгов и до 4 шортов в зависимости от силы сигналов и концентрирует слот в лучших лидерах.
|
|
@@ -279,10 +279,13 @@ export const TOOLS = {
|
|
|
279
279
|
ожидание_проц: x.expectedPct,
|
|
280
280
|
порог_покупки_проц: symBuyThr,
|
|
281
281
|
...(x.shortSignal !== undefined ? { порог_шорта_проц: -symShortThr } : {}),
|
|
282
|
+
доля_порога_проц: x.thresholdRatioPct ?? 0,
|
|
283
|
+
порог_пройден: Boolean(x.crossedThreshold),
|
|
282
284
|
импульс_текущей_свечи_проц: x.lastMovePct ?? 0,
|
|
283
285
|
динамика_проц: x.deltaPct || 0,
|
|
284
286
|
сигнал_покупки: !!x.signal,
|
|
285
287
|
...(buyBlockedByDip ? { покупка_заблокирована: `сейчас бумага падает на текущей свече (${x.lastMovePct}%), сработала защита от ловли падающего ножа` } : {}),
|
|
288
|
+
...(x.buyBlockedByOverheat ? { покупка_заблокирована_перегревом: `бумага уже перегрета (+${x.buyBlockedByOverheat}% сверх рынка за 1–3 дня), сработала защита от покупки на вершине пампа` } : {}),
|
|
286
289
|
...(x.buyBlockedByDiv ? { покупка_заблокирована_дивидендами: x.buyBlockedByDiv } : {}),
|
|
287
290
|
...(x.buyBlockedByNews ? { покупка_заблокирована_новостями: x.buyBlockedByNews } : {}),
|
|
288
291
|
...(x.shortSignal !== undefined ? { сигнал_шорта: !!x.shortSignal } : {}),
|
|
@@ -309,6 +312,7 @@ export const TOOLS = {
|
|
|
309
312
|
...(targetRow ? { выбранная_акция: pick(targetRow) } : {}),
|
|
310
313
|
сильнее_рынка: r.rows.slice(0, top).map(pick),
|
|
311
314
|
слабее_рынка: r.rows.slice(-Math.min(6, top)).reverse().map(pick),
|
|
315
|
+
журнал_почти_сигналов_за_неделю: (r.nearSignals || []).slice(0, 12),
|
|
312
316
|
},
|
|
313
317
|
summary: `Прогноз на свече ${r.bar}: сигналов покупки ${r.rows.filter((x) => x.signal).length}, шорта ${r.rows.filter((x) => x.shortSignal).length}`,
|
|
314
318
|
};
|