neuroalpha 0.3.12 → 0.3.14

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 +780 -226
  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 +62 -30
  15. package/server/auth/index.js +109 -20
  16. package/server/billing/yookassa.js +1 -1
  17. package/server/brokers/index.js +256 -53
  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 +354 -25
  27. package/server/model/linalg.js +1 -1
  28. package/server/model/live.js +36 -25
  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 +14 -38
  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
package/public/legal.html 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
  <!doctype html>
3
3
  <html lang="ru">
4
4
  <head>
@@ -41,22 +41,37 @@
41
41
 
42
42
  <h3>1.1. Предмет договора и правовой статус сервиса</h3>
43
43
  <ul>
44
- <li>Исполнитель предоставляет Пользователю дистанционный доступ к функциям аналитического программного обеспечения «НейроАльфа» в соответствии с выбранным Пользователем тарифным планом («Старт», «Инвестор», «Профи»).</li>
45
- <li>Сервис «НейроАльфа» является исключительно программно-техническим средством (калькулятором статистических закономерностей и интерфейсом автоматизации отправки поручений по параметрам, заданным самим Пользователем).</li>
46
- <li><b>Исполнитель не осуществляет деятельность по инвестиционному консультированию</b> (в смысле ст. 6.1 Федерального закона № 39-ФЗ «О рынке ценных бумаг»), не составляет индивидуальных инвестиционных профилей и не даёт индивидуальных инвестиционных рекомендаций.</li>
44
+ <li>Исполнитель предоставляет Пользователю дистанционный доступ к функциям облачного программно-вычислительного комплекса «НейроАльфа» (модель PaaS/SaaS) в соответствии с выбранным Пользователем тарифным планом («Старт», «Инвестор», «Профи», «VIP»).</li>
45
+ <li>Сервис «НейроАльфа» является исключительно программно-техническим средством (калькулятором статистических закономерностей и интерфейсом автоматизации отправки поручений по параметрам и предохранителям, заданным самим Пользователем).</li>
46
+ <li><b>Исполнитель не осуществляет деятельность по инвестиционному консультированию</b> (в смысле ст. 6.1 Федерального закона № 39-ФЗ «О рынке ценных бумаг»), не составляет индивидуальных инвестиционных профилей и не даёт индивидуальных инвестиционных рекомендаций (ИИР).</li>
47
47
  <li><b>Исполнитель не осуществляет доверительное управление</b> или брокерскую деятельность, не принимает денежные средства или ценные бумаги Пользователей на свои счета. Все торговые операции совершаются Пользователем самостоятельно на его личном счёте у лицензированного брокера.</li>
48
48
  </ul>
49
49
 
50
- <h3>1.2. Порядок оплаты и предоставления доступа</h3>
50
+ <h3>1.2. Тарифные планы и технические параметры мощности</h3>
51
51
  <ul>
52
- <li>Оплата тарифных планов производится в рублях РФ безналичным способом (банковские карты МИР, Visa, Mastercard, Система быстрых платежей).</li>
52
+ <li><b>Тариф «Старт (Тест-драйв)» (0 ₽):</b> предоставляет доступ к историческим исследованиям (экспериментам) и 1 виртуальному демо-счёту без возможности подключения реальных брокерских счетов.</li>
53
+ <li><b>Тариф «Инвестор»:</b> позволяет подключить до 2 реальных брокерских счетов с ограничением выделенного капитала до 1 000 000 ₽ на каждом счёте, работающих исключительно в режиме покупок («Только в рост (без шорта)») с консервативным профилем загрузки слота и предельным целевым темпом доходности стратегии до 50% годовых.</li>
54
+ <li><b>Тариф «Профи»:</b> позволяет подключить до 5 реальных брокерских счетов с ограничением выделенного капитала до 10 000 000 ₽ на каждом счёте, включает режим работы на росте и падении акций («В рост и в падение (с шортом)») со стандартным шагом ребалансировки и предельным целевым темпом доходности стратегии до 100% годовых.</li>
55
+ <li><b>Тариф «VIP (Без ограничений)»:</b> предоставляется по индивидуальному согласованию с Администрацией платформы (через персональный VIP-токен), снимает ограничения на количество счетов, объём торгового капитала и потолок годовой доходности, активируя полный режим мощности ядра (внутричасовое полушаговое сопровождение <code>H/2</code> и динамическую концентрацию слота <code>Conviction Sizing</code>).</li>
56
+ </ul>
57
+
58
+ <h3>1.3. Запрет использования автоследования и ретрансляции сигналов</h3>
59
+ <ul>
60
+ <li>Пользователю <b>строго запрещается</b> подключать к платформе «НейроАльфа» брокерские счета (субсчета), участвующие в любых сервисах автоследования или копирования сделок (как в роли автора/ведущего стратегии, так и в роли подписчика), включая, но не ограничиваясь: «Сигналы» Т-Инвестиций, Finam Comon, Автоследование БКС и сторонние копи-трейдинг сервисы.</li>
61
+ <li>Пользователю запрещается передавать, публиковать, ретранслировать или перепродавать третьим лицам формируемые платформой расчёты, прогнозы и пакеты торговых поручений.</li>
62
+ <li>Платформа оснащена автоматическим техническим контролем признаков автоследования. При обнаружении на подключённом субсчёте операций автоследования или ретрансляции сделок подача поручений по счёту автоматически блокируется, а действие подписки может быть прекращено Исполнителем в одностороннем порядке без возврата оплаченных средств.</li>
63
+ </ul>
64
+
65
+ <h3>1.4. Порядок оплаты и предоставления доступа</h3>
66
+ <ul>
67
+ <li>Оплата тарифных планов производится в рублях РФ безналичным способом (банковские карты МИР, Visa, Mastercard, Система быстрых платежей) либо путём активации промокода / персонального VIP-токена.</li>
53
68
  <li>Доступ к оплаченному тарифному плану активируется автоматически в момент получения подтверждения об успешной оплате (`succeeded`) от платёжной системы на срок оплаченного периода (30, 90 или 365 календарных дней).</li>
