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
@@ -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,18 @@ 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);
139
+ const nextBaseRunId = input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null);
140
+ const nextLimitOffset = Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.1)));
141
+ const strategyOrCapChanged = idx < 0
142
+ || acctChanged
143
+ || nextBaseRunId !== (prev.baseRunId || null)
144
+ || maxCapitalRub !== (prev.maxCapitalRub || 100000)
145
+ || legacyMode !== (prev.legacyMode || 'isolate')
146
+ || mode !== (prev.mode || 'confirm')
147
+ || nextLimitOffset !== (prev.limitOffsetPct ?? 0.1);
127
148
  const next = {
128
149
  id,
129
150
  name,
@@ -136,24 +157,34 @@ export function saveBrokerAccount(input = {}) {
136
157
  tbankAccountName: acctChanged ? '' : (prev.tbankAccountName || ''),
137
158
  equityHistory: acctChanged ? [] : (prev.equityHistory || []),
138
159
  lastHistory: acctChanged ? null : (prev.lastHistory || null),
139
- baseRunId: input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null),
160
+ baseRunId: nextBaseRunId,
140
161
  // жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
141
- maxCapitalRub: Math.max(10000, Math.round(Number(input.maxCapitalRub ?? prev.maxCapitalRub ?? 100000))),
162
+ maxCapitalRub,
142
163
  maxDailyDrawdownPct: Math.max(0.5, Math.min(50, Number(input.maxDailyDrawdownPct ?? prev.maxDailyDrawdownPct ?? 3))),
143
- limitOffsetPct: Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.1))),
164
+ limitOffsetPct: nextLimitOffset,
144
165
  skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
145
166
  autoSchedule: input.autoSchedule !== undefined ? Boolean(input.autoSchedule) : (prev.autoSchedule ?? true),
146
- lastScheduledBarTs: prev.lastScheduledBarTs || 0,
167
+ lastScheduledBarTs: strategyOrCapChanged ? 0 : (prev.lastScheduledBarTs || 0),
147
168
  haltedReason: input.resetHalt ? null : (prev.haltedReason || null),
148
169
  token,
149
170
  lastSync: prev.lastSync || null,
150
- pendingBatch: prev.pendingBatch || null,
171
+ pendingBatch: strategyOrCapChanged ? null : (prev.pendingBatch || null),
151
172
  };
152
173
  if (idx >= 0) list[idx] = next;
153
174
  else list.push(next);
154
175
  saveRawAccounts(list);
155
176
  const st = brokersState();
156
177
  hub.emit({ type: 'brokers:update', state: st });
178
+ // При изменении стратегии, лимита капитала или режима счёта сразу пересинхронизируем счёт и пересчитываем заявки под новые настройки
179
+ if (strategyOrCapChanged && token) {
180
+ const activeUser = currentUser();
181
+ setImmediate(() => {
182
+ runWithUser(activeUser, async () => {
183
+ await syncBrokerAccount(id).catch(() => {});
184
+ await prepareBrokerOrders(id).catch(() => {});
185
+ });
186
+ });
187
+ }
157
188
  return st;
158
189
  }
159
190
 
@@ -252,7 +283,7 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
252
283
  dirty = true;
253
284
  }
254
285
  }
