neuroalpha 0.3.16 → 0.5.1
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/README.md +0 -1
- package/config/certs/russian_trusted_root_ca.pem +33 -0
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -6
- package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +2 -2
- package/package.json +60 -53
- package/public/activate.html +36 -0
- package/public/activate.js +33 -0
- package/public/admin.html +126 -7
- package/public/app.js +608 -182
- package/public/index.html +57 -47
- package/public/landing.html +106 -37
- package/public/legal.html +4 -4
- package/public/styles.css +152 -36
- package/server/api/routes.js +308 -37
- package/server/auth/index.js +79 -15
- package/server/brokers/index.js +1022 -172
- package/server/build-info.js +7 -0
- package/server/config.js +13 -6
- package/server/data/moex.js +6 -4
- package/server/db.js +22 -18
- package/server/desktop-entry.js +103 -0
- package/server/hub.js +28 -14
- package/server/index.js +82 -12
- package/server/instance/agent.js +259 -0
- package/server/instance/central.js +491 -0
- package/server/instance/identity.js +79 -0
- package/server/instance/provision.js +166 -0
- package/server/instance/signing.js +71 -0
- package/server/model/edge.js +19 -0
- package/server/model/forecast.js +107 -57
- package/server/model/live.js +1 -1
- package/server/model/models.js +12 -3
- package/server/model/research.js +218 -30
- package/server/notify/index.js +51 -16
- package/server/params-meta.js +3 -3
- package/server/role.js +44 -0
- package/server/security.js +40 -0
- package/server/strategist/index.js +462 -488
- package/server/strategist/tools.js +675 -532
- package/server/tls-ca.js +32 -0
package/server/model/research.js
CHANGED
|
@@ -7,8 +7,9 @@ import { loadDataset, barsFromHours, BAR_INTERVAL } from './dataset.js';
|
|
|
7
7
|
import { buildFold, MODELS, modelNames, HEADS, LIQ_MIN, FEATURE_WARMUP, mapSnapshot, alignTo, matchClusters } from './models.js';
|
|
8
8
|
import { ensureProfiles } from './profiles.js';
|
|
9
9
|
import { spearman } from './linalg.js';
|
|
10
|
-
import { listRuns, getRun, createRun, finishRun, saveTrade, saveRunExtra } from '../db.js';
|
|
10
|
+
import { listRuns, getRun, createRun, finishRun, saveTrade, saveRunExtra, kvGet } from '../db.js';
|
|
11
11
|
import { strategyDefaults } from '../config.js';
|
|
12
|
+
import { ENTRY_EDGE_MULT, symbolEdgeMult } from './edge.js';
|
|
12
13
|
|
|
13
14
|
const DAY = 86400000;
|
|
14
15
|
const MSK = 3 * 3600000;
|
|
@@ -152,8 +153,14 @@ async function run(runId, p, state) {
|
|
|
152
153
|
// чтобы смена «Окна обучения» (modelTrainDays) или «Периода исследования» (modelHistoryDays)
|
|
153
154
|
// НИКОГДА не сдвигала fromTs и не вызывала повторную загрузку котировок!
|
|
154
155
|
const todayStartMsk = Math.floor((tillTs + MSK) / DAY) * DAY - MSK;
|
|
155
|
-
const
|
|
156
|
-
|
|
156
|
+
const rawResearchFrom = todayStartMsk - p.modelHistoryDays * DAY;
|
|
157
|
+
// Стабильная привязка сетки 20-дневных окон переобучения к эпохе (1706165400000 = 25.01.2024):
|
|
158
|
+
// начало первого фолда выравнивается по 20-дневной сетке, чтобы смена календарного дня запуска НЕ перекраивала всю прошлую 3-летнюю историю фолдов,
|
|
159
|
+
// а новые торговые дни честно дописывались в правый край последнего фолда!
|
|
160
|
+
const EPOCH_ANCHOR_TS = 1706165400000;
|
|
161
|
+
const gridBlockMs = 20 * DAY;
|
|
162
|
+
const researchFrom = EPOCH_ANCHOR_TS + Math.round((rawResearchFrom - EPOCH_ANCHOR_TS) / gridBlockMs) * gridBlockMs;
|
|
163
|
+
const fromTs = EPOCH_ANCHOR_TS - 660 * DAY;
|
|
157
164
|
|
|
158
165
|
emit({ stage: 'data', pct: 0, text: 'Загружаем словарь рынка (состав индекса широкого рынка)…' });
|
|
159
166
|
const ds = await loadDataset({
|
|
@@ -249,7 +256,7 @@ async function run(runId, p, state) {
|
|
|
249
256
|
foldIc,
|
|
250
257
|
pts: ptsRound,
|
|
251
258
|
cl,
|
|
252
|
-
|
|
259
|
+
symbols: tokens.map((tk) => tk.symbol),
|
|
253
260
|
...(attM ? {
|
|
254
261
|
heads: HEADS.map((h, i) => ({ label: h.label, coef: round(attM.coef[i], 4) })),
|
|
255
262
|
self: [round(attM.coef[HEADS.length], 4), round(attM.coef[HEADS.length + 1], 4), round(attM.coef[HEADS.length + 2], 4)],
|
|
@@ -293,6 +300,7 @@ async function run(runId, p, state) {
|
|
|
293
300
|
const mktFilter = p.modelMarketFilter === 'ma';
|
|
294
301
|
const invVol = p.modelAllocMode === 'invvol';
|
|
295
302
|
const realCost = (p.commissionPct + p.slippagePct) / 100;
|
|
303
|
+
const symMult = Float64Array.from(tokens, (tk) => symbolEdgeMult(tk.symbol));
|
|
296
304
|
const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
|
|
297
305
|
// только покупки: на позицию капитал / K; с продажами: капитал / 2K (покупки и продажи вместе не больше капитала)
|
|
298
306
|
const slots = dir === 'long' ? K : 2 * K;
|
|
@@ -324,14 +332,43 @@ async function run(runId, p, state) {
|
|
|
324
332
|
|
|
325
333
|
// решение на закрытии свечи t, сделки по цене открытия свечи t+1; сопровождение раз в H/2 свечей (при autoAdapt) или H свечей
|
|
326
334
|
// Плата за заём при продаже — % годовых от суммы позиции за календарное время от входа до выхода
|
|
327
|
-
|
|
335
|
+
// Параметры исполнения для стресс-теста (по умолчанию — как в основном отчёте):
|
|
336
|
+
// lag — через сколько свечей после решения входим/выходим (1 = open[t+1]; 2 = на свечу позже)
|
|
337
|
+
// needTrade — входить только в свечу, где реально были сделки; выход — по первой свече со сделками
|
|
338
|
+
// partCap — позиция не больше этой доли оборота свечи входа (в рублях)
|
|
339
|
+
// lots — только целые лоты
|
|
340
|
+
// shortable — множество бумаг, которые брокер разрешает продавать без покрытия (null — без проверки)
|
|
341
|
+
function simulate(name, cost, borrowPct = p.modelShortFeePct, startIdx = 0, ex = {}) {
|
|
328
342
|
startIdx = Math.max(0, Math.floor(startIdx));
|
|
343
|
+
const lag = Math.max(1, Math.floor(ex.lag || 1));
|
|
344
|
+
const needTrade = Boolean(ex.needTrade);
|
|
345
|
+
const partCap = Number(ex.partCap) || 0;
|
|
346
|
+
const useLots = Boolean(ex.lots);
|
|
347
|
+
const shortable = ex.shortable || null;
|
|
348
|
+
const midBar = Boolean(ex.midBar);
|
|
349
|
+
// множитель порога входа и выхода (1 — как в стратегии): проверка «торговать реже, но с запасом над издержками»
|
|
350
|
+
const needMult = Number(ex.needMult) > 0 ? Number(ex.needMult) : ENTRY_EDGE_MULT;
|
|
351
|
+
const cashRateAnnual = Number(ex.cashRateAnnual) > 0 ? Number(ex.cashRateAnnual) : 0;
|
|
352
|
+
const costOf = (i) => (ex.tierCost ? (symMult[i] === 1 ? 0.00065 : 0.00125) : cost);
|
|
329
353
|
let equity = p.capital;
|
|
354
|
+
let idleShareSum = 0, idleSteps = 0, prevStepTs = tsOf(testTs[startIdx] ?? testTs[0]);
|
|
330
355
|
const curve = [{ ts: tsOf(testTs[startIdx] ?? testTs[0]), equity }];
|
|
331
356
|
const trades = [];
|
|
332
357
|
const held = new Map(); // i -> { side, entryT, entryPrice, alloc, entryCost, expectedPct }
|
|
333
|
-
let lastOut = (testTs[startIdx] ?? testTs[0]) +
|
|
358
|
+
let lastOut = (testTs[startIdx] ?? testTs[0]) + lag;
|
|
334
359
|
const px = (i, t) => tokens[i].open[t];
|
|
360
|
+
// цена исполнения: open свечи или (midBar) средняя цена свечи — заявка исполняется где-то внутри неё, а не на первой сделке
|
|
361
|
+
const fillPx = (i, t) => {
|
|
362
|
+
const o = tokens[i].open[t], c = tokens[i].close[t];
|
|
363
|
+
return midBar && o > 0 && c > 0 ? (o + c) / 2 : o;
|
|
364
|
+
};
|
|
365
|
+
// выход по первой свече со сделками (не дальше maxH свечей), иначе — по расчётной цене этой свечи
|
|
366
|
+
const exitPx = (i, t) => {
|
|
367
|
+
if (!needTrade) return fillPx(i, t);
|
|
368
|
+
const lim = Math.min(T - 1, t + maxH);
|
|
369
|
+
for (let k = t; k <= lim; k++) if (tokens[i].has[k] && tokens[i].open[k] > 0) return fillPx(i, k);
|
|
370
|
+
return fillPx(i, t);
|
|
371
|
+
};
|
|
335
372
|
const borrowFee = (h, t) => h.alloc * (borrowPct / 100) * ((tsOf(t) - tsOf(h.entryT)) / 3600000 / 24 / 365);
|
|
336
373
|
const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
|
|
337
374
|
// Самообучение правил по прошлым закрытым сделкам (временный карантин «акций-убийц» с затуханием и авто-калибровка порога выхода)
|
|
@@ -341,16 +378,17 @@ async function run(runId, p, state) {
|
|
|
341
378
|
const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
|
|
342
379
|
const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
|
|
343
380
|
|
|
344
|
-
const closePos = (i, h, t, end, why) => {
|
|
345
|
-
const exitPrice =
|
|
381
|
+
const closePos = (i, h, t, end, why, forcedExitPrice = null) => {
|
|
382
|
+
const exitPrice = forcedExitPrice > 0 ? forcedExitPrice : exitPx(i, t);
|
|
346
383
|
let gross, fee, borrow = 0;
|
|
384
|
+
const c = costOf(i);
|
|
347
385
|
if (h.side === 'short') {
|
|
348
386
|
gross = h.alloc * (1 - exitPrice / h.entryPrice);
|
|
349
|
-
fee = (h.alloc - gross) *
|
|
387
|
+
fee = (h.alloc - gross) * c;
|
|
350
388
|
borrow = borrowFee(h, t);
|
|
351
389
|
} else {
|
|
352
390
|
gross = h.alloc * (exitPrice / h.entryPrice - 1);
|
|
353
|
-
fee = (h.alloc + gross) *
|
|
391
|
+
fee = (h.alloc + gross) * c;
|
|
354
392
|
}
|
|
355
393
|
equity += gross - fee - borrow;
|
|
356
394
|
trades.push(tradeRec(name, i, h, t, exitPrice, gross - fee - borrow - h.entryCost, h.entryCost + fee, borrow, end, why));
|
|
@@ -364,7 +402,7 @@ async function run(runId, p, state) {
|
|
|
364
402
|
};
|
|
365
403
|
for (let k = startIdx; k < testTs.length; k += stepBars) {
|
|
366
404
|
const t = testTs[k];
|
|
367
|
-
const tIn = t + 1, tOut = Math.min(t +
|
|
405
|
+
const tIn = Math.min(t + lag, T - 1), tOut = Math.min(t + lag + stepBars, T - 1);
|
|
368
406
|
if (autoAdapt) {
|
|
369
407
|
for (let i = 0; i < N; i++) { stockPnl[i] *= 0.99; stockCnt[i] *= 0.99; }
|
|
370
408
|
}
|
|
@@ -375,20 +413,38 @@ async function run(runId, p, state) {
|
|
|
375
413
|
let order, entries;
|
|
376
414
|
if (dir !== 'long') {
|
|
377
415
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
378
|
-
const need = effMinEdge * 2 * realCost;
|
|
416
|
+
const need = effMinEdge * 2 * realCost * needMult;
|
|
379
417
|
const exitNeed = need * curExitRatio;
|
|
380
418
|
const earlyExitNeed = need * 0.35;
|
|
381
419
|
// держим позицию, пока ожидаемое движение выше порога выхода (первые два полушага — мягкий порог, чтобы не резать сделку от шума)
|
|
382
420
|
for (const [i, h] of [...held]) {
|
|
383
421
|
const e = edge(i);
|
|
384
422
|
const barsHeld = tIn - h.entryT;
|
|
385
|
-
const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
|
|
423
|
+
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
|
|
386
424
|
if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn, false, 'Прогноз ослаб');
|
|
387
425
|
}
|
|
388
426
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
389
427
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
390
|
-
const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]);
|
|
391
|
-
const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) < -needS).sort((a, b) => pr[a] - pr[b]);
|
|
428
|
+
const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
|
|
429
|
+
const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) < -needS * symMult[i]).sort((a, b) => pr[a] - pr[b]);
|
|
430
|
+
// Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
|
|
431
|
+
// если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
|
|
432
|
+
// освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
|
|
433
|
+
const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
434
|
+
if (autoAdapt && usedNow > equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
|
|
435
|
+
const bestNewEdge = Math.max(candL.length ? edge(candL[0]) : 0, candS.length ? Math.abs(edge(candS[0])) : 0);
|
|
436
|
+
if (bestNewEdge >= need * 2.2) {
|
|
437
|
+
let weakestIdx = -1, weakestE = Infinity;
|
|
438
|
+
for (const [i, h] of held) {
|
|
439
|
+
const curSignedE = h.side === 'long' ? edge(i) : -edge(i);
|
|
440
|
+
if (curSignedE < exitNeed * 0.60 && curSignedE < weakestE && bestNewEdge > Math.max(need, curSignedE) * 2.4) {
|
|
441
|
+
weakestE = curSignedE;
|
|
442
|
+
weakestIdx = i;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (weakestIdx >= 0) closePos(weakestIdx, held.get(weakestIdx), tIn, false, 'Ротация в более сильного лидера');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
392
448
|
const nL = K - countSide('long');
|
|
393
449
|
const nS = K - countSide('short');
|
|
394
450
|
const pickedL = filterDynamicTopK(candL, edge, needL, Math.max(0, nL));
|
|
@@ -400,18 +456,18 @@ async function run(runId, p, state) {
|
|
|
400
456
|
} else if (effMinEdge > 0) {
|
|
401
457
|
// ожидаемое движение (в долях) = прогноз × обычный размах × √H; входим, только если оно окупает сделку
|
|
402
458
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
403
|
-
const need = effMinEdge * 2 * realCost;
|
|
459
|
+
const need = effMinEdge * 2 * realCost * needMult;
|
|
404
460
|
const exitNeed = need * curExitRatio;
|
|
405
461
|
const earlyExitNeed = need * 0.35;
|
|
406
462
|
for (const [i, h] of [...held]) {
|
|
407
463
|
const e = edge(i);
|
|
408
464
|
const barsHeld = tIn - h.entryT;
|
|
409
|
-
const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
|
|
465
|
+
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symMult[i];
|
|
410
466
|
if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn, false);
|
|
411
467
|
}
|
|
412
468
|
const free = K - held.size;
|
|
413
469
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
414
|
-
const rawCand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]);
|
|
470
|
+
const rawCand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL * symMult[i]).sort((a, b) => pr[b] - pr[a]);
|
|
415
471
|
const cand = filterDynamicTopK(rawCand, edge, needL, Math.max(0, free));
|
|
416
472
|
order = [...held.keys(), ...cand];
|
|
417
473
|
} else {
|
|
@@ -430,7 +486,7 @@ async function run(runId, p, state) {
|
|
|
430
486
|
}
|
|
431
487
|
const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
432
488
|
let avail = Math.max(0, equity * 0.99 - usedStart);
|
|
433
|
-
const needEdge = effMinEdge * 2 * realCost;
|
|
489
|
+
const needEdge = effMinEdge * 2 * realCost * needMult;
|
|
434
490
|
// Динамическая концентрация капитала под реально работающие позиции:
|
|
435
491
|
// если при K=5 на рынке есть всего 1–2 сильных сигнала, не замораживаем 60% денег в кэше под пустые слоты,
|
|
436
492
|
// а взвешиваем слот от фактического числа занятых + новых позиций и силы каждого сигнала (Conviction Sizing)
|
|
@@ -450,6 +506,8 @@ async function run(runId, p, state) {
|
|
|
450
506
|
for (let idx = 0; idx < newEntries.length; idx++) {
|
|
451
507
|
const [i, side, edgeAt] = newEntries[idx];
|
|
452
508
|
const effEdge = edgeWeights[idx];
|
|
509
|
+
if (side === 'short' && shortable && !shortable.has(tokens[i].symbol)) continue;
|
|
510
|
+
if (needTrade && !(tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) continue;
|
|
453
511
|
const conv = autoAdapt && needEdge > 0 && effEdge > 0 ? Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (effEdge / needEdge))) : 1;
|
|
454
512
|
const propShare = autoAdapt && remWeightSum > 0 ? avail * (effEdge / remWeightSum) : avail / Math.max(1, newEntries.length - idx);
|
|
455
513
|
remWeightSum = Math.max(0, remWeightSum - effEdge);
|
|
@@ -459,12 +517,29 @@ async function run(runId, p, state) {
|
|
|
459
517
|
const w = Math.max(0.4, Math.min(1.8, (1 / sg[i]) / avgInv));
|
|
460
518
|
alloc = Math.min(baseAlloc * w, avail / Math.max(1, newEntries.length - idx));
|
|
461
519
|
}
|
|
520
|
+
// не больше заданной доли реального оборота свечи входа
|
|
521
|
+
if (partCap > 0) alloc = Math.min(alloc, partCap * (tokens[i].value[tIn] || 0));
|
|
522
|
+
// только целые лоты
|
|
523
|
+
if (useLots) {
|
|
524
|
+
const lotRub = (lotSizeOf(tokens[i].symbol) || 1) * px(i, tIn);
|
|
525
|
+
alloc = lotRub > 0 ? Math.floor(alloc / lotRub) * lotRub : 0;
|
|
526
|
+
}
|
|
462
527
|
if (!(alloc > 1000)) continue;
|
|
463
528
|
avail = Math.max(0, avail - alloc);
|
|
464
|
-
const fee = alloc *
|
|
529
|
+
const fee = alloc * costOf(i);
|
|
465
530
|
equity -= fee;
|
|
466
|
-
held.set(i, { side, entryT: tIn, entryPrice:
|
|
531
|
+
held.set(i, { side, entryT: tIn, entryPrice: fillPx(i, tIn), alloc, entryCost: fee, expectedPct: edgeAt === null ? null : round(edgeAt * 100, 3) });
|
|
532
|
+
}
|
|
533
|
+
const usedAfter = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
534
|
+
const idleCash = Math.max(0, equity - usedAfter);
|
|
535
|
+
idleShareSum += equity > 0 ? idleCash / equity : 0;
|
|
536
|
+
idleSteps++;
|
|
537
|
+
const curStepTs = tsOf(tOut);
|
|
538
|
+
if (cashRateAnnual > 0 && curStepTs > prevStepTs && idleCash > 0) {
|
|
539
|
+
const dtYears = Math.min(4 / 365, (curStepTs - prevStepTs) / (365.25 * 86400000));
|
|
540
|
+
equity += idleCash * cashRateAnnual * dtYears;
|
|
467
541
|
}
|
|
542
|
+
prevStepTs = curStepTs;
|
|
468
543
|
let mtm = 0;
|
|
469
544
|
for (const [i, h] of held) {
|
|
470
545
|
mtm += h.side === 'short' ? h.alloc * (1 - px(i, tOut) / h.entryPrice) - borrowFee(h, tOut) : h.alloc * (px(i, tOut) / h.entryPrice - 1);
|
|
@@ -478,7 +553,7 @@ async function run(runId, p, state) {
|
|
|
478
553
|
exitRatio: curExitRatio,
|
|
479
554
|
quarantined: tokens.map((tk, i) => (!stockAllowed(i) ? tk.symbol : null)).filter(Boolean),
|
|
480
555
|
} : null;
|
|
481
|
-
return { trades, curve, adaptation };
|
|
556
|
+
return { trades, curve, adaptation, idleCashPct: round((idleShareSum / Math.max(1, idleSteps)) * 100, 1) };
|
|
482
557
|
}
|
|
483
558
|
function tradeRec(name, i, h, t, exitPrice, pnl, commission, borrow, end = false, why = '') {
|
|
484
559
|
const heldMin = (t - h.entryT) * BAR_INTERVAL; // минуты
|
|
@@ -523,8 +598,10 @@ async function run(runId, p, state) {
|
|
|
523
598
|
await tick();
|
|
524
599
|
const gross = simulate(n, 0, 0);
|
|
525
600
|
const grossReport = computeReport(gross.trades, gross.curve, p.capital, p.oosPct);
|
|
601
|
+
const liveSim = simulate(n, 0.001, p.modelShortFeePct, 0, { midBar: true, needTrade: true, partCap: 0.05, lots: true, shortable: shortableSet(), tierCost: true });
|
|
602
|
+
const liveReport = computeReport(liveSim.trades, liveSim.curve, p.capital, p.oosPct);
|
|
526
603
|
splitTs = report.splitTs;
|
|
527
|
-
results[n] = { sim, report, grossReport, gross };
|
|
604
|
+
results[n] = { sim, report, grossReport, gross, liveSim, liveReport };
|
|
528
605
|
}
|
|
529
606
|
const segRet = (curve, a, b) => {
|
|
530
607
|
const seg = curve.filter((x) => x.ts >= a && x.ts <= b);
|
|
@@ -584,6 +661,8 @@ async function run(runId, p, state) {
|
|
|
584
661
|
longPct: sidePct('long'), shortPct: sidePct('short'),
|
|
585
662
|
ic: ic.seg[k].ic, weeksPositivePct: ic.seg[k].weeksPositivePct, weeks: ic.seg[k].weeks,
|
|
586
663
|
returnPct: m?.returnPct ?? null, grossReturnPct: (results[n].grossReport[k] || results[n].grossReport.full)?.returnPct ?? null,
|
|
664
|
+
realisticReturnPct: (results[n].liveReport?.[k] || results[n].liveReport?.full)?.returnPct ?? null,
|
|
665
|
+
realisticProfitFactor: (results[n].liveReport?.[k] || results[n].liveReport?.full)?.profitFactor ?? null,
|
|
587
666
|
marketPct: round(mret, 2), vsMarketPct: m ? round(m.returnPct - mret, 2) : null,
|
|
588
667
|
trades: m?.trades ?? 0, profitFactor: m?.profitFactor ?? null, maxDrawdownPct: m?.maxDrawdownPct ?? null, winRate: m?.winRate ?? null,
|
|
589
668
|
};
|
|
@@ -596,6 +675,8 @@ async function run(runId, p, state) {
|
|
|
596
675
|
const report = results[chosen].report;
|
|
597
676
|
report.period = { from: tsOf(testTs[0]), till: tsOf(testTs[testTs.length - 1]) };
|
|
598
677
|
report.bars = testTs.length;
|
|
678
|
+
// итог стресс-теста в метриках прогона (сводка для списка экспериментов); подробности — в extra.stress
|
|
679
|
+
report.stressPending = true;
|
|
599
680
|
report.model = { chosen, horizon: H, tokens: N, folds: fi, comparison: Object.fromEntries(names.map((n) => [n, comparison[n]])) };
|
|
600
681
|
|
|
601
682
|
// «головы внимания» последнего окна обучения — что модель считает важным
|
|
@@ -606,9 +687,11 @@ async function run(runId, p, state) {
|
|
|
606
687
|
const macroHeads = (att.macroHeads || []).map((h) => ({ label: h.label, coef: round(h.coef, 4) }));
|
|
607
688
|
const neighbors = tokens.map((_, j) => [...lastFold.cos[j].keys()].filter((i) => i !== j).sort((a, b) => lastFold.cos[j][b] - lastFold.cos[j][a]).slice(0, 6).map((i) => [i, round(lastFold.cos[j][i], 3)]));
|
|
608
689
|
|
|
609
|
-
// Быстрая проверка устойчивости по сдвигам точки пересмотра и старта (на готовых прогнозах, без переобучения)
|
|
610
|
-
|
|
611
|
-
const
|
|
690
|
+
// Быстрая проверка устойчивости по сдвигам точки пересмотра и старта (на готовых прогнозах, без переобучения):
|
|
691
|
+
// dayShift2 и dayShift4 кратны stepBars, чтобы сдвиги дней проверяли устойчивость к дате начала, а фазы +10м/+20м — к внутричасовой минуте!
|
|
692
|
+
const stepBarsForStab = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
|
|
693
|
+
const bpdAligned = Math.max(stepBarsForStab, Math.round(bpd / stepBarsForStab) * stepBarsForStab);
|
|
694
|
+
const offsets = [...new Set([0, 1, 2, bpdAligned * 2, bpdAligned * 4].filter((o) => o < Math.min(testTs.length / 4, bpdAligned * 10)))];
|
|
612
695
|
const stabRuns = [];
|
|
613
696
|
for (let idx = 0; idx < offsets.length; idx++) {
|
|
614
697
|
const off = offsets[idx];
|
|
@@ -624,6 +707,10 @@ async function run(runId, p, state) {
|
|
|
624
707
|
}
|
|
625
708
|
const stability = summarizeStability(stabRuns, 'phase');
|
|
626
709
|
|
|
710
|
+
emit({ stage: 'calc', pct: 96, text: 'Финальный пересчёт · стресс-тест исполнения (издержки, задержка входа, ликвидность, лоты, шорты)…' });
|
|
711
|
+
await tick();
|
|
712
|
+
const stress = runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H });
|
|
713
|
+
|
|
627
714
|
emit({ stage: 'calc', pct: 98, text: `Финальный пересчёт · запись ${results[chosen].sim.trades.length} сделок и кривых капитала в базу…` });
|
|
628
715
|
await tick();
|
|
629
716
|
const chosenTrades = results[chosen].sim.trades;
|
|
@@ -640,6 +727,7 @@ async function run(runId, p, state) {
|
|
|
640
727
|
comparison, splitTs,
|
|
641
728
|
adaptation: results[chosen].sim.adaptation || null,
|
|
642
729
|
stability,
|
|
730
|
+
stress,
|
|
643
731
|
periods: buildPeriods({
|
|
644
732
|
model: results[chosen].sim.curve, gross: results[chosen].gross.curve, market: mcurve,
|
|
645
733
|
models: Object.fromEntries(names.map((n) => [n, results[n].sim.curve])),
|
|
@@ -650,10 +738,110 @@ async function run(runId, p, state) {
|
|
|
650
738
|
map: { snapshots, eigenvalues: lastFold.eigenvalues.map((v) => round(v, 3)) },
|
|
651
739
|
neighbors,
|
|
652
740
|
});
|
|
741
|
+
delete report.stressPending;
|
|
742
|
+
const realistic = stress.scenarios.find((s) => s.key === 'realistic');
|
|
743
|
+
report.stress = realistic ? { returnPct: realistic.full.returnPct, cagrPct: realistic.full.cagrPct, profitFactor: realistic.full.profitFactor, maxDrawdownPct: realistic.full.maxDrawdownPct, last12Pct: realistic.last12.returnPct } : null;
|
|
653
744
|
finishRun(runId, { metrics: report, equity: downsample(results[chosen].sim.curve) });
|
|
654
745
|
return report;
|
|
655
746
|
}
|
|
656
747
|
|
|
748
|
+
// ---------- стресс-тест исполнения ----------
|
|
749
|
+
// Та же стратегия и те же правила решений, но с реалистичным исполнением. Показывает, какая часть доходности
|
|
750
|
+
// бэктеста держится на допущениях (дешёвые сделки, вход по цене самого сигнала, неограниченная ликвидность, шорт всего подряд).
|
|
751
|
+
const lotSizeCache = { at: 0, map: {} };
|
|
752
|
+
function lotSizeOf(sym) {
|
|
753
|
+
if (Date.now() - lotSizeCache.at > 3600000) {
|
|
754
|
+
lotSizeCache.map = kvGet('moexSpecs')?.specs || {};
|
|
755
|
+
lotSizeCache.at = Date.now();
|
|
756
|
+
}
|
|
757
|
+
return Number(lotSizeCache.map[sym]?.lotSize) || 1;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function shortableSet() {
|
|
761
|
+
const m = kvGet('tbankShortableMap')?.map;
|
|
762
|
+
if (!m || typeof m !== 'object') return null;
|
|
763
|
+
const s = new Set(Object.entries(m).filter(([, v]) => v === true || v?.shortEnabled || v?.short === true).map(([k]) => k));
|
|
764
|
+
return s.size ? s : null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function runStressTest({ simulate, chosen, testTs, tsOf, mcurve, p, H }) {
|
|
768
|
+
const shortable = shortableSet();
|
|
769
|
+
const lastTs = tsOf(testTs[testTs.length - 1]);
|
|
770
|
+
const yearsOf = (curve) => Math.max(1 / 365, ((curve.at(-1)?.ts || 0) - (curve[0]?.ts || 0)) / (365.25 * 86400000));
|
|
771
|
+
const cagr = (ret, yrs) => (ret > -100 && yrs >= 0.25 ? round((Math.pow(1 + ret / 100, 1 / yrs) - 1) * 100, 1) : null);
|
|
772
|
+
const segment = (sim, fromTs) => {
|
|
773
|
+
const curve = sim.curve.filter((x) => x.ts >= fromTs);
|
|
774
|
+
if (curve.length < 2) return { returnPct: null, trades: 0 };
|
|
775
|
+
const m = computeReport(sim.trades.filter((t) => t.entryTs >= fromTs), curve, curve[0].equity, 0).full;
|
|
776
|
+
return { returnPct: m.returnPct, profitFactor: m.profitFactor, maxDrawdownPct: m.maxDrawdownPct, trades: m.trades };
|
|
777
|
+
};
|
|
778
|
+
const mSeg = (fromTs) => {
|
|
779
|
+
const c = mcurve.filter((x) => x.ts >= fromTs);
|
|
780
|
+
return c.length > 1 ? round((c.at(-1).equity / c[0].equity - 1) * 100, 2) : null;
|
|
781
|
+
};
|
|
782
|
+
const base = (p.commissionPct + p.slippagePct) / 100;
|
|
783
|
+
const REAL = { lag: 2, needTrade: true, partCap: 0.05, lots: true, shortable };
|
|
784
|
+
const LIVE = { midBar: true, needTrade: true, partCap: 0.05, lots: true, shortable };
|
|
785
|
+
const scenarios = [
|
|
786
|
+
{ key: 'base', label: 'Как в основном отчёте', note: `издержки ${round(base * 100, 3)}% на сторону, вход по open следующей свечи`, cost: base, ex: {} },
|
|
787
|
+
{ key: 'cost15', label: 'Издержки 0,15% на сторону', note: 'комиссия + спред + проскальзывание для второго-третьего эшелона', cost: 0.0015, ex: {} },
|
|
788
|
+
{ key: 'mid1', label: 'Вход внутри следующей свечи', note: 'средняя цена (open+close)/2 свечи t+1: заявка по живой цене исполняется в течение свечи, а не на её первой сделке', cost: base, ex: { midBar: true } },
|
|
789
|
+
{ key: 'lag2', label: 'Вход на свечу позже', note: 'open[t+2]: данные приходят с задержкой 10+ минут (ISS без живых цен брокера)', cost: base, ex: { lag: 2 } },
|
|
790
|
+
{ key: 'trades', label: 'Только свечи со сделками', note: 'без входа по «цене», которой не было (свеча без сделок = цена закрытия предыдущей)', cost: base, ex: { needTrade: true } },
|
|
791
|
+
{ key: 'liquidity', label: '≤ 5% оборота свечи и целые лоты', note: 'позиция не больше 5% реального оборота свечи входа', cost: base, ex: { partCap: 0.05, lots: true } },
|
|
792
|
+
{ key: 'shorts', label: 'Шорт только разрешённых бумаг', note: shortable ? `по текущему списку Т-Банка (${shortable.size} бумаг)` : 'список Т-Банка не загружен — проверка не выполнена', cost: base, ex: { shortable } },
|
|
793
|
+
{ key: 'liveLike', label: 'Как живой контур (внутри свечи), издержки как в отчёте', note: 'живые цены брокера: вход внутри следующей свечи + сделки, оборот, лоты, шорты', cost: base, ex: LIVE },
|
|
794
|
+
{ key: 'liveLike10', label: 'Как живой контур, издержки 0,10%', note: 'то же при издержках 0,10% на сторону (комиссия 0,05% + спред/проскальзывание 0,05%)', cost: 0.001, ex: LIVE },
|
|
795
|
+
{ key: 'liveTierLqdt', label: 'Живой контур + издержки по эшелонам + свободный кэш в LQDT (16%/год)', note: 'издержки 0,065% 1-й эшелон и 0,125% 2–3-й эшелон + начисление ставки денежного рынка на незанятые рубли (~95% времени)', cost: 0.001, ex: { ...LIVE, tierCost: true, cashRateAnnual: 0.16 } },
|
|
796
|
+
{ key: 'execOnly', label: 'Всё вместе, издержки как в отчёте', note: 'задержка, сделки, оборот, лоты и шорты — без роста издержек', cost: base, ex: REAL },
|
|
797
|
+
{ key: 'realistic', label: 'Всё вместе, издержки 0,15%', note: 'реалистичная оценка', cost: 0.0015, ex: REAL },
|
|
798
|
+
{ key: 'harsh', label: 'Всё вместе, издержки 0,25%', note: 'пессимистичная оценка', cost: 0.0025, ex: REAL },
|
|
799
|
+
];
|
|
800
|
+
const out = [];
|
|
801
|
+
for (const s of scenarios) {
|
|
802
|
+
const sim = simulate(chosen, s.cost, p.modelShortFeePct, 0, s.ex);
|
|
803
|
+
const full = computeReport(sim.trades, sim.curve, p.capital, 0).full;
|
|
804
|
+
const yrs = yearsOf(sim.curve);
|
|
805
|
+
const longPnl = sim.trades.filter((t) => t.side === 'long').reduce((a, t) => a + t.pnl, 0);
|
|
806
|
+
const shortPnl = sim.trades.filter((t) => t.side === 'short').reduce((a, t) => a + t.pnl, 0);
|
|
807
|
+
out.push({
|
|
808
|
+
key: s.key, label: s.label, note: s.note,
|
|
809
|
+
full: { returnPct: full.returnPct, cagrPct: cagr(full.returnPct, yrs), profitFactor: full.profitFactor, maxDrawdownPct: full.maxDrawdownPct, trades: full.trades, winRate: full.winRate, avgTradePct: full.avgTradePct, longPnl: round(longPnl, 0), shortPnl: round(shortPnl, 0) },
|
|
810
|
+
last12: segment(sim, lastTs - 365 * 86400000),
|
|
811
|
+
last6: segment(sim, lastTs - 182 * 86400000),
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
// Порог безубыточности: при каких издержках на сторону итог за период равен нулю (итог монотонно падает с ростом издержек)
|
|
815
|
+
const breakEven = (ex) => {
|
|
816
|
+
let lo = 0, hi = 0.01;
|
|
817
|
+
const retAt = (c) => { const s = simulate(chosen, c, p.modelShortFeePct, 0, ex); return computeReport(s.trades, s.curve, p.capital, 0).full.returnPct; };
|
|
818
|
+
if (retAt(lo) <= 0) return 0;
|
|
819
|
+
for (let k = 0; k < 9; k++) {
|
|
820
|
+
const mid = (lo + hi) / 2;
|
|
821
|
+
if (retAt(mid) > 0) lo = mid; else hi = mid;
|
|
822
|
+
}
|
|
823
|
+
return round(((lo + hi) / 2) * 100, 3);
|
|
824
|
+
};
|
|
825
|
+
// Подбор порога входа под реальные издержки: живое исполнение, издержки 0,09% на сторону (комиссия 0,04% + проскальзывание ~0,05%)
|
|
826
|
+
const thresholdSweep = [1, 1.5, 1.65, 2, 2.5, 3].map((m) => {
|
|
827
|
+
const sim = simulate(chosen, 0.0009, p.modelShortFeePct, 0, { ...LIVE, needMult: m });
|
|
828
|
+
const f = computeReport(sim.trades, sim.curve, p.capital, 0).full;
|
|
829
|
+
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 };
|
|
830
|
+
});
|
|
831
|
+
return {
|
|
832
|
+
scenarios: out,
|
|
833
|
+
thresholdSweep,
|
|
834
|
+
breakEvenCostPct: { asReport: breakEven({}), liveLike: breakEven(LIVE), realisticExecution: breakEven(REAL) },
|
|
835
|
+
market: { full: mSeg(-Infinity), last12: mSeg(lastTs - 365 * 86400000), last6: mSeg(lastTs - 182 * 86400000) },
|
|
836
|
+
shortableKnown: Boolean(shortable),
|
|
837
|
+
caveats: [
|
|
838
|
+
'Состав рынка — сегодняшний индекс широкого рынка, бумаги отобраны по покрытию данных за весь период (ошибка выжившего не устранена).',
|
|
839
|
+
'Константы стратегии подбирались на этой же истории, поэтому «последние 6 и 12 месяцев» не являются честно отложенной выборкой. Честная проверка — торговля после фиксации версии.',
|
|
840
|
+
'Список бумаг для шорта — текущий, а не на каждую дату.',
|
|
841
|
+
],
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
657
845
|
function summarizeStability(runs, mode = 'shifts') {
|
|
658
846
|
const rets = runs.map((r) => r.returnPct).sort((a, b) => a - b);
|
|
659
847
|
const med = rets.length % 2 ? rets[(rets.length - 1) >> 1] : (rets[(rets.length >> 1) - 1] + rets[rets.length >> 1]) / 2;
|
|
@@ -797,18 +985,18 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
797
985
|
const bear = isMarketDown(ds, t);
|
|
798
986
|
const mktDown = mktFilter && bear;
|
|
799
987
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
800
|
-
const need = effMinEdge * 2 * realCost, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
|
|
988
|
+
const need = effMinEdge * 2 * realCost * ENTRY_EDGE_MULT, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
|
|
801
989
|
let order, entries;
|
|
802
990
|
if (dir !== 'long') {
|
|
803
991
|
for (const [i, h] of [...held]) {
|
|
804
992
|
const e = edge(i), barsHeld = tIn - h.entryT;
|
|
805
|
-
const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
|
|
993
|
+
const req = (autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed) * symbolEdgeMult(tokens[i].symbol);
|
|
806
994
|
if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn);
|
|
807
995
|
}
|
|
808
996
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
809
997
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
810
|
-
const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]);
|
|
811
|
-
const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) < -needS).sort((a, b) => pr[a] - pr[b]);
|
|
998
|
+
const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[b] - pr[a]);
|
|
999
|
+
const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) < -needS * symbolEdgeMult(tokens[i].symbol)).sort((a, b) => pr[a] - pr[b]);
|
|
812
1000
|
const nL = K - countSide('long'), nS = K - countSide('short');
|
|
813
1001
|
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)])];
|
|
814
1002
|
} else if (effMinEdge > 0) {
|
package/server/notify/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
3
|
import https from 'node:https';
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
4
|
+
import { TRUSTED_CA } from '../tls-ca.js';
|
|
5
|
+
import { IS_INSTANCE } from '../role.js';
|
|
6
|
+
import { getIdentity } from '../instance/identity.js';
|
|
7
|
+
import { relayNotifyToCentral } from '../instance/agent.js';
|
|
8
8
|
import { db, kvGet, kvSet } from '../db.js';
|
|
9
9
|
import { encryptSecret, decryptSecret, getUserById, runWithUser, generateApiKey, hashPassword, ensureApiKey } from '../auth/index.js';
|
|
10
10
|
import { round } from '../util.js';
|
|
@@ -13,11 +13,7 @@ let actionHandlers = { execute: null, reject: null };
|
|
|
13
13
|
export function registerBrokerHandlers(h) { actionHandlers = h; }
|
|
14
14
|
|
|
15
15
|
// Надёжный HTTP-клиент для platform-api2.max.ru (поддерживает сертификат НУЦ Минцифры РФ)
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
const certPath = process.env.GIGACHAT_CA_FILE || path.resolve(ROOT, '../work/#system/certs/russian_trusted_root_ca.pem');
|
|
19
|
-
if (fs.existsSync(certPath)) maxCa = [...tls.rootCertificates, fs.readFileSync(certPath, 'utf8')];
|
|
20
|
-
} catch { /* ignore */ }
|
|
16
|
+
const maxCa = TRUSTED_CA;
|
|
21
17
|
|
|
22
18
|
function maxRequest(urlPath, { method = 'GET', token = '', body = null, timeoutMs = 12000 } = {}) {
|
|
23
19
|
return new Promise((resolve, reject) => {
|
|
@@ -29,7 +25,7 @@ function maxRequest(urlPath, { method = 'GET', token = '', body = null, timeoutM
|
|
|
29
25
|
path: u.pathname + u.search,
|
|
30
26
|
method,
|
|
31
27
|
ca: maxCa,
|
|
32
|
-
rejectUnauthorized:
|
|
28
|
+
rejectUnauthorized: true,
|
|
33
29
|
headers: {
|
|
34
30
|
Authorization: token,
|
|
35
31
|
Accept: 'application/json',
|
|
@@ -171,6 +167,14 @@ export async function sendMax(chatId, text, buttons = null) {
|
|
|
171
167
|
}
|
|
172
168
|
|
|
173
169
|
export async function notifyUser(userId, text, buttons = null) {
|
|
170
|
+
// На машине клиента ботов нет: текст и кнопки уходят в центр, а центр пишет клиенту в его Telegram / MAX.
|
|
171
|
+
// Нажатие кнопки центр возвращает машине командой внутри подписанной лицензии.
|
|
172
|
+
if (IS_INSTANCE) {
|
|
173
|
+
const termUrl = getIdentity().url;
|
|
174
|
+
const relayButtons = (buttons || []).filter((b) => b.action && b.accountId).map((b) => ({ label: b.label, action: b.action, accountId: b.accountId, batchId: b.batchId || '' }));
|
|
175
|
+
const hint = buttons?.length ? `\n\nПодтвердите кнопкой ниже или в терминале${termUrl ? `: ${termUrl}` : ' на вашем компьютере'}.` : '';
|
|
176
|
+
return relayNotifyToCentral(`${text}${hint}`, relayButtons);
|
|
177
|
+
}
|
|
174
178
|
const u = getUserById(userId);
|
|
175
179
|
if (!u) return { tg: false, max: false };
|
|
176
180
|
const [tg, max] = await Promise.all([
|
|
@@ -185,8 +189,8 @@ export async function notifyBrokerBatch(userId, acc, batch) {
|
|
|
185
189
|
const lines = batch.orders.map((o) => `• ${o.action === 'buy' ? 'Купить' : 'Продать'} ${o.symbol}: ${o.lots} л. (${o.shares} шт.) по ${o.limitPrice} ₽ ≈ ${round(o.amountRub, 0)} ₽`);
|
|
186
190
|
const text = `НейроАльфа · Счёт «${acc.name}»\nСформирован пакет лимитных заявок (свеча ${batch.bar || '—'}):\n${lines.join('\n')}`;
|
|
187
191
|
const buttons = acc.mode === 'confirm' ? [
|
|
188
|
-
{ label: `✓ Исполнить (${batch.orders.length})`, data: `exec:${userId}:${acc.id}
|
|
189
|
-
{ label: '✕ Отклонить', data: `rej:${userId}:${acc.id}
|
|
192
|
+
{ label: `✓ Исполнить (${batch.orders.length})`, data: `exec:${userId}:${acc.id}`, action: 'exec', accountId: acc.id, batchId: batch.id || '' },
|
|
193
|
+
{ label: '✕ Отклонить', data: `rej:${userId}:${acc.id}`, action: 'rej', accountId: acc.id, batchId: batch.id || '' },
|
|
190
194
|
] : null;
|
|
191
195
|
await notifyUser(userId, text, buttons);
|
|
192
196
|
}
|
|
@@ -202,10 +206,29 @@ function tryLinkByCode(text, channel, chatId) {
|
|
|
202
206
|
return u;
|
|
203
207
|
}
|
|
204
208
|
|
|
205
|
-
|
|
209
|
+
// Центр: обработчик кнопок, пришедших с машин клиентов (данные вида «ix:<одноразовый id>»)
|
|
210
|
+
let instanceActionHandler = null;
|
|
211
|
+
export function registerInstanceActionHandler(fn) { instanceActionHandler = fn; }
|
|
212
|
+
|
|
213
|
+
async function handleCallbackAction(data, replyFn, { channel, chatId } = {}) {
|
|
214
|
+
if (String(data || '').startsWith('ix:')) {
|
|
215
|
+
if (!instanceActionHandler) return;
|
|
216
|
+
try {
|
|
217
|
+
await replyFn(await instanceActionHandler(String(data).slice(3), { channel, chatId }));
|
|
218
|
+
} catch (e) {
|
|
219
|
+
await replyFn(`Ошибка: ${e.message}`);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
206
223
|
const [act, uidStr, accId] = String(data || '').split(':');
|
|
207
224
|
const u = getUserById(Number(uidStr));
|
|
208
225
|
if (!u || !accId) return;
|
|
226
|
+
// Кнопка должна прийти из того же чата, который привязан к владельцу счёта (данные кнопки можно подделать)
|
|
227
|
+
const boundChat = channel === 'max' ? u.max_chat_id : u.tg_chat_id;
|
|
228
|
+
if (!boundChat || !chatId || String(boundChat) !== String(chatId)) {
|
|
229
|
+
await replyFn('⛔ Это действие доступно только из чата, привязанного к владельцу счёта.');
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
209
232
|
try {
|
|
210
233
|
if (act === 'exec' && actionHandlers.execute) {
|
|
211
234
|
await runWithUser(u, () => actionHandlers.execute(accId));
|
|
@@ -250,7 +273,10 @@ async function pollTelegram() {
|
|
|
250
273
|
}
|
|
251
274
|
} else if (upd.callback_query?.data) {
|
|
252
275
|
const chatId = upd.callback_query.message?.chat?.id;
|
|
253
|
-
|
|
276
|
+
const fromId = upd.callback_query.from?.id;
|
|
277
|
+
// в личном чате с ботом id чата совпадает с id нажавшего пользователя
|
|
278
|
+
const verifiedChat = chatId && fromId && String(chatId) === String(fromId) ? chatId : null;
|
|
279
|
+
await handleCallbackAction(upd.callback_query.data, (msg) => sendTelegram(chatId, msg), { channel: 'tg', chatId: verifiedChat });
|
|
254
280
|
}
|
|
255
281
|
}
|
|
256
282
|
} catch { /* ignore */ }
|
|
@@ -274,11 +300,19 @@ async function processMaxUpdate(upd) {
|
|
|
274
300
|
}
|
|
275
301
|
if (upd.callback?.payload) {
|
|
276
302
|
const cbChatId = upd.chat_id || upd.message?.recipient?.chat_id || upd.callback?.user?.user_id;
|
|
277
|
-
await handleCallbackAction(upd.callback.payload, (msg) => sendMax(cbChatId, msg));
|
|
303
|
+
await handleCallbackAction(upd.callback.payload, (msg) => sendMax(cbChatId, msg), { channel: 'max', chatId: cbChatId });
|
|
278
304
|
}
|
|
279
305
|
}
|
|
280
306
|
|
|
281
|
-
|
|
307
|
+
// Вебхук MAX принимается только с секретом (заголовок X-Max-Bot-Api-Secret = MAX_WEBHOOK_SECRET).
|
|
308
|
+
// Без настроенного секрета вебхук выключен — обновления MAX забираются опросом (pollMax).
|
|
309
|
+
export async function handleMaxWebhook(body = {}, headers = {}) {
|
|
310
|
+
const secret = String(process.env.MAX_WEBHOOK_SECRET || '').trim();
|
|
311
|
+
const got = String(headers['x-max-bot-api-secret'] || '').trim();
|
|
312
|
+
const a = Buffer.from(got);
|
|
313
|
+
const b = Buffer.from(secret);
|
|
314
|
+
const okSecret = secret && a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
315
|
+
if (!okSecret) throw Object.assign(new Error('Вебхук MAX отключён или секрет неверен'), { status: 403 });
|
|
282
316
|
const list = Array.isArray(body.updates) ? body.updates : (body.update_type ? [body] : []);
|
|
283
317
|
for (const upd of list) await processMaxUpdate(upd);
|
|
284
318
|
return { ok: true };
|
|
@@ -296,6 +330,7 @@ async function pollMax() {
|
|
|
296
330
|
}
|
|
297
331
|
|
|
298
332
|
export function startNotifyPolling() {
|
|
333
|
+
if (IS_INSTANCE) return;
|
|
299
334
|
const timer = setInterval(() => {
|
|
300
335
|
pollTelegram();
|
|
301
336
|
pollMax();
|
package/server/params-meta.js
CHANGED
|
@@ -18,9 +18,9 @@ export const PARAMS = [
|
|
|
18
18
|
{ key: 'modelType', label: 'Какая стратегия торгует', hint: 'Внимание через карту.', type: 'select', options: [['attention', 'Внимание через карту'], ['leadlag', 'Кто за кем ходит'], ['inertia', 'Своя инерция']], hidden: true, fixed: true },
|
|
19
19
|
{ key: 'modelUseDividends', label: 'Учитывать дивиденды', hint: 'Цены до даты отсечки уменьшаются на размер дивиденда со сверкой по реальному падению цены.', type: 'checkbox', hidden: true, fixed: true },
|
|
20
20
|
{ key: 'capital', label: 'Стартовая сумма, ₽', hint: 'С какой суммой начинает счёт.', min: 10000, max: 1e9, hidden: true },
|
|
21
|
-
{ key: 'commissionPct', label: 'Комиссия брокера и биржи, % за сделку', hint: 'Сколько берут брокер и биржа за покупку и отдельно за продажу.', min: 0, max: 1, ai: false },
|
|
22
|
-
{ key: 'slippagePct', label: 'Проскальзывание при исполнении, %', hint: 'На сколько реальная цена сделки в стакане обычно хуже ожидаемой.', min: 0, max: 1, ai: false },
|
|
23
|
-
{ key: 'modelShortFeePct', label: 'Плата брокеру за заём акций (шорт), % годовых', hint: 'Ставка брокера за удержание коротких позиций (начисляется пропорционально времени удержания сделки).', min: 0, max: 100 },
|
|
21
|
+
{ key: 'commissionPct', label: 'Комиссия брокера и биржи, % за сделку', hint: 'Сколько берут брокер и биржа за покупку и отдельно за продажу.', min: 0, max: 1, ai: false, hidden: true, fixed: true },
|
|
22
|
+
{ key: 'slippagePct', label: 'Проскальзывание при исполнении, %', hint: 'На сколько реальная цена сделки в стакане обычно хуже ожидаемой.', min: 0, max: 1, ai: false, hidden: true, fixed: true },
|
|
23
|
+
{ key: 'modelShortFeePct', label: 'Плата брокеру за заём акций (шорт), % годовых', hint: 'Ставка брокера за удержание коротких позиций (начисляется пропорционально времени удержания сделки).', min: 0, max: 100, hidden: true, fixed: true },
|
|
24
24
|
{ key: 'autopilotBudget', label: 'Лимит исследований автопилота за задание', hint: 'Сколько экспериментов Стратег может провести подряд за одно задание.', min: 1, max: 30, step: 1, int: true, ai: false, hidden: true },
|
|
25
25
|
{ key: 'paperDailyReview', label: 'ИИ разбирает каждый торговый день', hint: 'После окончания торгового дня Стратег сам разбирает сделки и пишет сводку в чат.', type: 'checkbox', hidden: true },
|
|
26
26
|
{ key: 'newsEnabled', label: 'Следить за новостями', hint: 'Раз в 10 минут собирает новости и раскрытия компаний.', type: 'checkbox', hidden: true, fixed: true },
|