54
- <li>Услуга предоставления доступа к SaaS-платформе считается оказанной надлежащим образом с момента открытия технической возможности использования оплаченного тарифа в личном кабинете Пользователя.</li>
69
+ <li>Услуга предоставления доступа к PaaS/SaaS-платформе считается оказанной надлежащим образом с момента открытия технической возможности использования выбранного тарифа в учётной записи Пользователя.</li>
55
70
  </ul>
56
71
 
57
- <h3>1.3. Ограничение ответственности</h3>
72
+ <h3>1.5. Ограничение ответственности</h3>
58
73
  <ul>
59
- <li>Программное обеспечение предоставляется по принципу «как есть» (as is). Исполнитель не гарантирует достижения какой-либо положительной доходности при использовании аналитических расчётов программы.</li>
74
+ <li>Программное обеспечение предоставляется по принципу «как есть» (as is). Исполнитель не гарантирует достижения какой-либо положительной доходности при использовании аналитических расчётов программы. Указанные в описании тарифов предельные уровни годовой доходности (50% / 100% годовых) являются верхними техническими порогами алгоритма управления риском, а не обещанием или гарантией дохода.</li>
60
75
  <li>Пользователь самостоятельно задаёт параметры стратегии, лимиты суммы, пороги дневного стопа и несёт полную имущественную ответственность за результаты сделок, совершаемых через подключённые им API-токены брокеров.</li>
61
76
  </ul>
62
77
  </section>
@@ -88,24 +103,30 @@
88
103
  </section>
89
104
 
90
105
  <section class="doc" id="requisites">
91
- <h2>4. Реквизиты Исполнителя и контакты поддержки</h2>
106
+ <h2>4. Правообладатель, Оператор платформы и контакты поддержки</h2>
92
107
  <div id="legal-req-body">
93
- <p>Загрузка реквизитов…</p>
108
+ <ul>
109
+ <li><b>Оператор платформы (Исполнитель):</b> ООО «БИС» («Бизнес Интер Софт», г. Рязань)</li>
110
+ <li><b>Автор и правообладатель ПО «НейроАльфа»:</b> Перепелкин Роман Александрович</li>
111
+ <li><b>Лицензионный статус:</b> Проприетарное программное обеспечение (Все права защищены). Копирование, декомпиляция, модификация и распространение без письменного согласия Правообладателя запрещены.</li>
112
+ </ul>
94
113
  </div>
95
114
  </section>
96
- <div style="text-align:center;color:var(--muted);font-size:13px;margin-top:16px">© 2026 Roman A. Perepelkin · Licensed under the MIT License</div>
115
+ <div style="text-align:center;color:var(--muted);font-size:13px;margin-top:16px">© 2026 Автор: Перепелкин Р.А. · Оператор: ООО «БИС» («Бизнес Интер Софт», г. Рязань) · Все права защищены</div>
97
116
  </div>
98
117
  <script>
