neuroalpha 0.3.17 → 0.5.2

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.
Files changed (39) hide show
  1. package/config/certs/russian_trusted_root_ca.pem +33 -0
  2. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -4
  4. 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
  5. package/package.json +60 -53
  6. package/public/activate.html +36 -0
  7. package/public/activate.js +33 -0
  8. package/public/admin.html +126 -7
  9. package/public/app.js +589 -66
  10. package/public/index.html +48 -21
  11. package/public/landing.html +106 -37
  12. package/public/legal.html +4 -4
  13. package/public/styles.css +152 -36
  14. package/server/api/routes.js +308 -37
  15. package/server/auth/index.js +79 -15
  16. package/server/brokers/index.js +1095 -168
  17. package/server/build-info.js +7 -0
  18. package/server/config.js +13 -6
  19. package/server/data/moex.js +6 -4
  20. package/server/db.js +22 -18
  21. package/server/desktop-entry.js +103 -0
  22. package/server/hub.js +28 -14
  23. package/server/index.js +82 -12
  24. package/server/instance/agent.js +259 -0
  25. package/server/instance/central.js +491 -0
  26. package/server/instance/identity.js +79 -0
  27. package/server/instance/provision.js +166 -0
  28. package/server/instance/signing.js +71 -0
  29. package/server/model/edge.js +19 -0
  30. package/server/model/forecast.js +107 -57
  31. package/server/model/live.js +1 -1
  32. package/server/model/models.js +12 -3
  33. package/server/model/research.js +218 -30
  34. package/server/notify/index.js +51 -16
  35. package/server/role.js +44 -0
  36. package/server/security.js +40 -0
  37. package/server/strategist/index.js +86 -47
  38. package/server/strategist/tools.js +231 -42
  39. package/server/tls-ca.js +32 -0
@@ -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 researchFrom = todayStartMsk - p.modelHistoryDays * DAY;
156
- const fromTs = todayStartMsk - 1650 * DAY;
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
- ...(fi === 0 ? { symbols: tokens.map((tk) => tk.symbol) } : {}),
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
- function simulate(name, cost, borrowPct = p.modelShortFeePct, startIdx = 0) {
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]) + 1;
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 = px(i, t);
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) * cost;
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) * cost;
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 + 1 + stepBars, T - 1);
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 * cost;
529
+ const fee = alloc * costOf(i);
465
530
  equity -= fee;
466
- held.set(i, { side, entryT: tIn, entryPrice: px(i, tIn), alloc, entryCost: fee, expectedPct: edgeAt === null ? null : round(edgeAt * 100, 3) });
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
- const bpdInt = Math.max(1, Math.round(bpd));
611
- const offsets = [...new Set([0, Math.max(1, Math.floor(H / 3)), Math.max(2, Math.floor((2 * H) / 3)), bpdInt * 2, bpdInt * 4].filter((o) => o < Math.min(testTs.length / 4, bpdInt * 10)))];
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) {
@@ -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 fs from 'node:fs';
5
- import path from 'node:path';
6
- import tls from 'node:tls';
7
- import { ROOT } from '../config.js';
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
- let maxCa = undefined;
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: Boolean(maxCa),
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
- async function handleCallbackAction(data, replyFn) {
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
- await handleCallbackAction(upd.callback_query.data, (msg) => sendTelegram(chatId, msg));
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
- export async function handleMaxWebhook(body = {}) {
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/role.js ADDED
@@ -0,0 +1,44 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Режим работы сервера:
3
+ // central — центральный сервер платформы: лендинг, кабинеты, оплата, админка, ИИ-прокси, реестр машин клиентов и выпуск лицензий
4
+ // instance — машина клиента с лицензией центра. Вид машины (INSTANCE_KIND):
5
+ // cloud — арендованная машина в Kubernetes (поддомен naXXXXXX.odant.org), вход через центр
6
+ // desktop — установка на компьютер клиента (VIP по договору), активация кодом из кабинета
7
+ // dev — локальный запуск разработчика/владельца (прежнее поведение)
8
+ import './config.js';
9
+ import { BUILD } from './build-info.js';
10
+
11
+ // Защищённая сборка для компьютера клиента — всегда instance/desktop, без возможности переопределения
12
+ const raw = BUILD ? 'instance' : String(process.env.NEUROALPHA_ROLE || (process.env.CLOUD_MODE === '1' ? 'central' : 'dev')).trim().toLowerCase();
13
+ export const ROLE = ['central', 'instance', 'dev'].includes(raw) ? raw : 'dev';
14
+ export const IS_CENTRAL = ROLE === 'central';
15
+ export const IS_INSTANCE = ROLE === 'instance';
16
+ export const IS_DEV = ROLE === 'dev';
17
+ export const INSTANCE_KIND = BUILD ? 'desktop' : (IS_INSTANCE && String(process.env.INSTANCE_KIND || 'cloud').trim().toLowerCase() === 'desktop' ? 'desktop' : 'cloud');
18
+ export const IS_DESKTOP = IS_INSTANCE && INSTANCE_KIND === 'desktop';
19
+
20
+ if (IS_CENTRAL) process.env.CLOUD_MODE = '1';
21
+ if (BUILD) {
22
+ delete process.env.CLOUD_MODE;
23
+ // провайдер ИИ «НейроАльфа Облако» и проверка лицензии ходят только в вшитый центр
24
+ process.env.CLOUD_URL = BUILD.centralUrl;
25
+ }
26
+
27
+ export const INSTANCE_DOMAIN = String(process.env.INSTANCE_DOMAIN || 'odant.org').trim().replace(/^\.+/, '');
28
+ export const CENTRAL_URL = (BUILD ? BUILD.centralUrl : String(process.env.CENTRAL_URL || process.env.CLOUD_URL || 'https://neuroalpha.odant.org')).trim().replace(/\/+$/, '');
29
+
30
+ // Адрес прослушивания: установка на компьютер по умолчанию доступна только с этого компьютера
31
+ export const LISTEN_HOST = String(process.env.HOST || (IS_DESKTOP ? '127.0.0.1' : '')).trim() || undefined;
32
+
33
+ // IP клиента: за ingress/прокси (central, cloud-машина или TRUST_PROXY=1) берётся последний адрес из X-Forwarded-For,
34
+ // который добавил наш прокси; первые значения цепочки клиент может подделать
35
+ export function clientIp(req) {
36
+ const behindProxy = IS_CENTRAL || (IS_INSTANCE && !IS_DESKTOP) || process.env.TRUST_PROXY === '1';
37
+ const xff = String(req?.headers?.['x-forwarded-for'] || '').split(',').map((s) => s.trim()).filter(Boolean);
38
+ if (behindProxy && xff.length) return xff[xff.length - 1];
39
+ return String(req?.socket?.remoteAddress || '');
40
+ }
41
+
42
+ export function isHttpsRequest(req) {
43
+ return Boolean(req?.secure || String(req?.headers?.['x-forwarded-proto'] || '').split(',')[0].trim() === 'https');
44
+ }