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.
- package/LICENSE +13 -17
- package/README.md +4 -2
- package/bin/cli.js +1 -1
- package/deploy/install.sh +1 -1
- 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
- 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
- package/package.json +2 -2
- package/public/admin.html +43 -20
- package/public/app.js +780 -226
- package/public/index.html +43 -3
- package/public/landing.html +328 -115
- package/public/legal.html +37 -16
- package/public/styles.css +24 -3
- package/server/api/routes.js +62 -30
- package/server/auth/index.js +109 -20
- package/server/billing/yookassa.js +1 -1
- package/server/brokers/index.js +256 -53
- package/server/config.js +1 -1
- package/server/data/moex.js +1 -1
- package/server/db.js +17 -3
- package/server/hub.js +1 -1
- package/server/index.js +4 -4
- package/server/llm/client.js +1 -1
- package/server/llm/providers.js +1 -1
- package/server/model/dataset.js +1 -1
- package/server/model/forecast.js +354 -25
- package/server/model/linalg.js +1 -1
- package/server/model/live.js +36 -25
- package/server/model/metrics.js +1 -1
- package/server/model/models.js +1 -1
- package/server/model/profiles.js +1 -1
- package/server/model/research.js +14 -38
- package/server/model/vocab.js +1 -1
- package/server/news/index.js +1 -1
- package/server/notify/index.js +1 -1
- package/server/params-meta.js +4 -3
- package/server/settings.js +1 -1
- package/server/strategist/analytics.js +2 -2
- package/server/strategist/index.js +2 -2
- package/server/strategist/tools.js +1 -1
- package/server/util.js +1 -1
package/server/brokers/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
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:
|
|
160
|
+
baseRunId: nextBaseRunId,
|
|
140
161
|
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
141
|
-
maxCapitalRub
|
|
162
|
+
maxCapitalRub,
|
|
142
163
|
maxDailyDrawdownPct: Math.max(0.5, Math.min(50, Number(input.maxDailyDrawdownPct ?? prev.maxDailyDrawdownPct ?? 3))),
|
|
143
|
-
limitOffsetPct:
|
|
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,
|
|
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 (
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
690
|
-
|
|
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.
|
|
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
|
|
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('Сначала
|
|
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
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/server/data/moex.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
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.
|
|
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'
|
|
372
|
-
|
|
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.
|
|
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.
|
|
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
|
|
60
|
-
//
|
|
61
|
-
if (process.env.CLOUD_MODE === '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();
|
package/server/llm/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
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
|
|
package/server/llm/providers.js
CHANGED
package/server/model/dataset.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
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';
|