99
118
  fetch('/api/public/legal').then((r) => r.json()).then((d) => {
100
119
  const rows = [
101
- d.ownerName ? `<li><b>Исполнитель:</b> ${d.ownerName}${d.status ? ` (${d.status})` : ''}</li>` : '',
120
+ `<li><b>Оператор платформы (Исполнитель):</b> ${d.ownerName || 'ООО «БИС» («Бизнес Интер Софт»)'}${d.status ? ` (${d.status})` : ''}</li>`,
121
+ `<li><b>Автор и правообладатель ПО:</b> ${d.authorName || 'Перепелкин Роман Александрович'}</li>`,
102
122
  d.inn ? `<li><b>ИНН:</b> ${d.inn}</li>` : '',
103
- d.ogrn ? `<li><b>ОГРН / ОГРНИП:</b> ${d.ogrn}</li>` : '',
123
+ d.ogrn ? `<li><b>ОГРН:</b> ${d.ogrn}</li>` : '',
104
124
  d.email ? `<li><b>Email технической поддержки:</b> <a href="mailto:${d.email}">${d.email}</a></li>` : '',
105
125
  d.phone ? `<li><b>Телефон:</b> ${d.phone}</li>` : '',
106
- d.address ? `<li><b>Местонахождение:</b> ${d.address}</li>` : '',
126
+ `<li><b>Местонахождение:</b> ${d.address || 'Российская Федерация, г. Рязань'}</li>`,
127
+ '<li><b>Правовой режим ПО:</b> Проприетарное программное обеспечение (Все права защищены)</li>',
107
128
  ].filter(Boolean).join('');
108
- document.getElementById('legal-req-body').innerHTML = rows ? `<ul>${rows}</ul>` : '<p>Связь с поддержкой — в личном кабинете сервиса.</p>';
129
+ document.getElementById('legal-req-body').innerHTML = `<ul>${rows}</ul>`;
109
130
  }).catch(() => {});
110
131
  </script>
111
132
  </body>
package/public/styles.css 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
  :root {
3
3
  --bg: #0d1016;
4
4
  --panel: #13171f;
@@ -57,6 +57,9 @@ button.icon { padding: 2px 8px; color: var(--text); background: transparent; }
57
57
  button.icon-lg { width: 40px; height: 40px; padding: 0; font-size: 15px; flex: none; }
58
58
 
59
59
  main { display: grid; grid-template-columns: var(--chat-w) 5px 1fr; height: calc(100vh - 46px); }
60
+ body.chat-closed main { grid-template-columns: 1fr; }
61
+ body.chat-closed .chat, body.chat-closed #splitter { display: none; }
62
+ #btn-chat-toggle.active { border-color: var(--accent); color: var(--accent); background: rgba(79, 140, 255, 0.1); }
60
63
  #splitter { cursor: col-resize; background: var(--line); transition: background .15s; }
61
64
  #splitter:hover, #splitter.drag { background: var(--accent); }
62
65
  body.dragging { cursor: col-resize; user-select: none; }
@@ -202,7 +205,7 @@ body.light .tbadge.run { background: #dbeafe; border-color: #93c5fd; }
202
205
  .ld-syms { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
203
206
  .ld-sym { font-size: 11px; padding: 2px 7px; border-radius: 5px; background: var(--bg); border: 1px solid var(--line); color: var(--text); }
204
207
  .ld-sym.ok { border-color: rgba(63, 185, 80, 0.5); color: var(--green); }
205
- .verdict { margin: 16px 0 12px; font-size: 15px; line-height: 1.5; }
208
+ .verdict { margin: 10px 0; font-size: 13.5px; line-height: 1.45; }
206
209
  .kpis { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
207
210
  @media (max-width: 1180px) { .kpis { grid-template-columns: repeat(3, 1fr); } }
208
211
  .kpi { background: var(--bg); padding: 10px 14px; cursor: help; }
@@ -253,6 +256,11 @@ tr.best td { background: var(--best-bg); }
253
256
  .stab-chip { background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 2px 8px; font-size: 12px; }
254
257
  .live-card { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin-top: 10px; }
255
258
  .live-card.stopped { opacity: .75; }
259
+ .live-card.collapsed { padding: 10px 14px; }
260
+ .acc-fold-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; cursor: pointer; user-select: none; }
261
+ .acc-fold-head:hover b { color: var(--accent); }
262
+ .acc-caret { display: inline-block; width: 16px; color: var(--muted); font-size: 12px; transition: transform .15s; }
263
+ .live-card:not(.collapsed) .acc-caret { transform: rotate(90deg); color: var(--accent); }
256
264
  .live-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 10px; }
257
265
  .stab-box[hidden], .run-bar[hidden], .run-bar button[hidden] { display: none; }
258
266
  .run-bar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; margin: 8px 0 4px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
@@ -279,7 +287,7 @@ body.light input[type=checkbox].switch:checked { background: #1a7f37; border-col
279
287
  .headbar .b { height: 6px; background: var(--panel); border-radius: 3px; position: relative; }
280
288
  .headbar .b div { position: absolute; top: 0; bottom: 0; border-radius: 3px; }
281
289
 
282
- .settings-wrap { display: grid; grid-template-columns: 205px minmax(0, 740px); gap: 24px; align-items: start; min-width: 0; }
290
+ .settings-wrap { display: grid; grid-template-columns: 205px minmax(0, 1fr); gap: 24px; align-items: start; min-width: 0; }
283
291
  @media (max-width: 1100px) { .settings-wrap { grid-template-columns: 180px minmax(0, 1fr); gap: 16px; } }
284
292
  .snav { display: flex; flex-direction: column; gap: 3px; position: sticky; top: 0; min-width: 0; }
285
293
  .snav #s-search { width: 100%; margin-bottom: 10px; background: transparent; border-color: var(--line); padding: 6px 10px; font-size: 13px; }
@@ -366,6 +374,19 @@ table.cmp td { padding: 3px 8px; }
366
374
  .brk-alloc-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--muted); }
367
375
  .brk-alloc-legend span { display: inline-flex; align-items: center; gap: 5px; }
368
376
  .brk-alloc-legend i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
377
+ .brk-specs { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 4px 14px; font-size: 13px; margin: 10px 0; line-height: 1.45; }
378
+ .brk-specs .k { color: var(--muted); }
379
+ .brk-specs .v { color: var(--text); }
380
+ .brk-toolbar { display: flex; flex-wrap: nowrap; align-items: center; gap: 8px; padding: 8px 0 10px; margin-bottom: 10px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); overflow-x: auto; }
381
+ .brk-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 6px; font-size: 13px; border: 1px solid var(--line); background: var(--panel); color: var(--muted); transition: .15s; }
382
+ .brk-toggle.on { border-color: rgba(22, 163, 74, 0.55); background: rgba(22, 163, 74, 0.14); color: #4ade80; font-weight: 600; }
383
+ .brk-icon-btn { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; font-size: 15px; flex: none; }
384
+ tr.day-group-row td { background: var(--panel); color: var(--text); font-weight: 600; font-size: 12.5px; padding: 7px 10px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
385
+ details.hist-year-box { margin-top: 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); overflow: hidden; }
386
+ details.hist-year-box > summary { padding: 10px 14px; cursor: pointer; font-weight: 600; font-size: 13.5px; background: var(--panel); display: flex; justify-content: space-between; align-items: center; gap: 10px; user-select: none; }
387
+ details.hist-year-box > summary:hover { border-color: var(--accent); color: var(--accent); }
388
+ details.hist-year-box[open] > summary { border-bottom: 1px solid var(--line); }
389
+ @media (max-width: 700px) { .brk-specs { grid-template-columns: 1fr; } }
369
390
 
370
391
  @media (max-width: 900px) {
371
392
  body { overflow: auto; }
@@ -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 express from 'express';
@@ -6,7 +6,7 @@ import { ROOT, strategyDefaults } from '../config.js';
6
6
  import { activeModelInfo } from '../llm/client.js';
7
7
  import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel, resolveUpstreamForProxy, providerFetch } from '../llm/providers.js';
8
8
  import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet } from '../db.js';
