neuroalpha 0.2.0 → 0.3.12

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -3
  3. package/bin/cli.js +69 -69
  4. package/config/seed-run.json +1 -0
  5. package/config/strategy.json +4 -10
  6. package/deploy/install.sh +128 -0
  7. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +103 -0
  8. 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
  9. 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 +171 -0
  10. package/package.json +15 -5
  11. package/public/admin.html +426 -0
  12. package/public/app.js +741 -251
  13. package/public/index.html +45 -20
  14. package/public/landing.html +1004 -870
  15. package/public/legal.html +22 -0
  16. package/public/styles.css +49 -15
  17. package/server/api/routes.js +176 -23
  18. package/server/auth/index.js +544 -340
  19. package/server/billing/yookassa.js +1 -0
  20. package/server/brokers/index.js +1224 -645
  21. package/server/config.js +6 -4
  22. package/server/data/moex.js +1 -0
  23. package/server/db.js +53 -2
  24. package/server/hub.js +1 -0
  25. package/server/index.js +10 -2
  26. package/server/llm/client.js +1 -0
  27. package/server/llm/providers.js +50 -3
  28. package/server/model/dataset.js +362 -352
  29. package/server/model/forecast.js +15 -21
  30. package/server/model/linalg.js +1 -0
  31. package/server/model/live.js +489 -486
  32. package/server/model/metrics.js +1 -0
  33. package/server/model/models.js +31 -21
  34. package/server/model/profiles.js +1 -0
  35. package/server/model/research.js +119 -172
  36. package/server/model/vocab.js +1 -0
  37. package/server/news/index.js +1 -0
  38. package/server/notify/index.js +304 -168
  39. package/server/params-meta.js +20 -25
  40. package/server/settings.js +1 -0
  41. package/server/strategist/analytics.js +1 -0
  42. package/server/strategist/index.js +6 -6
  43. package/server/strategist/tools.js +2 -1
  44. package/server/util.js +1 -0
  45. package/server/model/gpu-worker.html +0 -11
  46. package/server/model/gpu-worker.js +0 -60
  47. package/server/model/tf-node.js +0 -26
  48. package/server/model/transformer-core.js +0 -330
  49. package/server/model/transformer-gpu.js +0 -221
  50. package/server/model/transformer-pool.js +0 -200
  51. package/server/model/transformer-worker.js +0 -28
  52. package/server/model/transformer.js +0 -57
package/public/legal.html CHANGED
@@ -1,3 +1,4 @@
1
+ <!-- Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License. -->
1
2
  <!doctype html>
2
3
  <html lang="ru">
3
4
  <head>
@@ -85,6 +86,27 @@
85
86
  <li>Используя сервис «НейроАльфа», Пользователь подтверждает, что осознаёт указанные риски и принимает все инвестиционные решения самостоятельно.</li>
86
87
  </ul>
87
88
  </section>
89
+
90
+ <section class="doc" id="requisites">
91
+ <h2>4. Реквизиты Исполнителя и контакты поддержки</h2>
92
+ <div id="legal-req-body">
93
+ <p>Загрузка реквизитов…</p>
94
+ </div>
95
+ </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>
88
97
  </div>
98
+ <script>
99
+ fetch('/api/public/legal').then((r) => r.json()).then((d) => {
100
+ const rows = [
101
+ d.ownerName ? `<li><b>Исполнитель:</b> ${d.ownerName}${d.status ? ` (${d.status})` : ''}</li>` : '',
102
+ d.inn ? `<li><b>ИНН:</b> ${d.inn}</li>` : '',
103
+ d.ogrn ? `<li><b>ОГРН / ОГРНИП:</b> ${d.ogrn}</li>` : '',
104
+ d.email ? `<li><b>Email технической поддержки:</b> <a href="mailto:${d.email}">${d.email}</a></li>` : '',
105
+ d.phone ? `<li><b>Телефон:</b> ${d.phone}</li>` : '',
106
+ d.address ? `<li><b>Местонахождение:</b> ${d.address}</li>` : '',
107
+ ].filter(Boolean).join('');
108
+ document.getElementById('legal-req-body').innerHTML = rows ? `<ul>${rows}</ul>` : '<p>Связь с поддержкой — в личном кабинете сервиса.</p>';
109
+ }).catch(() => {});
110
+ </script>
89
111
  </body>