255
- const missing = [...new Set(keys.filter((k) => k && !cache[k]))].slice(0, 25);
286
+ const missing = [...new Set(keys.filter((k) => k && !cache[k]))].slice(0, 60);
256
287
  await Promise.all(missing.map(async (id) => {
257
288
  try {
258
289
  const idType = id.includes('-') ? 'INSTRUMENT_ID_TYPE_UID' : 'INSTRUMENT_ID_TYPE_FIGI';
@@ -318,7 +349,7 @@ async function syncTbank(acc) {
318
349
  }
319
350
  if (!accountId) throw new Error('В Т-Банке не найдено открытых счетов (проверьте токен API)');
320
351
 
321
- const [rPort, rOrd, rMargin] = await Promise.all([
352
+ const [rPort, rOrd, rMargin, rRecentOps] = await Promise.all([
322
353
  brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetPortfolio`, {
323
354
  method: 'POST', headers, body: JSON.stringify({ accountId, currency: 'RUB' }), timeoutMs: 15000,
324
355
  }),
@@ -328,8 +359,31 @@ async function syncTbank(acc) {
328
359
  brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.UsersService/GetMarginAttributes`, {
329
360
  method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
330
361
  }).catch(() => null),
362
+ brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
363
+ method: 'POST',
364
+ headers,
365
+ body: JSON.stringify({
366
+ accountId,
367
+ from: new Date(Date.now() - 35 * DAY).toISOString(),
368
+ to: new Date().toISOString(),
369
+ state: 'OPERATION_STATE_EXECUTED',
370
+ }),
371
+ timeoutMs: 10000,
372
+ }).catch(() => null),
331
373
  ]);
332
374
  if (!rPort.ok) throw new Error(`T-Invest GetPortfolio: HTTP ${rPort.status}`);
375
+ let autoFollowDetected = /автослед|сигнал|copy|follow|comon|strategy/i.test(accountName);
376
+ if (rRecentOps?.ok) {
377
+ const jOps = await rRecentOps.json().catch(() => ({}));
378
+ for (const op of jOps.operations || []) {
379
+ const ot = String(op.operationType || '');
380
+ const tp = String(op.type || '');
381
+ if (ot.includes('TRACK_MFEE') || ot.includes('TRACK_PFEE') || ot.includes('ADVICE_FEE') || /автослед/i.test(tp)) {
382
+ autoFollowDetected = true;
383
+ break;
384
+ }
385
+ }
386
+ }
333
387
  let marginEnabled = false;
334
388
  if (accountType === 'ACCOUNT_TYPE_TINKOFF_IIS' || accountType === 'ACCOUNT_TYPE_INVEST_BOX') {
335
389
  marginEnabled = false;
@@ -424,6 +478,7 @@ async function syncTbank(acc) {
424
478
  accountName,
425
479
  accountType,
426
480
  marginEnabled,
481
+ autoFollowDetected,
427
482
  totalRub: totalRub || cashRub,
428
483
  cashRub: round(cashRub, 2),
429
484
  totalPnlRub: round(totalPnlRub, 2),
@@ -464,8 +519,9 @@ export async function discoverBrokerAccounts(input = {}) {
464
519
  };
465
520
  const out = await Promise.all(rawAccs.map(async (a) => {
466
521
  let totalRub = null, cashRub = null, positionsCount = 0, marginEnabled = false;
522
+ let autoFollowDetected = /автослед|сигнал|copy|follow|comon|strategy/i.test(a.name || '');
467
523
  try {
468
- const [rp, rm] = await Promise.all([
524
+ const [rp, rm, rops] = await Promise.all([
469
525
  brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetPortfolio`, {
470
526
  method: 'POST', headers, body: JSON.stringify({ accountId: a.id, currency: 'RUB' }), timeoutMs: 10000,
471
527
  }),
@@ -474,8 +530,30 @@ export async function discoverBrokerAccounts(input = {}) {
474
530
  method: 'POST', headers, body: JSON.stringify({ accountId: a.id }), timeoutMs: 8000,
475
531
  }).catch(() => null)
476
532
  : Promise.resolve(null),
533
+ brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
534
+ method: 'POST',
535
+ headers,
536
+ body: JSON.stringify({
537
+ accountId: a.id,
538
+ from: new Date(Date.now() - 35 * DAY).toISOString(),
539
+ to: new Date().toISOString(),
540
+ state: 'OPERATION_STATE_EXECUTED',
541
+ }),
542
+ timeoutMs: 8000,
543
+ }).catch(() => null),
477
544
  ]);
478
545
  if (rm?.ok) marginEnabled = true;
546
+ if (rops?.ok) {
547
+ const jOps = await rops.json().catch(() => ({}));
548
+ for (const op of jOps.operations || []) {
549
+ const ot = String(op.operationType || '');
550
+ const tp = String(op.type || '');
551
+ if (ot.includes('TRACK_MFEE') || ot.includes('TRACK_PFEE') || ot.includes('ADVICE_FEE') || /автослед/i.test(tp)) {
552
+ autoFollowDetected = true;
553
+ break;
554
+ }
555
+ }
556
+ }
479
557
  if (rp.ok) {
480
558
  const pj = await rp.json();
481
559
  totalRub = round(qVal(pj.totalAmountPortfolio), 0);
@@ -495,6 +573,7 @@ export async function discoverBrokerAccounts(input = {}) {
495
573
  typeLabel: TYPE_MAP[a.type] || 'Счёт',
496
574
  accessLabel: ACCESS_MAP[a.accessLevel] || '',
497
575
  marginEnabled,
576
+ autoFollowDetected,
498
577
  totalRub,
499
578
  cashRub,
500
579
  positionsCount,
@@ -613,7 +692,9 @@ export async function syncBrokerAccount(id) {
613
692
  const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
614
693
  const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
615
694
  const ddPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
616
- if (ddPct >= acc.maxDailyDrawdownPct) {
695
+ if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
696
+ acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
697
+ } else if (ddPct >= acc.maxDailyDrawdownPct) {
617
698
  acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
618
699
  }
619
700
  // История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
@@ -637,13 +718,22 @@ export async function syncBrokerAccount(id) {
637
718
  return st;
638
719
  }
639
720
 
640
- // Зафиксировать текущие позиции счёта как «неприкосновенный стартовый портфель» (baselineQty)
721
+ // Переключатель (тогл) защиты текущих бумаг на счёте:
722
+ // включает режим «Изолированный карман» (isolate) с фиксацией текущего снимка baselineQty,
723
+ // либо при повторном нажатии переключает обратно в «Плавный переход» (smooth)
641
724
  export async function freezeBrokerBaseline(id) {
642
725
  const list = loadRawAccounts();
643
726
  const acc = list.find((x) => x.id === id);
644
727
  if (!acc) throw new Error('Счёт не найден');
645
728
  const positions = acc.lastSync?.positions || [];
646
- acc.baselineQty = Object.fromEntries(positions.filter((p) => p.qty !== 0).map((p) => [p.symbol, p.qty]));
729
+ const hasBaseline = acc.baselineQty && Object.keys(acc.baselineQty).length > 0;
730
+ const isProtectedNow = (acc.legacyMode || 'isolate') === 'isolate' && hasBaseline;
731
+ if (isProtectedNow) {
732
+ acc.legacyMode = 'smooth';
733
+ } else {
734
+ acc.legacyMode = 'isolate';
735
+ acc.baselineQty = Object.fromEntries(positions.filter((p) => p.qty !== 0).map((p) => [p.symbol, p.qty]));
736
+ }
647
737
  saveRawAccounts(list);
648
738
  const st = brokersState();
649
739
  hub.emit({ type: 'brokers:update', state: st });
@@ -669,27 +759,58 @@ export async function prepareBrokerOrders(id) {
669
759
  if (acc.haltedReason) throw new Error(acc.haltedReason);
670
760
  const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
671
761
 
762
+ const plan = effectivePlan(currentUser());
763
+ if ((plan.maxBrokerAccounts ?? 0) < 1) {
764
+ throw new Error(`На бесплатном тарифе «${plan.name}» работа с реальными брокерскими счетами недоступна (доступно тестирование в Экспериментах и виртуальный счёт). Оформите тариф «Инвестор», «Профи» или «VIP».`);
765
+ }
672
766
  const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
673
767
  const params = normalizeParams(baseRun?.params || getSettings());
674
768
  const [fc, specs] = await Promise.all([
675
769
  forecastNow(params),
676
770
  ensureBoardSpecs(),
677
771
  ]);
678
- const budget = Math.min(acc.maxCapitalRub, acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : acc.maxCapitalRub);
772
+ const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
773
+ const effectiveMaxCap = Math.min(acc.maxCapitalRub, capCeiling);
774
+ const budget = Math.min(effectiveMaxCap, acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : effectiveMaxCap);
679
775
  const K = params.modelTopK || 3;
680
- // Автоматическая подстройка направления торговли по реальным данным счёта:
681
- // если на брокерском счёте отключена маржинальная торговля (или это ИИС), короткие продажи (шорты) автоматически отключаются
776
+ // Автоматическая подстройка направления и мощности торговли под тариф и реальные данные счёта:
682
777
  const marginAllowed = acc.lastSync?.marginEnabled !== false;
778
+ const planAllowsShorts = Boolean(plan.allowShorts);
779
+ const vipBoost = Boolean(plan.allowVipBoost);
683
780
  const rawDir = params.modelDirection || 'long';
684
- const dir = marginAllowed ? rawDir : 'long';
781
+ const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
685
782
  const autoAdapt = params.modelAutoAdapt !== false;
686
783
  const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
687
784
  const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
688
785
  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;
786
+ // Фильтр мощности слота по тарифу:
787
+ // • Инвестор (до 50% годовых): только покупки (без шортов), консервативный слот 1 / (K + 1) (до 22% капитала)
788
+ // • Профи (до 100% годовых): покупки + шорты, стандартный равный слот 1 / (2K) (до 25% капитала, шаг раз в полный горизонт H)
789
+ // • VIP (без ограничений): полный форсаж (Conviction Slot Sizing до 42% капитала в лучших сигналах + полушаговый перехват H/2)
790
+ let slotRub;
791
+ if (vipBoost && autoAdapt) {
792
+ const maxSlotWeight = dir === 'long' ? Math.min(0.48, Math.max(1 / K, 1.4 / K)) : Math.min(0.48, 1.9 / (2 * K));
793
+ slotRub = budget * maxSlotWeight;
794
+ } else if (plan.id === 'pro') {
795
+ slotRub = budget * Math.min(0.25, 1 / Math.max(2, slots));
796
+ } else {
797
+ slotRub = budget * Math.min(0.22, 1 / Math.max(3, K + 1));
798
+ }
799
+ // Дополнительный мягкий дроссель при опережении годового потолка доходности тарифа (50% для Инвестора, 100% для Профи)
800
+ if (!vipBoost && plan.maxAnnualReturnPct > 0 && Array.isArray(acc.equityHistory) && acc.equityHistory.length >= 2) {
801
+ const firstPt = acc.equityHistory[0];
802
+ const lastPt = acc.equityHistory[acc.equityHistory.length - 1];
803
+ const elapsedDays = Math.max(7, (lastPt.ts - firstPt.ts) / DAY);
804
+ if (firstPt.totalRub > 0 && lastPt.totalRub > firstPt.totalRub) {
805
+ const curGainPct = ((lastPt.totalRub - firstPt.totalRub) / firstPt.totalRub) * 100;
806
+ const targetGainPct = plan.maxAnnualReturnPct * (elapsedDays / 365);
807
+ if (curGainPct > targetGainPct) {
808
+ slotRub *= Math.max(0.25, targetGainPct / curGainPct);
809
+ }
810
+ }
811
+ }
691
812
  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);
813
+ const convOf = (r) => (vipBoost && autoAdapt && r.expectedPct ? Math.min(1.25, Math.max(0.85, 0.80 + 0.25 * (Math.abs(r.expectedPct) / needPct))) : 1);
693
814
  const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
694
815
  const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
695
816
  const quarantined = new Set(baseRun?.extra?.adaptation?.quarantined || []);
@@ -697,7 +818,7 @@ export async function prepareBrokerOrders(id) {
697
818
 
698
819
  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
820
  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 : [];
821
+ const shortCands = (marginAllowed && planAllowsShorts) ? rawShortCands : [];
701
822
 
702
823
  const legacyMode = acc.legacyMode || 'isolate';
703
824
  const baseline = acc.baselineQty || {};
@@ -836,11 +957,21 @@ export async function prepareBrokerOrders(id) {
836
957
  }
837
958
 
838
959
  let diagNote = '';
960
+ let planThrottleNote = '';
961
+ if (plan.id === 'investor') {
962
+ const shInfo = rawShortCands.length ? ` (пропущены сигналы на шорт: ${rawShortCands.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
963
+ planThrottleNote = `Тариф «Инвестор» (целевой потолок до 50% годовых, лимит до 1 млн ₽/счёт): активен режим «Только в рост (без шорта)» и консервативный слот 22%${shInfo}. Для включения заработка на падении акций (шорты, до 100% годовых и до 10 млн ₽/счёт) перейдите на тариф «Профи».`;
964
+ } else if (plan.id === 'pro') {
965
+ planThrottleNote = 'Тариф «Профи» (целевой потолок до 100% годовых, лимит до 10 млн ₽/счёт): активны покупки и шорты со стандартным шагом H и базовым слотом 25%. Для снятия всех ограничений (внутричасовой перехват H/2 + концентрация слота Conviction Sizing до 42%, как в максимальных экспериментах) подключите тариф «VIP».';
966
+ }
839
967
  if (!orders.length) {
840
968
  if ((newBuys.length || newShorts.length) && availRub < 1500) {
841
969
  const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
842
970
  const cashNow = round(acc.lastSync?.cashRub || 0, 0);
843
971
  diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
972
+ } else if (!newBuys.length && !planAllowsShorts && rawShortCands.length > 0) {
973
+ const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
974
+ diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 50% годовых). Чтобы зарабатывать и на падении акций (до 100% годовых), перейдите на тариф «Профи» или «VIP».`;
844
975
  } else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
845
976
  const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
846
977
  diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
@@ -857,6 +988,8 @@ export async function prepareBrokerOrders(id) {
857
988
  availRub: round(availRub, 0),
858
989
  effectiveDirection: dir,
859
990
  marginAllowed,
991
+ planId: plan.id,
992
+ planThrottleNote,
860
993
  edgeWarning: edgeWarn,
861
994
  diagNote,
862
995
  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,44 +1193,60 @@ export function rejectBrokerOrders(id) {
1060
1193
  return st;
1061
1194
  }
1062
1195
 
1063
- // ---------- история операций брокерского счёта (покупки, продажи, комиссии, дивиденды/купоны, пополнения) ----------
1064
- export async function fetchBrokerHistory(id) {
1196
+ // ---------- история операций брокерского счёта (покупки, продажи, комиссии, дивиденды/купоны, пополнения; ленивая подгрузка по годам) ----------
1197
+ export async function fetchBrokerHistory(id, opts = {}) {
1065
1198
  const list = loadRawAccounts();
1066
1199
  const acc = list.find((x) => x.id === id);
1067
1200
  if (!acc) throw new Error('Счёт не найден');
1068
1201
  if (!acc.token) throw new Error('У счёта не задан токен API');
1069
1202
  if (acc.broker !== 'tbank') {
1070
- return { operations: [], summary: { tradesCount: 0, buySumRub: 0, sellSumRub: 0, commissionsRub: 0, dividendsRub: 0, inOutRub: 0 } };
1203
+ return brokersState();
1071
1204
  }
1072
1205
  const base = acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest';
1073
1206
  const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
1074
1207
  const accountId = acc.accountId || acc.lastSync?.accountId;
1075
- 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))];
1088
- const metaMap = await resolveTbankMetaMap(base, headers, figis);
1208
+ if (!accountId) throw new Error('Сначала дождитесь синхронизации портфеля, чтобы определить ID счёта');
1089
1209
 
1090
- let tradesCount = 0, buySumRub = 0, sellSumRub = 0, commissionsRub = 0, dividendsRub = 0, inOutRub = 0;
1091
- for (const op of rawOps) {
1092
- const pay = qVal(op.payment);
1093
- const t = String(op.operationType || '');
1094
- if (t.includes('BUY')) { tradesCount++; buySumRub += Math.abs(pay); }
1095
- else if (t.includes('SELL')) { tradesCount++; sellSumRub += Math.abs(pay); }
1096
- else if (t.includes('FEE') || t.includes('COMMISSION') || t.includes('TAX')) { commissionsRub += Math.abs(pay); }
1097
- else if (t.includes('DIVIDEND') || t.includes('COUPON')) { dividendsRub += pay; }
1098
- else if (t.includes('INPUT') || t.includes('OUTPUT')) { inOutRub += pay; }
1210
+ const curYear = new Date(Date.now() + MSK).getUTCFullYear();
1211
+ const targetYear = Number(opts?.year) || curYear;
1212
+ const intervals = [];
1213
+ const startH2 = Date.UTC(targetYear, 6, 1);
1214
+ const endH2 = Math.min(Date.now(), Date.UTC(targetYear, 11, 31, 23, 59, 59));
1215
+ if (startH2 < Date.now()) intervals.push([new Date(startH2).toISOString(), new Date(endH2).toISOString()]);
1216
+ const startH1 = Date.UTC(targetYear, 0, 1);
1217
+ const endH1 = Math.min(Date.now(), Date.UTC(targetYear, 5, 30, 23, 59, 59));
1218
+ if (startH1 < Date.now()) intervals.push([new Date(startH1).toISOString(), new Date(endH1).toISOString()]);
1219
+
1220
+ const chunkResults = await Promise.all(intervals.map(async ([fromIso, toIso]) => {
1221
+ try {
1222
+ const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
1223
+ method: 'POST',
1224
+ headers,
1225
+ body: JSON.stringify({ accountId, from: fromIso, to: toIso, state: 'OPERATION_STATE_EXECUTED' }),
1226
+ timeoutMs: 20000,
1227
+ });
1228
+ if (!r.ok) return [];
1229
+ const j = await r.json();
1230
+ return j.operations || [];
1231
+ } catch {
1232
+ return [];
1233
+ }
1234
+ }));
1235
+
1236
+ const seenIds = new Set();
1237
+ const rawOps = [];
1238
+ for (const arr of chunkResults) {
1239
+ for (const op of arr) {
1240
+ const key = op.id || `${op.date}_${op.operationType}_${op.figi}_${op.payment?.units}`;
1241
+ if (seenIds.has(key)) continue;
1242
+ seenIds.add(key);
1243
+ rawOps.push(op);
1244
+ }
1099
1245
  }
1100
1246
 
1247
+ const figis = [...new Set(rawOps.map((o) => o.figi || o.instrumentUid).filter(Boolean))];
1248
+ const metaMap = await resolveTbankMetaMap(base, headers, figis);
1249
+
1101
1250
  const OP_KIND = (t = '') => {
1102
1251
  if (t.includes('BUY')) return ['buy', 'Покупка'];
1103
1252
  if (t.includes('SELL')) return ['sell', 'Продажа'];
@@ -1110,9 +1259,7 @@ export async function fetchBrokerHistory(id) {
1110
1259
  return ['other', 'Операция'];
1111
1260
  };
1112
1261
 
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) => {
1262
+ const fetchedOps = rawOps.map((op) => {
1116
1263
  const m = metaMap[op.figi] || metaMap[op.instrumentUid] || {};
1117
1264
  const [kind, kindLabel] = OP_KIND(op.operationType);
1118
1265
  return {
@@ -1129,9 +1276,35 @@ export async function fetchBrokerHistory(id) {
1129
1276
  };
1130
1277
  });
1131
1278
 
1279
+ // Сохраняем операции других годов, которые уже были загружены ранее, и заменяем только targetYear
1280
+ const prevOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
1281
+ const otherYearsOps = prevOps.filter((op) => {
1282
+ const y = op.date ? new Date(op.date + MSK).getUTCFullYear() : curYear;
1283
+ return y !== targetYear;
1284
+ });
1285
+ const mergedOps = [...fetchedOps, ...otherYearsOps]
1286
+ .sort((a, b) => (b.date || 0) - (a.date || 0))
1287
+ .slice(0, 6000);
1288
+
1289
+ let tradesCount = 0, buySumRub = 0, sellSumRub = 0, commissionsRub = 0, dividendsRub = 0, inOutRub = 0;
1290
+ for (const op of mergedOps) {
1291
+ const pay = Number(op.paymentRub) || 0;
1292
+ if (op.kind === 'buy') { tradesCount++; buySumRub += Math.abs(pay); }
1293
+ else if (op.kind === 'sell') { tradesCount++; sellSumRub += Math.abs(pay); }
1294
+ else if (op.kind === 'fee') { commissionsRub += Math.abs(pay); }
1295
+ else if (op.kind === 'div') { dividendsRub += pay; }
1296
+ else if (op.kind === 'in' || op.kind === 'out') { inOutRub += pay; }
1297
+ }
1298
+
1299
+ const prevLoadedYears = Array.isArray(acc.lastHistory?.loadedYears)
1300
+ ? acc.lastHistory.loadedYears
1301
+ : [...new Set(prevOps.map((op) => op.date ? new Date(op.date + MSK).getUTCFullYear() : null).filter(Boolean))];
1302
+ const loadedYears = [...new Set([...prevLoadedYears, targetYear])].sort((a, b) => b - a);
1303
+
1132
1304
  const histData = {
1133
1305
  at: Date.now(),
1134
- totalCount: rawOps.length,
1306
+ loadedYears,
1307
+ totalCount: mergedOps.length,
1135
1308
  summary: {
1136
1309
  tradesCount,
1137
1310
  buySumRub: round(buySumRub, 0),
@@ -1140,7 +1313,7 @@ export async function fetchBrokerHistory(id) {
1140
1313
  dividendsRub: round(dividendsRub, 0),
1141
1314
  inOutRub: round(inOutRub, 0),
1142
1315
  },
1143
- operations,
1316
+ operations: mergedOps,
1144
1317
  };
1145
1318
  acc.lastHistory = histData;
1146
1319
  saveRawAccounts(list);
@@ -1171,6 +1344,8 @@ async function tickBrokerSchedule() {
1171
1344
  await runWithUser(u, async () => {
1172
1345
  const list = loadRawAccounts();
1173
1346
  if (!list.length) return;
1347
+ const plan = effectivePlan(u);
1348
+ if ((plan.maxBrokerAccounts ?? 0) < 1) return;
1174
1349
  for (const acc of list) {
1175
1350
  if (acc.autoSchedule === false || acc.haltedReason) continue;
1176
1351
  const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
@@ -1178,7 +1353,8 @@ async function tickBrokerSchedule() {
1178
1353
  const autoAdapt = params.modelAutoAdapt !== false;
1179
1354
  const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
1180
1355
  const H = barsFromHours(effHorizon);
1181
- const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
1356
+ // Внутричасовой перехват H/5 (каждые 30 мин) активен в режиме VIP (полный форсаж); на тарифах Инвестор и Профи шаг равен полному горизонту H
1357
+ const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(3, Math.floor(H / 5)) : H;
1182
1358
  if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
1183
1359
  acc.lastScheduledBarTs = latest;
1184
1360
  saveRawAccounts(list);
@@ -1217,8 +1393,35 @@ async function resumeBrokerAccountsOnBoot() {
1217
1393
  } catch { /* ignore startup sync errors */ }
1218
1394
  }
1219
1395
 
1396
+ // Автоматическое периодическое фоновое обновление портфеля и истории текущего года (раз в 2 минуты)
1397
+ let autoRefreshBusy = false;
1398
+ async function tickBrokerAutoRefresh() {
1399
+ if (autoRefreshBusy || schedulerBusy) return;
1400
+ autoRefreshBusy = true;
1401
+ try {
1402
+ const curYear = new Date(Date.now() + MSK).getUTCFullYear();
1403
+ const users = db.prepare('SELECT * FROM users').all();
1404
+ for (const u of users) {
1405
+ await runWithUser(u, async () => {
1406
+ const list = loadRawAccounts();
1407
+ for (const acc of list) {
1408
+ if (!acc.token) continue;
1409
+ await syncBrokerAccount(acc.id).catch(() => {});
1410
+ if (acc.broker === 'tbank') {
1411
+ await fetchBrokerHistory(acc.id, { year: curYear }).catch(() => {});
1412
+ }
1413
+ }
1414
+ });
1415
+ }
1416
+ } finally {
1417
+ autoRefreshBusy = false;
1418
+ }
1419
+ }
1420
+
1220
1421
  export function startBrokerScheduler() {
1221
1422
  setTimeout(resumeBrokerAccountsOnBoot, 2500);
1222
1423
  const timer = setInterval(tickBrokerSchedule, 60000);
1424
+ const syncTimer = setInterval(tickBrokerAutoRefresh, 120000);
1223
1425
  timer.unref?.();
1426
+ syncTimer.unref?.();
1224
1427
  }
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';