9
- import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createPromoCode, listPromoCodes, redeemPromoCode, parseCookies, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
9
+ import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
10
10
  import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments } from '../billing/yookassa.js';
11
11
  import { publicNotifyConfig, getNotifyConfig, saveNotifyConfig, notifyUser, createTelegramLoginChallenge, checkTelegramLoginChallenge, handleMaxWebhook } from '../notify/index.js';
12
12
  import { PARAMS } from '../params-meta.js';
@@ -129,10 +129,19 @@ export function createRouter() {
129
129
  res.json({ ok: true, ...sent });
130
130
  }));
131
131
  r.post('/auth/redeem-promo', wrap((req, res) => {
132
- if (!req.user) throw new Error('Войдите в аккаунт для активации промокода');
132
+ if (!req.user) throw new Error('Войдите в аккаунт для активации кода или VIP-токена');
133
133
  const user = redeemPromoCode(req.user.id, req.body?.code);
134
134
  res.json({ user });
135
135
  }));
136
+ r.post('/auth/accept-offer', wrap((req, res) => {
137
+ if (!req.user) throw new Error('Войдите в аккаунт');
138
+ res.json({ ok: true, user: acceptUserOffer(req.user.id) });
139
+ }));
140
+ r.post('/auth/vip-request', wrap(async (req, res) => {
141
+ const item = createVipRequest({ user: req.user, ...(req.body || {}) });
142
+ notifyUser(1, `👑 Заявка на тариф VIP (НейроАльфа):\nИмя: ${item.name}\nКонтакт: ${item.contact || item.email}\nКапитал: ${item.capitalRub || '—'}\nКомментарий: ${item.comment || '—'}`).catch(() => {});
143
+ res.json({ ok: true, request: item });
144
+ }));
136
145
  r.post('/auth/api-key/regenerate', wrap((req, res) => {
137
146
  if (!req.user) throw new Error('Войдите в аккаунт');
138
147
  const apiKey = ensureApiKey(req.user.id, true);
@@ -188,49 +197,72 @@ export function createRouter() {
188
197
 
189
198
  // ---------- публичная витрина для лендинга ----------
190
199
  r.get('/public/showcase', (req, res) => {
191
- const runs = listShowcaseRuns().map((x) => {
192
- const full = getRun(x.id);
200
+ const buildShowcaseItem = (x, full) => {
201
+ const chosen = x.metrics?.model?.chosen || 'attention';
202
+ const cmpFull = x.metrics?.model?.comparison?.[chosen]?.full || full?.extra?.comparison?.[chosen]?.full || {};
203
+ const mFull = { ...(x.metrics?.full || {}), ...cmpFull };
204
+ const p = x.params || {};
205
+ const dir = p.modelDirection || 'long';
206
+ const topK = p.modelTopK || 2;
207
+ const from = x.metrics?.period?.from || 0, till = x.metrics?.period?.till || 0;
208
+ const yrs = Math.max(0.5, (till - from) / (365.25 * 86400000));
209
+ const ret = Number(mFull.returnPct || 0);
210
+ const cagr = yrs >= 0.25 && ret > -99 ? ((Math.pow(1 + ret / 100, 1 / yrs) - 1) * 100) : (ret / yrs);
211
+
212
+ let tierBadge = 'Тариф «Инвестор»';
213
+ let tierColor = 'blue';
214
+ let tierNote = 'Режим «Только в рост (без шорта)» · лимит до 50% годовых · до 1 млн ₽ на счёт';
215
+ let autoTitle = p._title;
216
+ if (dir === 'long' || cagr <= 52) {
217
+ tierBadge = 'Для тарифа «Инвестор»';
218
+ tierColor = 'blue';
219
+ tierNote = 'Торговля только от покупки (без шортов) · потолок тарифа до 50% годовых · до 1 млн ₽/счёт';
220
+ if (!autoTitle) autoTitle = 'Консервативный рост · Только покупки';
221
+ } else if (cagr <= 105) {
222
+ tierBadge = 'Для тарифа «Профи»';
223
+ tierColor = 'indigo';
224
+ tierNote = 'Заработок на росте и падении акций (с шортами) · потолок тарифа до 100% годовых · до 10 млн ₽/счёт';
225
+ if (!autoTitle) autoTitle = 'Активный рост и падение · С шортами';
226
+ } else {
227
+ tierBadge = '👑 Для тарифа «VIP»';
228
+ tierColor = 'gold';
229
+ tierNote = 'Полная мощность без потолка доходности · форсаж H/2 + концентрация слота Conviction Sizing';
230
+ if (!autoTitle || autoTitle === 'Эталонная адаптивная стратегия') autoTitle = 'Институциональный форсаж · Полная мощность';
231
+ }
193
232
  return {
194
233
  id: x.id,
195
- title: x.params?._title || `Эталонная стратегия №${x.id}`,
196
- params: { modelHorizon: x.params?.modelHorizon, modelTopK: x.params?.modelTopK, modelDirection: x.params?.modelDirection, modelType: x.params?.modelType },
197
- metrics: x.metrics?.full || {},
198
- oos: x.metrics?.model?.comparison?.[x.metrics?.model?.chosen || 'attention']?.outOfSample || {},
234
+ title: autoTitle,
235
+ tierBadge,
236
+ tierColor,
237
+ tierNote,
238
+ params: { modelHorizon: p.modelHorizon || 2.5, modelTopK: topK, modelDirection: dir, modelType: chosen },
239
+ metrics: mFull,
240
+ oos: x.metrics?.model?.comparison?.[chosen]?.outOfSample || full?.extra?.comparison?.[chosen]?.outOfSample || {},
199
241
  period: x.metrics?.period || {},
200
- curve: full?.extra?.curves?.[x.metrics?.model?.chosen || 'attention']?.slice(-180) || [],
242
+ curve: full?.extra?.curves?.[chosen]?.slice(-180) || [],
201
243
  marketCurve: full?.extra?.curves?.market?.slice(-180) || [],
202
244
  };
203
- });
245
+ };
246
+ const runs = listShowcaseRuns().map((x) => buildShowcaseItem(x, getRun(x.id)));
204
247
  if (!runs.length) {
205
248
  try {
206
249
  const ref = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'seed-run.json'), 'utf8'));
207
- const att = ref?.extra?.comparison?.attention;
208
- if (att?.full) {
209
- runs.push({
210
- id: 1,
211
- title: ref.params?._title || 'Эталонная адаптивная стратегия «Внимание через карту»',
212
- params: { modelHorizon: ref.params?.modelHorizon || 2, modelTopK: ref.params?.modelTopK || 3, modelDirection: ref.params?.modelDirection || 'longshort', modelType: 'attention' },
213
- metrics: att.full,
214
- oos: att.outOfSample || {},
215
- period: ref.metrics?.period || {},
216
- curve: (ref.extra?.curves?.attention || []).slice(-180),
217
- marketCurve: (ref.extra?.curves?.market || []).slice(-180),
218
- });
219
- }
250
+ if (ref) runs.push(buildShowcaseItem({ id: 1, ...ref }, ref));
220
251
  } catch { /* ignore */ }
221
252
  }
222
253
  res.json({ runs, plans: PLANS, legal: getLegalInfo() });
223
254
  });
224
255
 
225
- // ---------- реквизиты оферты (для модерации ЮKassa) ----------
256
+ // ---------- реквизиты оферты (для модерации ЮKassa и правовых документов) ----------
226
257
  const DEFAULT_LEGAL = {
227
- ownerName: 'Перепелкин Роман Александрович',
228
- status: 'Индивидуальный предприниматель / Плательщик НПД',
258
+ ownerName: 'ООО «БИС» («Бизнес Интер Софт»)',
259
+ authorName: 'Перепелкин Роман Александрович',
260
+ status: 'Оператор платформы PaaS · Проприетарное ПО (Все права защищены)',
229
261
  inn: '',
230
262
  ogrn: '',
231
263
  email: 'support@neuroalpha.ru',
232
264
  phone: '',
233
- address: 'Российская Федерация',
265
+ address: 'Российская Федерация, г. Рязань',
234
266
  };
235
267
  const getLegalInfo = () => ({ ...DEFAULT_LEGAL, ...(kvGet('legalInfo') || {}) });
236
268
  r.get('/public/legal', (req, res) => res.json(getLegalInfo()));
@@ -241,7 +273,7 @@ export function createRouter() {
241
273
  };
242
274
  r.get('/admin/state', wrap((req, res) => {
243
275
  requireAdmin(req);
244
- res.json({ users: listAllUsers(), payments: listAllPayments(), promos: listPromoCodes(), billing: publicBillingConfig(), notify: publicNotifyConfig(), oauth: publicOAuthConfig(), legal: getLegalInfo() });
276
+ res.json({ users: listAllUsers(), payments: listAllPayments(), promos: listPromoCodes(), vipRequests: listVipRequests(), billing: publicBillingConfig(), notify: publicNotifyConfig(), oauth: publicOAuthConfig(), legal: getLegalInfo() });
245
277
  }));
246
278
  r.post('/admin/oauth', wrap((req, res) => {
247
279
  requireAdmin(req);
@@ -444,7 +476,7 @@ export function createRouter() {
444
476
  r.put('/brokers/:id', wrap((req, res) => res.json(saveBrokerAccount({ ...(req.body || {}), id: req.params.id }))));
445
477
  r.delete('/brokers/:id', wrap((req, res) => res.json(deleteBrokerAccount(req.params.id))));
446
478
  r.post('/brokers/:id/sync', wrap(async (req, res) => res.json(await syncBrokerAccount(req.params.id))));
447
- r.post('/brokers/:id/history', wrap(async (req, res) => res.json(await fetchBrokerHistory(req.params.id))));
479
+ r.post('/brokers/:id/history', wrap(async (req, res) => res.json(await fetchBrokerHistory(req.params.id, req.body || {}))));
448
480
  r.post('/brokers/:id/freeze-baseline', wrap(async (req, res) => res.json(await freezeBrokerBaseline(req.params.id))));
449
481
  r.post('/brokers/:id/prepare', wrap(async (req, res) => res.json(await prepareBrokerOrders(req.params.id))));
450
482
  r.post('/brokers/:id/execute', wrap(async (req, res) => res.json(await executeBrokerOrders(req.params.id))));
@@ -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 crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
@@ -74,11 +74,61 @@ export function verifyPassword(password, stored) {
74
74
  return hash.length === target.length && crypto.timingSafeEqual(hash, target);
75
75
  }
76
76
 
77
- // ---------- пользователи и сессии ----------
77
+ // ---------- пользователи, тарифные планы (4 уровня) и сессии ----------
78
78
  export const PLANS = {
79
- free: { id: 'free', name: 'Старт (Витрина)', priceRub: 0, maxRunsPerDay: 2, maxVirtualAccounts: 1, maxBrokerAccounts: 0, maxChatPerDay: 10, allowTransformer: false },
80
- investor: { id: 'investor', name: 'Инвестор (Автопилот)', priceRub: 3900, maxRunsPerDay: 30, maxVirtualAccounts: 3, maxBrokerAccounts: 2, maxChatPerDay: 150, allowTransformer: false },
81
- pro: { id: 'pro', name: 'Профи / Лаборатория', priceRub: 9900, maxRunsPerDay: 500, maxVirtualAccounts: 5, maxBrokerAccounts: 5, maxChatPerDay: 1000, allowTransformer: true },
79
+ free: {
80
+ id: 'free',
81
+ name: 'Старт (Тест-драйв)',
82
+ priceRub: 0,
83
+ maxRunsPerDay: 20,
84
+ maxVirtualAccounts: 1,
85
+ maxBrokerAccounts: 0,
86
+ maxCapitalPerAccountRub: 0,
87
+ allowShorts: false,
88
+ allowVipBoost: false,
89
+ maxAnnualReturnPct: 50,
90
+ maxChatPerDay: 20,
91
+ },
92
+ investor: {
93
+ id: 'investor',
94
+ name: 'Инвестор',
95
+ priceRub: 3900,
96
+ maxRunsPerDay: 50,
97
+ maxVirtualAccounts: 3,
98
+ maxBrokerAccounts: 2,
99
+ maxCapitalPerAccountRub: 1000000,
100
+ allowShorts: false,
101
+ allowVipBoost: false,
102
+ maxAnnualReturnPct: 50,
103
+ maxChatPerDay: 200,
104
+ },
105
+ pro: {
106
+ id: 'pro',
107
+ name: 'Профи',
108
+ priceRub: 9900,
109
+ maxRunsPerDay: 500,
110
+ maxVirtualAccounts: 5,
111
+ maxBrokerAccounts: 5,
112
+ maxCapitalPerAccountRub: 10000000,
113
+ allowShorts: true,
114
+ allowVipBoost: false,
115
+ maxAnnualReturnPct: 100,
116
+ maxChatPerDay: 1000,
117
+ },
118
+ vip: {
119
+ id: 'vip',
120
+ name: 'VIP (Без ограничений)',
121
+ priceRub: 0, // по индивидуальному согласованию / VIP-токену от администратора
122
+ customPrice: true,
123
+ maxRunsPerDay: 10000,
124
+ maxVirtualAccounts: 20,
125
+ maxBrokerAccounts: 50,
126
+ maxCapitalPerAccountRub: 1000000000,
127
+ allowShorts: true,
128
+ allowVipBoost: true,
129
+ maxAnnualReturnPct: 0, // 0 = без потолка доходности (полный форсаж H/2 + Conviction Sizing)
130
+ maxChatPerDay: 5000,
131
+ },
82
132
  };
83
133
 
84
134
  export function generateApiKey() {
@@ -158,18 +208,22 @@ async function patchUserLicense(cleanKey, baseUrl) {
158
208
 
159
209
  export function effectivePlan(user) {
160
210
  if (!user) return PLANS.free;
161
- // В локальном режиме (npx): если активирован лицензионный ключ — действует тариф подписки;
162
- // если ADMIN_ALL_PRO=1 или это явно админ облака — действует его тариф
211
+ // В локальном режиме: если активирован лицензионный ключ — действует тариф подписки;
212
+ // если это явно админ — действует VIP-тариф без ограничений
163
213
  if (process.env.CLOUD_MODE !== '1' && user.id === 1) {
214
+ if (user.plan && PLANS[user.plan] && user.plan !== 'pro') {
215
+ if (user.plan !== 'free' && user.plan_until > 0 && user.plan_until < Date.now()) return PLANS.free;
216
+ return PLANS[user.plan];
217
+ }
164
218
  const lic = getLocalLicense();
165
219
  if (lic?.plan && PLANS[lic.plan]) {
166
220
  if (lic.plan !== 'free' && lic.planUntil > 0 && lic.planUntil < Date.now()) return PLANS.free;
167
221
  return PLANS[lic.plan];
168
222
  }
169
223
  if (process.env.LOCAL_STRICT_LICENSE === '1') return PLANS.free;
170
- return PLANS.pro;
224
+ return PLANS.vip;
171
225
  }
172
- if (user.role === 'admin') return PLANS.pro;
226
+ if (user.role === 'admin') return PLANS[user.plan] || PLANS.vip;
173
227
  const p = PLANS[user.plan] || PLANS.free;
174
228
  if (p.id !== 'free' && user.plan_until > 0 && user.plan_until < Date.now()) return PLANS.free;
175
229
  return p;
@@ -194,11 +248,19 @@ export function publicUser(u) {
194
248
  tgConnected: Boolean(u.tg_chat_id),
195
249
  maxConnected: Boolean(u.max_chat_id),
196
250
  notifyCode: u.notify_code || null,
251
+ offerAcceptedAt: u.offer_accepted_at || 0,
197
252
  localLicense: process.env.CLOUD_MODE !== '1' ? getLocalLicense() : null,
198
253
  createdAt: u.created_at,
199
254
  };
200
255
  }
201
256
 
257
+ export function acceptUserOffer(userId) {
258
+ if (!userId) throw new Error('Войдите в аккаунт для подписания оферты');
259
+ const now = Date.now();
260
+ db.prepare('UPDATE users SET offer_accepted_at = ? WHERE id = ?').run(now, Number(userId));
261
+ return publicUser(getUserById(userId));
262
+ }
263
+
202
264
  export function getUserById(id) {
203
265
  return db.prepare('SELECT * FROM users WHERE id = ?').get(Number(id));
204
266
  }
@@ -305,13 +367,16 @@ export function setUserPlan(userId, plan, days = 30) {
305
367
  return publicUser(getUserById(userId));
306
368
  }
307
369
 
308
- // ---------- промокоды ----------
309
- export function createPromoCode({ code, plan = 'investor', days = 14, maxUses = 50 }) {
310
- const clean = String(code || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '');
370
+ // ---------- промокоды и персональные VIP-токены ----------
371
+ export function createPromoCode({ code, plan = 'investor', days = 14, maxUses = 50, generateVip = false }) {
372
+ let clean = String(code || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '');
373
+ if (generateVip || (!clean && plan === 'vip')) {
374
+ clean = `VIP-${crypto.randomBytes(3).toString('hex').toUpperCase()}-${crypto.randomBytes(2).toString('hex').toUpperCase()}`;
375
+ }
311
376
  if (clean.length < 3) throw new Error('Код должен содержать минимум 3 символа (латиница/цифры)');
312
- if (!(plan in PLANS) || plan === 'free') throw new Error('Выберите тариф Инвестор или Профи');
313
- const d = Math.max(1, Math.min(365, Math.round(Number(days) || 14)));
314
- const m = Math.max(1, Math.min(10000, Math.round(Number(maxUses) || 50)));
377
+ if (!(plan in PLANS) || plan === 'free') throw new Error('Выберите тариф Инвестор, Профи или VIP');
378
+ const d = Math.max(1, Math.min(3650, Math.round(Number(days) || (plan === 'vip' ? 365 : 14))));
379
+ const m = Math.max(1, Math.min(10000, Math.round(Number(maxUses) || (plan === 'vip' ? 1 : 50))));
315
380
  db.prepare(`INSERT OR REPLACE INTO promocodes (code, plan, days, max_uses, used_count, created_at)
316
381
  VALUES (?, ?, ?, ?, COALESCE((SELECT used_count FROM promocodes WHERE code = ?), 0), ?)`).run(clean, plan, d, m, clean, Date.now());
317
382
  return listPromoCodes();
@@ -322,22 +387,46 @@ export function listPromoCodes() {
322
387
  }
323
388
 
324
389
  export function redeemPromoCode(userId, rawCode) {
325
- if (!userId) throw new Error('Войдите в аккаунт для активации промокода');
390
+ if (!userId) throw new Error('Войдите в аккаунт для активации кода или VIP-токена');
326
391
  const clean = String(rawCode || '').trim().toUpperCase();
327
392
  const promo = db.prepare('SELECT * FROM promocodes WHERE UPPER(code) = ?').get(clean);
328
- if (!promo) throw new Error('Промокод не найден');
329
- if (promo.used_count >= promo.max_uses) throw new Error('Лимит активаций этого промокода исчерпан');
393
+ if (!promo) throw new Error('Промокод или VIP-токен не найден');
394
+ if (promo.used_count >= promo.max_uses) throw new Error('Лимит активаций этого кода или VIP-токена исчерпан');
330
395
  const already = db.prepare('SELECT 1 FROM promocode_uses WHERE code = ? AND user_id = ?').get(promo.code, Number(userId));
331
- if (already) throw new Error('Вы уже активировали этот промокод');
396
+ if (already) throw new Error('Вы уже активировали этот код');
332
397
  const u = getUserById(userId);
333
398
  const baseTs = u.plan === promo.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
334
399
  const nextUntil = baseTs + promo.days * 86400000;
335
- db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(promo.plan, nextUntil, u.id);
400
+ db.prepare('UPDATE users SET plan = ?, plan_until = ?, offer_accepted_at = COALESCE(NULLIF(offer_accepted_at, 0), ?) WHERE id = ?').run(promo.plan, nextUntil, Date.now(), u.id);
336
401
  db.prepare('UPDATE promocodes SET used_count = used_count + 1 WHERE code = ?').run(promo.code);
337
402
  db.prepare('INSERT INTO promocode_uses (code, user_id, used_at) VALUES (?, ?, ?)').run(promo.code, u.id, Date.now());
338
403
  return publicUser(getUserById(userId));
339
404
  }
340
405
 
406
+ export function createVipRequest({ user, name, contact, capitalRub, comment }) {
407
+ const list = kvGet('vipRequests') || [];
408
+ const item = {
409
+ id: `vipreq_${Date.now()}`,
410
+ createdAt: Date.now(),
411
+ userId: user?.id || null,
412
+ email: user?.email || '',
413
+ name: String(name || user?.name || '').trim() || 'Инвестор',
414
+ contact: String(contact || '').trim(),
415
+ capitalRub: String(capitalRub || '').trim(),
416
+ comment: String(comment || '').trim(),
417
+ status: 'new',
418
+ };
419
+ if (!item.contact && !item.email) throw new Error('Укажите ваш Telegram, телефон или email для связи');
420
+ list.unshift(item);
421
+ if (list.length > 200) list.length = 200;
422
+ kvSet('vipRequests', list);
423
+ return item;
424
+ }
425
+
426
+ export function listVipRequests() {
427
+ return kvGet('vipRequests') || [];
428
+ }
429
+
341
430
  export function authMiddleware(req, res, next) {
342
431
  const u = userFromRequest(req);
343
432
  req.user = u;
@@ -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 crypto from 'node:crypto';
3
3
  import { db, kvGet, kvSet } from '../db.js';
4
4
  import { PLANS, encryptSecret, decryptSecret, getUserById, publicUser } from '../auth/index.js';