neuroalpha 0.3.12 → 0.3.13

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 (41) hide show
  1. package/LICENSE +13 -17
  2. package/README.md +4 -2
  3. package/bin/cli.js +1 -1
  4. package/deploy/install.sh +1 -1
  5. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -1
  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 +1 -1
  7. package/package.json +2 -2
  8. package/public/admin.html +43 -20
  9. package/public/app.js +524 -211
  10. package/public/index.html +43 -3
  11. package/public/landing.html +328 -115
  12. package/public/legal.html +37 -16
  13. package/public/styles.css +24 -3
  14. package/server/api/routes.js +61 -29
  15. package/server/auth/index.js +109 -20
  16. package/server/billing/yookassa.js +1 -1
  17. package/server/brokers/index.js +179 -34
  18. package/server/config.js +1 -1
  19. package/server/data/moex.js +1 -1
  20. package/server/db.js +17 -3
  21. package/server/hub.js +1 -1
  22. package/server/index.js +4 -4
  23. package/server/llm/client.js +1 -1
  24. package/server/llm/providers.js +1 -1
  25. package/server/model/dataset.js +1 -1
  26. package/server/model/forecast.js +188 -17
  27. package/server/model/linalg.js +1 -1
  28. package/server/model/live.js +6 -19
  29. package/server/model/metrics.js +1 -1
  30. package/server/model/models.js +1 -1
  31. package/server/model/profiles.js +1 -1
  32. package/server/model/research.js +12 -36
  33. package/server/model/vocab.js +1 -1
  34. package/server/news/index.js +1 -1
  35. package/server/notify/index.js +1 -1
  36. package/server/params-meta.js +4 -3
  37. package/server/settings.js +1 -1
  38. package/server/strategist/analytics.js +2 -2
  39. package/server/strategist/index.js +2 -2
  40. package/server/strategist/tools.js +1 -1
  41. package/server/util.js +1 -1
@@ -1,8 +1,8 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
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
4
  import { db, kvGet, kvSet, userKvGet, userKvSet, getRun } from '../db.js';
5
- import { encryptSecret, decryptSecret, currentUserId, runWithUser } from '../auth/index.js';
5
+ import { encryptSecret, decryptSecret, currentUser, currentUserId, effectivePlan, runWithUser } from '../auth/index.js';
6
6
  import { notifyBrokerBatch, registerBrokerHandlers } from '../notify/index.js';
7
7
  import { getSettings, normalizeParams } from '../settings.js';
8
8
  import { latestBarTs } from '../data/moex.js';