90
112
  </html>
package/public/styles.css CHANGED
@@ -1,3 +1,4 @@
1
+ /* Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License. */
1
2
  :root {
2
3
  --bg: #0d1016;
3
4
  --panel: #13171f;
@@ -147,7 +148,11 @@ body.light .model-opt:hover:not(:disabled), body.light .model-opt.active { backg
147
148
  .model-manage-btn:hover { background: #ffffff0c; color: var(--accent); border-color: var(--line); }
148
149
  body.light .model-manage-btn:hover { background: #f0f3f6; }
149
150
  .pbtn { width: 34px; height: 34px; border-radius: 999px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 14px; background: var(--bg); border: 1px solid var(--line); color: var(--text); transition: background .15s, border-color .15s, color .15s, transform .15s; flex: none; }
150
- .pbtn[hidden], .pbtn svg[hidden] { display: none; }
151
+ .pbtn[hidden] { display: none; }
152
+ #chat-action svg { display: none; }
153
+ #chat-action[data-icon="mic"] #ico-mic { display: block; }
154
+ #chat-action[data-icon="send"] #ico-send { display: block; }
155
+ #chat-action[data-icon="stop"] #ico-stop { display: block; }
151
156
  .pbtn.action-btn { background: #ffffff0c; border-color: transparent; color: var(--text); }
152
157
  body.light .pbtn.action-btn { background: #eef1f5; color: #374151; }
153
158
  .pbtn.action-btn:hover:not(:disabled) { border-color: var(--violet); color: var(--violet); }
@@ -182,12 +187,28 @@ body.light .tbadge.run { background: #dbeafe; border-color: #93c5fd; }
182
187
  .progress .bar { position: absolute; inset: 0 auto 0 0; width: 0; background: #24467f; transition: width .2s; }
183
188
  .progress span { position: relative; font-size: 12px; padding: 0 8px; line-height: 22px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
184
189
  .empty { margin-top: 30px; color: var(--muted); line-height: 1.7; max-width: 640px; }
190
+ .empty.live-dash { margin-top: 14px; max-width: none; color: var(--text); line-height: 1.45; }
185
191
  .empty b { color: var(--text); }
192
+ .ld-grid { display: grid; grid-template-columns: 1.15fr 0.85fr; gap: 16px; margin-top: 12px; }
193
+ @media (max-width: 1100px) { .ld-grid { grid-template-columns: 1fr; } }
194
+ .ld-card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; min-width: 0; }
195
+ .ld-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
196
+ .ld-tl { position: relative; height: 26px; background: var(--bg); border: 1px solid var(--line); border-radius: 7px; overflow: hidden; margin: 8px 0 6px; }
197
+ .ld-tl-done { position: absolute; top: 0; bottom: 0; background: rgba(63, 185, 80, 0.16); transition: left .25s, width .25s; }
198
+ .ld-tl-train { position: absolute; top: 2px; bottom: 2px; background: rgba(79, 140, 255, 0.32); border: 1px solid var(--accent); border-radius: 5px; transition: left .25s, width .25s; display: flex; align-items: center; justify-content: center; font-size: 10.5px; font-weight: 600; color: var(--text); overflow: hidden; white-space: nowrap; }
199
+ .ld-tl-test { position: absolute; top: 2px; bottom: 2px; background: rgba(63, 185, 80, 0.42); border: 1px solid var(--green); border-radius: 5px; transition: left .25s, width .25s; }
200
+ .ld-ics { display: flex; align-items: flex-end; gap: 3px; height: 64px; padding-top: 8px; border-bottom: 1px solid var(--line); margin-top: 8px; }
201
+ .ld-ic-bar { flex: 1; min-width: 4px; max-width: 18px; border-radius: 2px 2px 0 0; transition: height .2s; }
202
+ .ld-syms { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
203
+ .ld-sym { font-size: 11px; padding: 2px 7px; border-radius: 5px; background: var(--bg); border: 1px solid var(--line); color: var(--text); }
204
+ .ld-sym.ok { border-color: rgba(63, 185, 80, 0.5); color: var(--green); }
186
205
  .verdict { margin: 16px 0 12px; font-size: 15px; line-height: 1.5; }
187
- .kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
206
+ .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
+ @media (max-width: 1180px) { .kpis { grid-template-columns: repeat(3, 1fr); } }
188
208
  .kpi { background: var(--bg); padding: 10px 14px; cursor: help; }
189
- .kpi .v { font-size: 22px; font-weight: 600; }
190
- .kpi .l { font-size: 12px; color: var(--muted); }
209
+ .kpi .v { font-size: 21px; font-weight: 600; line-height: 1.2; }
210
+ .kpi .l { font-size: 12px; color: var(--muted); margin-top: 2px; }
211
+ .kpi .sub { font-size: 11.5px; color: var(--muted); margin-top: 2px; line-height: 1.3; }
191
212
  .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; margin: 14px 0 6px; }
192
213
  .seg button { border: 0; border-radius: 0; padding: 3px 10px; font-size: 12px; color: var(--muted); }
193
214
  .seg button.active { background: var(--panel); color: var(--text); }
@@ -258,27 +279,29 @@ body.light input[type=checkbox].switch:checked { background: #1a7f37; border-col
258
279
  .headbar .b { height: 6px; background: var(--panel); border-radius: 3px; position: relative; }
259
280
  .headbar .b div { position: absolute; top: 0; bottom: 0; border-radius: 3px; }
260
281
 
261
- .settings-wrap { display: grid; grid-template-columns: 220px minmax(0, 740px); gap: 36px; align-items: start; }
262
- .snav { display: flex; flex-direction: column; gap: 3px; position: sticky; top: 0; }
282
+ .settings-wrap { display: grid; grid-template-columns: 205px minmax(0, 740px); gap: 24px; align-items: start; min-width: 0; }
283
+ @media (max-width: 1100px) { .settings-wrap { grid-template-columns: 180px minmax(0, 1fr); gap: 16px; } }
284
+ .snav { display: flex; flex-direction: column; gap: 3px; position: sticky; top: 0; min-width: 0; }
263
285
  .snav #s-search { width: 100%; margin-bottom: 10px; background: transparent; border-color: var(--line); padding: 6px 10px; font-size: 13px; }
264
286
  .snav button { text-align: left; border: 0; padding: 7px 12px; border-radius: 6px; color: var(--muted); font-size: 13.5px; }
265
287
  .snav button:hover { background: #ffffff08; color: var(--text); }
266
288
  .snav button.active { background: #262b36; color: var(--text); font-weight: 500; }
267
289
  .snav .ssep { height: 1px; background: var(--line); margin: 8px 4px; }
268
290
  .snav .sreset { color: var(--muted); font-size: 12.5px; }
269
- .spane { min-width: 0; }
270
- .ssec { margin-bottom: 24px; }
291
+ .spane { min-width: 0; overflow: hidden; }
292
+ .ssec { margin-bottom: 24px; min-width: 0; }
271
293
  .stitle { font-size: 14px; font-weight: 600; margin: 2px 0 12px; color: var(--text); }
272
294
  .stitle.bar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
273
- .scard { background: #141820; border: 1px solid var(--line); border-radius: 10px; padding: 0 20px; }
295
+ .scard { background: #141820; border: 1px solid var(--line); border-radius: 10px; padding: 0 20px; min-width: 0; overflow: hidden; }
274
296
  .scard.spadded { padding: 16px 20px; }
275
- .srow { display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: center; padding: 15px 0; border-bottom: 1px solid var(--line); }
297
+ .srow { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 20px; align-items: center; padding: 15px 0; border-bottom: 1px solid var(--line); }
298
+ .srow.sblock { display: flex; flex-direction: column; align-items: stretch; gap: 12px; padding: 16px 0; }
276
299
  .srow:last-child { border-bottom: 0; }
277
300
  .srow[hidden] { display: none; }
278
301
  .srow .slbl { font-weight: 600; font-size: 13.5px; color: var(--text); }
279
302
  .srow .hint { font-size: 12.5px; color: var(--muted); margin-top: 3px; line-height: 1.45; }
280
303
  .srow .mark { border: 0; padding: 0 0 0 8px; font-size: 11px; font-weight: 400; color: var(--yellow); text-decoration: underline dotted; }
281
- .srow select.sctrl { background: transparent; border: 1px solid transparent; padding: 5px 8px; font-weight: 500; text-align: right; cursor: pointer; }
304
+ .srow select.sctrl { background: transparent; border: 1px solid transparent; padding: 5px 8px; font-weight: 500; text-align: right; cursor: pointer; max-width: 240px; }
282
305
  .srow select.sctrl:hover, .srow select.sctrl:focus { background: var(--bg); border-color: var(--line); }
283
306
  .srow input[type=number].sctrl { width: 110px; text-align: right; background: var(--bg); }
284
307
  input[type=checkbox].switch { appearance: none; width: 38px; height: 22px; background: #2b313d; border: 1px solid #3a4252; border-radius: 999px; position: relative; cursor: pointer; transition: background .15s, border-color .15s; flex: none; padding: 0; }
@@ -300,11 +323,13 @@ input[type=checkbox].switch:checked::after { transform: translateX(16px); backgr
300
323
  .llm-remote button { padding: 1px 8px; font-size: 12px; border-radius: 999px; color: var(--muted); }
301
324
  .llm-remote button:disabled { opacity: .5; }
302
325
  .row-gap { display: flex; gap: 8px; }
303
- form#llm-add, form#broker-form { display: grid; gap: 10px; }
304
- form#llm-add label, form#broker-form label { display: grid; grid-template-columns: 1fr 1.4fr; gap: 10px; align-items: center; }
326
+ form#llm-add, form#broker-form { display: grid; gap: 10px; min-width: 0; }
327
+ form#llm-add label, form#broker-form label { display: grid; grid-template-columns: minmax(150px, 1fr) minmax(0, 1.4fr); gap: 12px; align-items: center; min-width: 0; }
328
+ form#llm-add label > input:not([type=checkbox]), form#llm-add label > select,
329
+ form#broker-form label > input:not([type=checkbox]), form#broker-form label > select { width: 100%; min-width: 0; max-width: 100%; text-overflow: ellipsis; }
305
330
  form#llm-add label[hidden], form#broker-form label[hidden] { display: none; }
306
- form#llm-add label.check, form#broker-form label.check { grid-template-columns: 1fr auto; }
307
- form#llm-add input[type=checkbox] { width: 18px; height: 18px; }
331
+ form#llm-add label.check, form#broker-form label.check { grid-template-columns: minmax(0, 1fr) auto; }
332
+ form#llm-add input[type=checkbox]:not(.switch) { width: 18px; height: 18px; }
308
333
  .flash { animation: flash 2.5s ease-out; }
309
334
  @keyframes flash { 0% { background: var(--flash-bg); } 100% { background: transparent; } }
310
335
 
@@ -332,6 +357,15 @@ form#llm-add input[type=checkbox] { width: 18px; height: 18px; }
332
357
  table.cmp { font-size: 12.5px; }
333
358
  table.cmp th { padding: 4px 8px; font-size: 12px; }
334
359
  table.cmp td { padding: 3px 8px; }
360
+ .secret-input { -webkit-text-security: disc; text-security: disc; font-family: monospace; }
361
+ .brk-subtabs { display: flex; gap: 6px; margin: 10px 0 8px; border-bottom: 1px solid var(--line); padding-bottom: 6px; }
362
+ .brk-subtabs button { background: transparent; border: 1px solid transparent; padding: 5px 12px; border-radius: 6px; font-weight: 500; color: var(--muted); }
363
+ .brk-subtabs button.active { background: var(--bg); border-color: var(--line); color: var(--text); font-weight: 600; }
364
+ .brk-alloc-wrap { margin: 6px 0 12px; padding: 8px 10px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; }
365
+ .brk-alloc-bar { display: flex; height: 10px; border-radius: 5px; overflow: hidden; gap: 2px; margin-bottom: 7px; background: var(--line); }
366
+ .brk-alloc-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--muted); }
367
+ .brk-alloc-legend span { display: inline-flex; align-items: center; gap: 5px; }
368
+ .brk-alloc-legend i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
335
369
 
336
370
  @media (max-width: 900px) {
337
371
  body { overflow: auto; }
@@ -1,20 +1,21 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import express from 'express';
4
5
  import { ROOT, strategyDefaults } from '../config.js';
5
6
  import { activeModelInfo } from '../llm/client.js';
6
- import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel } from '../llm/providers.js';
7
- import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory } from '../db.js';
8
- import { PLANS, effectivePlan, publicUser, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createPromoCode, listPromoCodes, redeemPromoCode, parseCookies } from '../auth/index.js';
7
+ import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel, resolveUpstreamForProxy, providerFetch } from '../llm/providers.js';
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
10
  import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments } from '../billing/yookassa.js';
10
- import { publicNotifyConfig, saveNotifyConfig, notifyUser } from '../notify/index.js';
11
+ import { publicNotifyConfig, getNotifyConfig, saveNotifyConfig, notifyUser, createTelegramLoginChallenge, checkTelegramLoginChallenge, handleMaxWebhook } from '../notify/index.js';
11
12
  import { PARAMS } from '../params-meta.js';
12
13
  import { getSettings, updateSettings, resetSettings, revertSettingsChange } from '../settings.js';
13
14
  import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
14
15
  import { forecastNow } from '../model/forecast.js';
15
16
  import { profilesInfo, startProfilesRefresh } from '../model/profiles.js';
16
17
  import { live } from '../model/live.js';
17
- import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders } from '../brokers/index.js';
18
+ import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, discoverBrokerAccounts, freezeBrokerBaseline, fetchBrokerHistory, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders } from '../brokers/index.js';
18
19
  import { listNews } from '../news/index.js';
19
20
  import * as strategist from '../strategist/index.js';
20
21
  import { hub } from '../hub.js';
@@ -42,8 +43,62 @@ export function createRouter() {
42
43
  cloudMode: process.env.CLOUD_MODE === '1',
43
44
  plans: PLANS,
44
45
  notify: publicNotifyConfig(),
46
+ oauth: publicOAuthConfig(),
45
47
  billing: { enabled: publicBillingConfig().enabled },
46
48
  }));
49
+ r.get('/auth/oauth/:provider/start', (req, res) => {
50
+ try {
51
+ const origin = `${req.protocol}://${req.get('host')}`;
52
+ const url = startOAuthRedirect(req.params.provider, { origin, plan: req.query.plan || '' });
53
+ res.redirect(url);
54
+ } catch (e) {
55
+ res.redirect(`/landing?oauth_error=${encodeURIComponent(e.message)}`);
56
+ }
57
+ });
58
+ r.get('/auth/oauth/:provider/callback', async (req, res) => {
59
+ try {
60
+ const { user, plan } = await completeOAuthCallback(req.params.provider, req.query || {});
61
+ const token = createSession(user.id);
62
+ setCookie(res, token);
63
+ res.redirect(`/landing?oauth_ok=1${plan ? `&buy_plan=${encodeURIComponent(plan)}` : ''}`);
64
+ } catch (e) {
65
+ res.redirect(`/landing?oauth_error=${encodeURIComponent(e.message)}`);
66
+ }
67
+ });
68
+ r.get('/auth/telegram/callback', (req, res) => {
69
+ try {
70
+ const { tgToken } = getNotifyConfig();
71
+ const user = verifyTelegramLoginWidget(req.query || {}, tgToken);
72
+ const token = createSession(user.id);
73
+ setCookie(res, token);
74
+ res.redirect('/landing?oauth_ok=1');
75
+ } catch (e) {
76
+ res.redirect(`/landing?oauth_error=${encodeURIComponent(e.message)}`);
77
+ }
78
+ });
79
+ // Быстрый вход в 1 клик через бота Telegram или MAX
80
+ r.post('/auth/telegram/start', wrap((req, res) => {
81
+ const ch = req.body?.channel === 'max' ? 'max' : 'tg';
82
+ const nt = publicNotifyConfig();
83
+ if (ch === 'max') {
84
+ if (!nt.hasMaxToken || !nt.maxBotName) throw new Error('Бот MAX ещё не подключён администратором');
85
+ const code = createTelegramLoginChallenge();
86
+ return res.json({ code, botUrl: `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` });
87
+ }
88
+ if (!nt.hasTgToken || !nt.tgBotName) throw new Error('Telegram-бот ещё не подключён администратором');
89
+ const code = createTelegramLoginChallenge();
90
+ res.json({ code, botUrl: `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` });
91
+ }));
92
+ r.get('/auth/telegram/poll', (req, res) => {
93
+ const st = checkTelegramLoginChallenge(req.query.code);
94
+ if (st.status === 'done' && st.userId) {
95
+ const u = getUserById(st.userId);
96
+ const token = createSession(u.id);
97
+ setCookie(res, token);
98
+ return res.json({ status: 'done', user: publicUser(u), token });
99
+ }
100
+ res.json(st);
101
+ });
47
102
  r.post('/auth/register', wrap((req, res) => {
48
103
  const u = registerUser(req.body || {});
49
104
  const token = createSession(u.id);
@@ -103,6 +158,7 @@ export function createRouter() {
103
158
  // Активация API-ключа облачной подписки на локальном npx-клиенте
104
159
  r.post('/license/activate', wrap(async (req, res) => {
105
160
  const lic = await activateLocalLicense(req.body?.apiKey, req.body?.cloudUrl);
161
+ llmChanged();
106
162
  res.json({ ok: true, license: lic, user: publicUser(req.user) });
107
163
  }));
108
164
 
@@ -126,6 +182,9 @@ export function createRouter() {
126
182
  if (pid) await verifyYookassaPayment(pid).catch(() => {});
127
183
  res.json({ ok: true });
128
184
  }));
185
+ r.post('/notify/max/webhook', wrap(async (req, res) => {
186
+ res.json(await handleMaxWebhook(req.body || {}));
187
+ }));
129
188
 
130
189
  // ---------- публичная витрина для лендинга ----------
131
190
  r.get('/public/showcase', (req, res) => {
@@ -144,31 +203,64 @@ export function createRouter() {
144
203
  });
145
204
  if (!runs.length) {
146
205
  try {
147
- const ref = JSON.parse(fs.readFileSync(path.join(ROOT, 'investor', 'данные.json'), 'utf8').replace(/^\uFEFF/, ''));
148
- if (ref?.cmp?.attention?.full) {
206
+ 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) {
149
209
  runs.push({
150
- id: 234,
151
- title: 'Эталонная стратегия «Внимание через карту»',
152
- params: { modelHorizon: 2, modelTopK: ref.params?.topK || 2, modelDirection: 'long', modelType: 'attention' },
153
- metrics: ref.cmp.attention.full,
154
- oos: ref.cmp.attention.oos || {},
155
- period: ref.period || {},
156
- curve: (ref.curves?.attention || []).slice(-180),
157
- marketCurve: (ref.curves?.market || []).slice(-180),
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),
158
218
  });
159
219
  }
160
220
  } catch { /* ignore */ }
161
221
  }
162
- res.json({ runs, plans: PLANS });
222
+ res.json({ runs, plans: PLANS, legal: getLegalInfo() });
163
223
  });
164
224
 
165
- // ---------- админ-панель (пользователи, оплаты, ЮKassa, боты Telegram и MAX, витрина) ----------
225
+ // ---------- реквизиты оферты (для модерации ЮKassa) ----------
226
+ const DEFAULT_LEGAL = {
227
+ ownerName: 'Перепелкин Роман Александрович',
228
+ status: 'Индивидуальный предприниматель / Плательщик НПД',
229
+ inn: '',
230
+ ogrn: '',
231
+ email: 'support@neuroalpha.ru',
232
+ phone: '',
233
+ address: 'Российская Федерация',
234
+ };
235
+ const getLegalInfo = () => ({ ...DEFAULT_LEGAL, ...(kvGet('legalInfo') || {}) });
236
+ r.get('/public/legal', (req, res) => res.json(getLegalInfo()));
237
+
238
+ // ---------- админ-панель (пользователи, оплаты, ЮKassa, боты Telegram и MAX, витрина, реквизиты) ----------
166
239
  const requireAdmin = (req) => {
167
240
  if (!req.user || req.user.role !== 'admin') throw new Error('Доступно только администратору платформы');
168
241
  };
169
242
  r.get('/admin/state', wrap((req, res) => {
170
243
  requireAdmin(req);
171
- res.json({ users: listAllUsers(), payments: listAllPayments(), promos: listPromoCodes(), billing: publicBillingConfig(), notify: publicNotifyConfig() });
244
+ res.json({ users: listAllUsers(), payments: listAllPayments(), promos: listPromoCodes(), billing: publicBillingConfig(), notify: publicNotifyConfig(), oauth: publicOAuthConfig(), legal: getLegalInfo() });
245
+ }));
246
+ r.post('/admin/oauth', wrap((req, res) => {
247
+ requireAdmin(req);
248
+ res.json({ ok: true, oauth: saveOAuthConfig(req.body || {}) });
249
+ }));
250
+ r.post('/admin/legal', wrap((req, res) => {
251
+ requireAdmin(req);
252
+ const b = req.body || {};
253
+ const next = {
254
+ ownerName: String(b.ownerName ?? '').trim(),
255
+ status: String(b.status ?? '').trim(),
256
+ inn: String(b.inn ?? '').trim(),
257
+ ogrn: String(b.ogrn ?? '').trim(),
258
+ email: String(b.email ?? '').trim(),
259
+ phone: String(b.phone ?? '').trim(),
260
+ address: String(b.address ?? '').trim(),
261
+ };
262
+ kvSet('legalInfo', next);
263
+ res.json({ ok: true, legal: getLegalInfo() });
172
264
  }));
173
265
  r.post('/admin/promo', wrap((req, res) => {
174
266
  requireAdmin(req);
@@ -182,9 +274,9 @@ export function createRouter() {
182
274
  requireAdmin(req);
183
275
  res.json(saveBillingConfig(req.body || {}));
184
276
  }));
185
- r.post('/admin/notify', wrap((req, res) => {
277
+ r.post('/admin/notify', wrap(async (req, res) => {
186
278
  requireAdmin(req);
187
- res.json(saveNotifyConfig(req.body || {}));
279
+ res.json(await saveNotifyConfig(req.body || {}));
188
280
  }));
189
281
  r.put('/runs/:id/showcase', wrap((req, res) => {
190
282
  requireAdmin(req);
@@ -205,6 +297,67 @@ export function createRouter() {
205
297
  r.post('/llm/test', wrap(async (req, res) => res.json(await testProvider(req.body?.providerId, req.body?.model))));
206
298
  r.post('/llm/select', wrap((req, res) => { selectModel(req.body?.providerId, req.body?.model); llmChanged(); res.json(llmState()); }));
207
299
 
300
+ // ---------- облачный ИИ-прокси для локальных npx-клиентов по APIKey или в демо-режиме ----------
301
+ const proxyUsesToday = (ident) => {
302
+ const day = new Date().toISOString().slice(0, 10);
303
+ const key = `llmProxy:${ident}:${day}`;
304
+ return { key, count: Number(kvGet(key, 0)) || 0 };
305
+ };
306
+ r.get('/llm/proxy/v1/models', (req, res) => {
307
+ res.json({ data: [{ id: 'Qwen/Qwen3.8-27B-FP8', object: 'model' }] });
308
+ });
309
+ r.post('/llm/proxy/v1/chat/completions', async (req, res) => {
310
+ try {
311
+ const ip = String(req.headers['x-forwarded-for'] || req.socket?.remoteAddress || 'anon').split(',')[0].trim();
312
+ let maxPerDay = 5;
313
+ let ident = `ip:${ip}`;
314
+ let planLabel = 'Демо (без ключа)';
315
+ if (req.user) {
316
+ const plan = effectivePlan(req.user);
317
+ maxPerDay = plan.maxChatPerDay;
318
+ ident = `u:${req.user.id}`;
319
+ planLabel = plan.name;
320
+ }
321
+ const u = proxyUsesToday(ident);
322
+ if (u.count >= maxPerDay) {
323
+ return res.status(429).json({
324
+ error: {
325
+ message: req.user
326
+ ? `Исчерпан дневной лимит ИИ-запросов по тарифу «${planLabel}» (${maxPerDay} в сутки). Расширьте тариф на https://neuroalpha.odant.org`
327
+ : `Исчерпан бесплатный демо-лимит (${maxPerDay} запросов в сутки). Получите бесплатный или платный APIKey на https://neuroalpha.odant.org`,
328
+ },
329
+ });
330
+ }
331
+ const t = resolveUpstreamForProxy();
332
+ if (!t) {
333
+ return res.status(503).json({ error: { message: 'На сервере neuroalpha.odant.org ещё не подключена апстрим-модель ИИ' } });
334
+ }
335
+ kvSet(u.key, u.count + 1);
336
+ const body = { ...(req.body || {}), model: t.model, stream: true };
337
+ if (!body.reasoning_effort && t.effort) body.reasoning_effort = t.effort;
338
+ const upstream = await providerFetch(t.provider, t.url, {
339
+ method: 'POST',
340
+ headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
341
+ body: JSON.stringify(body),
342
+ signal: AbortSignal.timeout(90000),
343
+ });
344
+ if (!upstream.ok) {
345
+ const txt = await upstream.text().catch(() => '');
346
+ return res.status(upstream.status).json({ error: { message: txt.slice(0, 300) || `HTTP ${upstream.status}` } });
347
+ }
348
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
349
+ res.setHeader('Cache-Control', 'no-cache');
350
+ res.setHeader('Connection', 'keep-alive');
351
+ for await (const chunk of upstream.body) {
352
+ res.write(chunk);
353
+ }
354
+ res.end();
355
+ } catch (e) {
356
+ if (!res.headersSent) res.status(500).json({ error: { message: e.message } });
357
+ else res.end();
358
+ }
359
+ });
360
+
208
361
  // ---------- настройки ----------
209
362
  r.get('/settings', (req, res) => res.json(getSettings()));
210
363
  r.put('/settings', wrap((req, res) => res.json(updateSettings(req.body || {}, { source: 'user' }))));
@@ -256,9 +409,6 @@ export function createRouter() {
256
409
  }
257
410
  const body = req.body || {};
258
411
  const merged = { ...getSettings(), ...body };
259
- if ((merged.modelUseTransformer || merged.modelType === 'transformer') && !plan.allowTransformer) {
260
- throw new Error('Обучение нейросети-трансформера доступно на тарифе «Профи / Лаборатория».');
261
- }
262
412
  const { runId, reused } = startModelResearch(merged, { source: 'user', force: !!body.force });
263
413
  res.json({ runId, reused: !!reused });
264
414
  }));
@@ -282,6 +432,7 @@ export function createRouter() {
282
432
 
283
433
  // ---------- брокерские счета (Т-Банк, Финам, Алор, БКС) ----------
284
434
  r.get('/brokers', (req, res) => res.json(brokersState()));
435
+ r.post('/brokers/discover', wrap(async (req, res) => res.json(await discoverBrokerAccounts(req.body || {}))));
285
436
  r.post('/brokers', wrap((req, res) => {
286
437
  const plan = effectivePlan(req.user);
287
438
  const cur = brokersState().accounts.length;
@@ -293,6 +444,8 @@ export function createRouter() {
293
444
  r.put('/brokers/:id', wrap((req, res) => res.json(saveBrokerAccount({ ...(req.body || {}), id: req.params.id }))));
294
445
  r.delete('/brokers/:id', wrap((req, res) => res.json(deleteBrokerAccount(req.params.id))));
295
446
  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))));
448
+ r.post('/brokers/:id/freeze-baseline', wrap(async (req, res) => res.json(await freezeBrokerBaseline(req.params.id))));
296
449
  r.post('/brokers/:id/prepare', wrap(async (req, res) => res.json(await prepareBrokerOrders(req.params.id))));
297
450
  r.post('/brokers/:id/execute', wrap(async (req, res) => res.json(await executeBrokerOrders(req.params.id))));
298
451
  r.post('/brokers/:id/reject', wrap((req, res) => res.json(rejectBrokerOrders(req.params.id))));