@@ -112,8 +112,17 @@ export function brokersState() {
112
112
 
113
113
  export function saveBrokerAccount(input = {}) {
114
114
  const list = loadRawAccounts();
115
+ const plan = effectivePlan(currentUser());
115
116
  const id = input.id && String(input.id).trim() ? String(input.id).trim() : `brk_${crypto.randomBytes(4).toString('hex')}`;
116
117
  const idx = list.findIndex((x) => x.id === id);
118
+ if (idx < 0) {
119
+ if ((plan.maxBrokerAccounts ?? 0) < 1) {
120
+ throw new Error(`На тарифе «${plan.name}» подключение реальных брокерских счетов недоступно (только виртуальный демо-счёт). Перейдите на тариф «Инвестор», «Профи» или «VIP».`);
121
+ }
122
+ if (list.length >= plan.maxBrokerAccounts) {
123
+ throw new Error(`Достигнут лимит брокерских счетов по тарифу «${plan.name}» (максимум ${plan.maxBrokerAccounts}). Для подключения большего числа счетов перейдите на тариф «Профи» (до 5 счетов) или «VIP» (без ограничений).`);
124
+ }
125
+ }
117
126
  const prev = idx >= 0 ? list[idx] : {};
118
127
  const broker = BROKER_META.some((b) => b.id === input.broker) ? input.broker : (prev.broker || 'tbank');
119
128
  const name = String(input.name ?? prev.name ?? '').trim() || BROKER_META.find((b) => b.id === broker).name;
@@ -124,6 +133,9 @@ export function saveBrokerAccount(input = {}) {
124
133
  else if (typeof input.token === 'string' && input.token.trim()) token = input.token.trim();
125
134
 
126
135
  const acctChanged = input.accountId !== undefined && String(input.accountId).trim() !== String(prev.accountId || '').trim();
136
+ const rawMaxCap = Math.max(10000, Math.round(Number(input.maxCapitalRub ?? prev.maxCapitalRub ?? 100000)));
137
+ const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
138
+ const maxCapitalRub = Math.min(rawMaxCap, capCeiling);
127
139
  const next = {
128
140
  id,
129
141
  name,
@@ -138,7 +150,7 @@ export function saveBrokerAccount(input = {}) {
138
150
  lastHistory: acctChanged ? null : (prev.lastHistory || null),
139
151
  baseRunId: input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null),
140
152
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
141
- maxCapitalRub: Math.max(10000, Math.round(Number(input.maxCapitalRub ?? prev.maxCapitalRub ?? 100000))),
153
+ maxCapitalRub,
142
154
  maxDailyDrawdownPct: Math.max(0.5, Math.min(50, Number(input.maxDailyDrawdownPct ?? prev.maxDailyDrawdownPct ?? 3))),
143
155
  limitOffsetPct: Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.1))),
144
156
  skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
@@ -252,7 +264,7 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
252
264
  dirty = true;
253
265
  }
254
266
  }
255
- const missing = [...new Set(keys.filter((k) => k && !cache[k]))].slice(0, 25);
267
+ const missing = [...new Set(keys.filter((k) => k && !cache[k]))].slice(0, 60);
256
268
  await Promise.all(missing.map(async (id) => {
257
269
  try {
258
270
  const idType = id.includes('-') ? 'INSTRUMENT_ID_TYPE_UID' : 'INSTRUMENT_ID_TYPE_FIGI';
@@ -318,7 +330,7 @@ async function syncTbank(acc) {
318
330
  }
319
331
  if (!accountId) throw new Error('В Т-Банке не найдено открытых счетов (проверьте токен API)');
320
332
 
321
- const [rPort, rOrd, rMargin] = await Promise.all([
333
+ const [rPort, rOrd, rMargin, rRecentOps] = await Promise.all([
322
334
  brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetPortfolio`, {
323
335
  method: 'POST', headers, body: JSON.stringify({ accountId, currency: 'RUB' }), timeoutMs: 15000,
324
336
  }),
@@ -328,8 +340,31 @@ async function syncTbank(acc) {
328
340
  brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.UsersService/GetMarginAttributes`, {
329
341
  method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
330
342
  }).catch(() => null),
343
+ brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
344
+ method: 'POST',
345
+ headers,
346
+ body: JSON.stringify({
347
+ accountId,
348
+ from: new Date(Date.now() - 35 * DAY).toISOString(),
349
+ to: new Date().toISOString(),
350
+ state: 'OPERATION_STATE_EXECUTED',
351
+ }),
352
+ timeoutMs: 10000,
353
+ }).catch(() => null),
331
354
  ]);
332
355
  if (!rPort.ok) throw new Error(`T-Invest GetPortfolio: HTTP ${rPort.status}`);
356
+ let autoFollowDetected = /автослед|сигнал|copy|follow|comon|strategy/i.test(accountName);
357
+ if (rRecentOps?.ok) {
358
+ const jOps = await rRecentOps.json().catch(() => ({}));
359
+ for (const op of jOps.operations || []) {
360
+ const ot = String(op.operationType || '');
361
+ const tp = String(op.type || '');
362
+ if (ot.includes('TRACK_MFEE') || ot.includes('TRACK_PFEE') || ot.includes('ADVICE_FEE') || /автослед/i.test(tp)) {
363
+ autoFollowDetected = true;
364
+ break;
365
+ }
366
+ }
367
+ }
333
368
  let marginEnabled = false;
334
369
  if (accountType === 'ACCOUNT_TYPE_TINKOFF_IIS' || accountType === 'ACCOUNT_TYPE_INVEST_BOX') {
335
370
  marginEnabled = false;
@@ -424,6 +459,7 @@ async function syncTbank(acc) {
424
459
  accountName,
425
460
  accountType,
426
461
  marginEnabled,
462
+ autoFollowDetected,
427
463
  totalRub: totalRub || cashRub,
428
464
  cashRub: round(cashRub, 2),
429
465
  totalPnlRub: round(totalPnlRub, 2),
@@ -464,8 +500,9 @@ export async function discoverBrokerAccounts(input = {}) {
464
500
  };
465
501
  const out = await Promise.all(rawAccs.map(async (a) => {
466
502
  let totalRub = null, cashRub = null, positionsCount = 0, marginEnabled = false;
503
+ let autoFollowDetected = /автослед|сигнал|copy|follow|comon|strategy/i.test(a.name || '');
467
504
  try {
468
- const [rp, rm] = await Promise.all([
505
+ const [rp, rm, rops] = await Promise.all([
469
506
  brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetPortfolio`, {
470
507
  method: 'POST', headers, body: JSON.stringify({ accountId: a.id, currency: 'RUB' }), timeoutMs: 10000,
471
508
  }),
@@ -474,8 +511,30 @@ export async function discoverBrokerAccounts(input = {}) {
474
511
  method: 'POST', headers, body: JSON.stringify({ accountId: a.id }), timeoutMs: 8000,
475
512
  }).catch(() => null)
476
513
  : Promise.resolve(null),
514
+ brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
515
+ method: 'POST',
516
+ headers,
517
+ body: JSON.stringify({
518
+ accountId: a.id,
519
+ from: new Date(Date.now() - 35 * DAY).toISOString(),
520
+ to: new Date().toISOString(),
521
+ state: 'OPERATION_STATE_EXECUTED',
522
+ }),
523
+ timeoutMs: 8000,
524
+ }).catch(() => null),
477
525
  ]);
478
526
  if (rm?.ok) marginEnabled = true;
527
+ if (rops?.ok) {
528
+ const jOps = await rops.json().catch(() => ({}));
529
+ for (const op of jOps.operations || []) {
530
+ const ot = String(op.operationType || '');
531
+ const tp = String(op.type || '');
532
+ if (ot.includes('TRACK_MFEE') || ot.includes('TRACK_PFEE') || ot.includes('ADVICE_FEE') || /автослед/i.test(tp)) {
533
+ autoFollowDetected = true;
534
+ break;
535
+ }
536
+ }
537
+ }
479
538
  if (rp.ok) {
480
539
  const pj = await rp.json();
481
540
  totalRub = round(qVal(pj.totalAmountPortfolio), 0);
@@ -495,6 +554,7 @@ export async function discoverBrokerAccounts(input = {}) {
495
554
  typeLabel: TYPE_MAP[a.type] || 'Счёт',
496
555
  accessLabel: ACCESS_MAP[a.accessLevel] || '',
497
556
  marginEnabled,
557
+ autoFollowDetected,
498
558
  totalRub,
499
559
  cashRub,
500
560
  positionsCount,
@@ -613,7 +673,9 @@ export async function syncBrokerAccount(id) {
613
673
  const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
614
674
  const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
615
675
  const ddPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
616
- if (ddPct >= acc.maxDailyDrawdownPct) {
676
+ if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
677
+ acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
678
+ } else if (ddPct >= acc.maxDailyDrawdownPct) {
617
679
  acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
618
680
  }
619
681
  // История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
@@ -637,13 +699,22 @@ export async function syncBrokerAccount(id) {
637
699
  return st;
638
700
  }
639
701
 
640
- // Зафиксировать текущие позиции счёта как «неприкосновенный стартовый портфель» (baselineQty)
702
+ // Переключатель (тогл) защиты текущих бумаг на счёте:
703
+ // включает режим «Изолированный карман» (isolate) с фиксацией текущего снимка baselineQty,
704
+ // либо при повторном нажатии переключает обратно в «Плавный переход» (smooth)
641
705
  export async function freezeBrokerBaseline(id) {
642
706
  const list = loadRawAccounts();
643
707
  const acc = list.find((x) => x.id === id);
644
708
  if (!acc) throw new Error('Счёт не найден');
645
709
  const positions = acc.lastSync?.positions || [];
646
- acc.baselineQty = Object.fromEntries(positions.filter((p) => p.qty !== 0).map((p) => [p.symbol, p.qty]));
710
+ const hasBaseline = acc.baselineQty && Object.keys(acc.baselineQty).length > 0;
711
+ const isProtectedNow = (acc.legacyMode || 'isolate') === 'isolate' && hasBaseline;
712
+ if (isProtectedNow) {
713
+ acc.legacyMode = 'smooth';
714
+ } else {
715
+ acc.legacyMode = 'isolate';
716
+ acc.baselineQty = Object.fromEntries(positions.filter((p) => p.qty !== 0).map((p) => [p.symbol, p.qty]));
717
+ }
647
718
  saveRawAccounts(list);
648
719
  const st = brokersState();
649
720
  hub.emit({ type: 'brokers:update', state: st });
@@ -669,27 +740,58 @@ export async function prepareBrokerOrders(id) {
669
740
  if (acc.haltedReason) throw new Error(acc.haltedReason);
670
741
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
671
742
 
743
+ const plan = effectivePlan(currentUser());
744
+ if ((plan.maxBrokerAccounts ?? 0) < 1) {
745
+ throw new Error(`На бесплатном тарифе «${plan.name}» работа с реальными брокерскими счетами недоступна (доступно тестирование в Экспериментах и виртуальный счёт). Оформите тариф «Инвестор», «Профи» или «VIP».`);
746
+ }
672
747
  const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
673
748
  const params = normalizeParams(baseRun?.params || getSettings());
674
749
  const [fc, specs] = await Promise.all([
675
750
  forecastNow(params),
676
751
  ensureBoardSpecs(),
677
752
  ]);
678
- const budget = Math.min(acc.maxCapitalRub, acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : acc.maxCapitalRub);
753
+ const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
754
+ const effectiveMaxCap = Math.min(acc.maxCapitalRub, capCeiling);
755
+ const budget = Math.min(effectiveMaxCap, acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : effectiveMaxCap);
679
756
  const K = params.modelTopK || 3;
680
- // Автоматическая подстройка направления торговли по реальным данным счёта:
681
- // если на брокерском счёте отключена маржинальная торговля (или это ИИС), короткие продажи (шорты) автоматически отключаются
757
+ // Автоматическая подстройка направления и мощности торговли под тариф и реальные данные счёта:
682
758
  const marginAllowed = acc.lastSync?.marginEnabled !== false;
759
+ const planAllowsShorts = Boolean(plan.allowShorts);
760
+ const vipBoost = Boolean(plan.allowVipBoost);
683
761
  const rawDir = params.modelDirection || 'long';
684
- const dir = marginAllowed ? rawDir : 'long';
762
+ const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
685
763
  const autoAdapt = params.modelAutoAdapt !== false;
686
764
  const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
687
765
  const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
688
766
  const slots = dir === 'long' ? K : 2 * K;
689
- const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : (dir === 'neutral' ? Math.min(0.25, 1 / (2 * K)) : Math.min(0.35, 1.4 / (2 * K)));
690
- const slotRub = autoAdapt ? budget * maxSlotWeight : budget / slots;
767
+ // Фильтр мощности слота по тарифу:
768
+ // • Инвестор (до 50% годовых): только покупки (без шортов), консервативный слот 1 / (K + 1) (до 22% капитала)
769
+ // • Профи (до 100% годовых): покупки + шорты, стандартный равный слот 1 / (2K) (до 25% капитала, шаг раз в полный горизонт H)
770
+ // • VIP (без ограничений): полный форсаж (Conviction Slot Sizing до 42% капитала в лучших сигналах + полушаговый перехват H/2)
771
+ let slotRub;
772
+ if (vipBoost && autoAdapt) {
773
+ const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
774
+ slotRub = budget * maxSlotWeight;
775
+ } else if (plan.id === 'pro') {
776
+ slotRub = budget * Math.min(0.25, 1 / Math.max(2, slots));
777
+ } else {
778
+ slotRub = budget * Math.min(0.22, 1 / Math.max(3, K + 1));
779
+ }
780
+ // Дополнительный мягкий дроссель при опережении годового потолка доходности тарифа (50% для Инвестора, 100% для Профи)
781
+ if (!vipBoost && plan.maxAnnualReturnPct > 0 && Array.isArray(acc.equityHistory) && acc.equityHistory.length >= 2) {
782
+ const firstPt = acc.equityHistory[0];
783
+ const lastPt = acc.equityHistory[acc.equityHistory.length - 1];
784
+ const elapsedDays = Math.max(7, (lastPt.ts - firstPt.ts) / DAY);
785
+ if (firstPt.totalRub > 0 && lastPt.totalRub > firstPt.totalRub) {
786
+ const curGainPct = ((lastPt.totalRub - firstPt.totalRub) / firstPt.totalRub) * 100;
787
+ const targetGainPct = plan.maxAnnualReturnPct * (elapsedDays / 365);
788
+ if (curGainPct > targetGainPct) {
789
+ slotRub *= Math.max(0.25, targetGainPct / curGainPct);
790
+ }
791
+ }
792
+ }
691
793
  const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
692
- const convOf = (r) => (autoAdapt && r.expectedPct ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (Math.abs(r.expectedPct) / needPct))) : 1);
794
+ const convOf = (r) => (vipBoost && autoAdapt && r.expectedPct ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (Math.abs(r.expectedPct) / needPct))) : 1);
693
795
  const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
694
796
  const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
695
797
  const quarantined = new Set(baseRun?.extra?.adaptation?.quarantined || []);
@@ -697,7 +799,7 @@ export async function prepareBrokerOrders(id) {
697
799
 
698
800
  const buyCands = fc.rows.filter((r) => r.signal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad).sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
699
801
  const rawShortCands = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct).slice(0, K) : [];
700
- const shortCands = marginAllowed ? rawShortCands : [];
802
+ const shortCands = (marginAllowed && planAllowsShorts) ? rawShortCands : [];
701
803
 
702
804
  const legacyMode = acc.legacyMode || 'isolate';
703
805
  const baseline = acc.baselineQty || {};
@@ -836,11 +938,21 @@ export async function prepareBrokerOrders(id) {
836
938
  }
837
939
 
838
940
  let diagNote = '';
941
+ let planThrottleNote = '';
942
+ if (plan.id === 'investor') {
943
+ const shInfo = rawShortCands.length ? ` (пропущены сигналы на шорт: ${rawShortCands.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
944
+ planThrottleNote = `Тариф «Инвестор» (целевой потолок до 50% годовых, лимит до 1 млн ₽/счёт): активен режим «Только в рост (без шорта)» и консервативный слот 22%${shInfo}. Для включения заработка на падении акций (шорты, до 100% годовых и до 10 млн ₽/счёт) перейдите на тариф «Профи».`;
945
+ } else if (plan.id === 'pro') {
946
+ planThrottleNote = 'Тариф «Профи» (целевой потолок до 100% годовых, лимит до 10 млн ₽/счёт): активны покупки и шорты со стандартным шагом H и базовым слотом 25%. Для снятия всех ограничений (внутричасовой перехват H/2 + концентрация слота Conviction Sizing до 42%, как в максимальных экспериментах) подключите тариф «VIP».';
947
+ }
839
948
  if (!orders.length) {
840
949
  if ((newBuys.length || newShorts.length) && availRub < 1500) {
841
950
  const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
842
951
  const cashNow = round(acc.lastSync?.cashRub || 0, 0);
843
952
  diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
953
+ } else if (!newBuys.length && !planAllowsShorts && rawShortCands.length > 0) {
954
+ const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
955
+ diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 50% годовых). Чтобы зарабатывать и на падении акций (до 100% годовых), перейдите на тариф «Профи» или «VIP».`;
844
956
  } else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
845
957
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
846
958
  diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
@@ -857,6 +969,8 @@ export async function prepareBrokerOrders(id) {
857
969
  availRub: round(availRub, 0),
858
970
  effectiveDirection: dir,
859
971
  marginAllowed,
972
+ planId: plan.id,
973
+ planThrottleNote,
860
974
  edgeWarning: edgeWarn,
861
975
  diagNote,
862
976
  topSignals: [...buyCands.slice(0, 3).map((r) => ({ symbol: r.symbol, side: 'buy', expectedPct: r.expectedPct })), ...shortCands.slice(0, 3).map((r) => ({ symbol: r.symbol, side: 'short', expectedPct: r.expectedPct }))],
@@ -1060,7 +1174,7 @@ export function rejectBrokerOrders(id) {
1060
1174
  return st;
1061
1175
  }
1062
1176
 
1063
- // ---------- история операций брокерского счёта (покупки, продажи, комиссии, дивиденды/купоны, пополнения) ----------
1177
+ // ---------- история операций брокерского счёта (покупки, продажи, комиссии, дивиденды/купоны, пополнения за все годы) ----------
1064
1178
  export async function fetchBrokerHistory(id) {
1065
1179
  const list = loadRawAccounts();
1066
1180
  const acc = list.find((x) => x.id === id);
@@ -1073,18 +1187,48 @@ export async function fetchBrokerHistory(id) {
1073
1187
  const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
1074
1188
  const accountId = acc.accountId || acc.lastSync?.accountId;
1075
1189
  if (!accountId) throw new Error('Сначала нажмите «Обновить портфель», чтобы определить ID счёта');
1076
- const fromIso = new Date(Date.now() - 365 * 5 * DAY).toISOString();
1077
- const toIso = new Date().toISOString();
1078
- const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
1079
- method: 'POST',
1080
- headers,
1081
- body: JSON.stringify({ accountId, from: fromIso, to: toIso, state: 'OPERATION_STATE_EXECUTED' }),
1082
- timeoutMs: 20000,
1083
- });
1084
- if (!r.ok) throw new Error(`T-Invest GetOperations: HTTP ${r.status}`);
1085
- const j = await r.json();
1086
- const rawOps = j.operations || [];
1087
- const figis = [...new Set(rawOps.slice(-120).map((o) => o.figi || o.instrumentUid).filter(Boolean))];
1190
+
1191
+ // Запрашиваем историю полугодовыми интервалами за последние 5 лет, чтобы не терять операции из-за лимита 1000 записей на один запрос Т-Банка
1192
+ const curYear = new Date(Date.now() + MSK).getUTCFullYear();
1193
+ const intervals = [];
1194
+ for (let y = curYear; y >= curYear - 4; y--) {
1195
+ const startH2 = Date.UTC(y, 6, 1);
1196
+ const endH2 = Math.min(Date.now(), Date.UTC(y, 11, 31, 23, 59, 59));
1197
+ if (startH2 < Date.now()) intervals.push([new Date(startH2).toISOString(), new Date(endH2).toISOString()]);
1198
+ const startH1 = Date.UTC(y, 0, 1);
1199
+ const endH1 = Math.min(Date.now(), Date.UTC(y, 5, 30, 23, 59, 59));
1200
+ if (startH1 < Date.now()) intervals.push([new Date(startH1).toISOString(), new Date(endH1).toISOString()]);
1201
+ }
1202
+
1203
+ const chunkResults = await Promise.all(intervals.map(async ([fromIso, toIso]) => {
1204
+ try {
1205
+ const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
1206
+ method: 'POST',
1207
+ headers,
1208
+ body: JSON.stringify({ accountId, from: fromIso, to: toIso, state: 'OPERATION_STATE_EXECUTED' }),
1209
+ timeoutMs: 20000,
1210
+ });
1211
+ if (!r.ok) return [];
1212
+ const j = await r.json();
1213
+ return j.operations || [];
1214
+ } catch {
1215
+ return [];
1216
+ }
1217
+ }));
1218
+
1219
+ const seenIds = new Set();
1220
+ const rawOps = [];
1221
+ for (const arr of chunkResults) {
1222
+ for (const op of arr) {
1223
+ const key = op.id || `${op.date}_${op.operationType}_${op.figi}_${op.payment?.units}`;
1224
+ if (seenIds.has(key)) continue;
1225
+ seenIds.add(key);
1226
+ rawOps.push(op);
1227
+ }
1228
+ }
1229
+ rawOps.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
1230
+
1231
+ const figis = [...new Set(rawOps.map((o) => o.figi || o.instrumentUid).filter(Boolean))];
1088
1232
  const metaMap = await resolveTbankMetaMap(base, headers, figis);
1089
1233
 
1090
1234
  let tradesCount = 0, buySumRub = 0, sellSumRub = 0, commissionsRub = 0, dividendsRub = 0, inOutRub = 0;
@@ -1110,9 +1254,7 @@ export async function fetchBrokerHistory(id) {
1110
1254
  return ['other', 'Операция'];
1111
1255
  };
1112
1256
 
1113
- // Последние 80 операций в обратном хронологическом порядке (свежие сверху)
1114
- const recentRaw = [...rawOps].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))).slice(0, 80);
1115
- const operations = recentRaw.map((op) => {
1257
+ const operations = rawOps.slice(0, 5000).map((op) => {
1116
1258
  const m = metaMap[op.figi] || metaMap[op.instrumentUid] || {};
1117
1259
  const [kind, kindLabel] = OP_KIND(op.operationType);
1118
1260
  return {
@@ -1171,6 +1313,8 @@ async function tickBrokerSchedule() {
1171
1313
  await runWithUser(u, async () => {
1172
1314
  const list = loadRawAccounts();
1173
1315
  if (!list.length) return;
1316
+ const plan = effectivePlan(u);
1317
+ if ((plan.maxBrokerAccounts ?? 0) < 1) return;
1174
1318
  for (const acc of list) {
1175
1319
  if (acc.autoSchedule === false || acc.haltedReason) continue;
1176
1320
  const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
@@ -1178,7 +1322,8 @@ async function tickBrokerSchedule() {
1178
1322
  const autoAdapt = params.modelAutoAdapt !== false;
1179
1323
  const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
1180
1324
  const H = barsFromHours(effHorizon);
1181
- const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
1325
+ // Полушаговое сопровождение H/2 (каждые 1 ч 15 мин) активно в режиме VIP (полный форсаж); на тарифах Инвестор и Профи шаг равен полному горизонту H
1326
+ const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(2, Math.floor(H / 2)) : H;
1182
1327
  if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
1183
1328
  acc.lastScheduledBarTs = latest;
1184
1329
  saveRawAccounts(list);
package/server/config.js CHANGED
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import { saveCandles, loadCandles, candleRange, kvGet, kvSet } from '../db.js';
3
3
  import { DAY, MSK } from '../util.js';
4
4
 
package/server/db.js CHANGED
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
@@ -189,6 +189,7 @@ for (const [tbl, col, def] of [
189
189
  ['journal', 'user_id', 'INTEGER DEFAULT 1'],
190
190
  ['settings_history', 'user_id', 'INTEGER DEFAULT 1'],
191
191
  ['users', 'api_key', 'TEXT'],
192
+ ['users', 'offer_accepted_at', 'INTEGER DEFAULT 0'],
192
193
  ]) {
193
194
  if (!db.prepare(`PRAGMA table_info(${tbl})`).all().some((c) => c.name === col)) {
194
195
  db.exec(`ALTER TABLE ${tbl} ADD COLUMN ${col} ${def}`);
@@ -368,8 +369,21 @@ export function setRunShowcase(id, showcase) {
368
369
  return getRun(id);
369
370
  }
370
371
  export function listShowcaseRuns() {
371
- const rows = db.prepare("SELECT * FROM runs WHERE mode = 'model' AND status = 'done' AND (showcase = 1 OR id IN (SELECT id FROM runs WHERE mode = 'model' AND status = 'done' ORDER BY json_extract(metrics, '$.full.returnPct') DESC LIMIT 3)) ORDER BY showcase DESC, json_extract(metrics, '$.full.returnPct') DESC LIMIT 4").all();
372
- return rows.map(parseRun);
372
+ const rows = db.prepare("SELECT * FROM runs WHERE mode = 'model' AND status = 'done' ORDER BY showcase DESC, json_extract(metrics, '$.full.returnPct') ASC LIMIT 40").all().map(parseRun);
373
+ const picked = [];
374
+ const seenSig = new Set();
375
+ for (const r of rows) {
376
+ const dir = r.params?.modelDirection || 'long';
377
+ if (dir === 'neutral') continue;
378
+ const ret = Math.round(Number(r.metrics?.full?.returnPct || 0));
379
+ const topK = r.params?.modelTopK || 2;
380
+ const sig = `${dir}_${topK}_${ret}`;
381
+ if (seenSig.has(sig)) continue;
382
+ seenSig.add(sig);
383
+ picked.push(r);
384
+ if (picked.length >= 3) break;
385
+ }
386
+ return picked.sort((a, b) => (Number(a.metrics?.full?.returnPct || 0) - Number(b.metrics?.full?.returnPct || 0)));
373
387
  }
374
388
  export function countUserRunsToday(uid = getUid()) {
375
389
  const since = Date.now() - 86400000;
package/server/hub.js CHANGED
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  // Общий канал событий сервера → браузер (WebSocket) и подписчики внутри сервера
3
3
  const listeners = new Set();
4
4
  let sender = () => {};
package/server/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import http from 'node:http';
3
3
  import os from 'node:os';
4
4
  import { spawn } from 'node:child_process';
@@ -56,9 +56,9 @@ app.get('/admin', (req, res) => {
56
56
  return res.sendFile(path.join(ROOT, 'public', 'admin.html'));
57
57
  });
58
58
  app.get('/', (req, res, next) => {
59
- // В CLOUD_MODE=1 сервер neuroalpha.odant.org работает как портал регистрации, оплаты и выдачи APIKey;
60
- // торговый терминал на облачном сервере доступен только администратору (?admin=1) для настройки ЮKassa, ботов и ИИ-модели
61
- if (process.env.CLOUD_MODE === '1' && !(req.user?.role === 'admin' && req.query.admin === '1')) {
59
+ // В CLOUD_MODE=1 неавторизованным гостям показывается лендинг (витрина PaaS),
60
+ // а авторизованным пользователям открывается их изолированный облачный терминал (или витрина при ?landing=1)
61
+ if (process.env.CLOUD_MODE === '1' && (!req.user || req.query.landing === '1')) {
62
62
  return res.sendFile(path.join(ROOT, 'public', 'landing.html'));
63
63
  }
64
64
  next();
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import { llmConfig } from '../config.js';
3
3
  import { resolveActive, isReady, providerFetch } from './providers.js';
4
4
 
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import https from 'node:https';
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import { ensureCandles, ensureDividends, exDate, mskToTs, tsToDate } from '../data/moex.js';
3
3
  import { ensureVocab } from './vocab.js';
4
4
  import { kvGet, kvSet } from '../db.js';