neuroalpha 0.3.12 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +524 -211
- 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 +61 -29
- package/server/auth/index.js +109 -20
- package/server/billing/yookassa.js +1 -1
- package/server/brokers/index.js +179 -34
- 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 +188 -17
- package/server/model/linalg.js +1 -1
- package/server/model/live.js +6 -19
- 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 +12 -36
- 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,9 @@ export function saveBrokerAccount(input = {}) {
|
|
|
124
133
|
else if (typeof input.token === 'string' && input.token.trim()) token = input.token.trim();
|
|
125
134
|
|
|
126
135
|
const acctChanged = input.accountId !== undefined && String(input.accountId).trim() !== String(prev.accountId || '').trim();
|
|
136
|
+
const rawMaxCap = Math.max(10000, Math.round(Number(input.maxCapitalRub ?? prev.maxCapitalRub ?? 100000)));
|
|
137
|
+
const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
|
|
138
|
+
const maxCapitalRub = Math.min(rawMaxCap, capCeiling);
|
|
127
139
|
const next = {
|
|
128
140
|
id,
|
|
129
141
|
name,
|
|
@@ -138,7 +150,7 @@ export function saveBrokerAccount(input = {}) {
|
|
|
138
150
|
lastHistory: acctChanged ? null : (prev.lastHistory || null),
|
|
139
151
|
baseRunId: input.baseRunId !== undefined ? (Number(input.baseRunId) || null) : (prev.baseRunId || null),
|
|
140
152
|
// жёсткие предохранители (недоступны для изменения ИИ-Стратегом)
|
|
141
|
-
maxCapitalRub
|
|
153
|
+
maxCapitalRub,
|
|
142
154
|
maxDailyDrawdownPct: Math.max(0.5, Math.min(50, Number(input.maxDailyDrawdownPct ?? prev.maxDailyDrawdownPct ?? 3))),
|
|
143
155
|
limitOffsetPct: Math.max(0, Math.min(2, Number(input.limitOffsetPct ?? prev.limitOffsetPct ?? 0.1))),
|
|
144
156
|
skipEdgeMinutes: input.skipEdgeMinutes !== undefined ? Boolean(input.skipEdgeMinutes) : (prev.skipEdgeMinutes ?? true),
|
|
@@ -252,7 +264,7 @@ async function resolveTbankMetaMap(base, headers, keys = []) {
|
|
|
252
264
|
dirty = true;
|
|
253
265
|
}
|
|
254
266
|
}
|
|
255
|
-
const missing = [...new Set(keys.filter((k) => k && !cache[k]))].slice(0,
|
|
267
|
+
const missing = [...new Set(keys.filter((k) => k && !cache[k]))].slice(0, 60);
|
|
256
268
|
await Promise.all(missing.map(async (id) => {
|
|
257
269
|
try {
|
|
258
270
|
const idType = id.includes('-') ? 'INSTRUMENT_ID_TYPE_UID' : 'INSTRUMENT_ID_TYPE_FIGI';
|
|
@@ -318,7 +330,7 @@ async function syncTbank(acc) {
|
|
|
318
330
|
}
|
|
319
331
|
if (!accountId) throw new Error('В Т-Банке не найдено открытых счетов (проверьте токен API)');
|
|
320
332
|
|
|
321
|
-
const [rPort, rOrd, rMargin] = await Promise.all([
|
|
333
|
+
const [rPort, rOrd, rMargin, rRecentOps] = await Promise.all([
|
|
322
334
|
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetPortfolio`, {
|
|
323
335
|
method: 'POST', headers, body: JSON.stringify({ accountId, currency: 'RUB' }), timeoutMs: 15000,
|
|
324
336
|
}),
|
|
@@ -328,8 +340,31 @@ async function syncTbank(acc) {
|
|
|
328
340
|
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.UsersService/GetMarginAttributes`, {
|
|
329
341
|
method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
|
|
330
342
|
}).catch(() => null),
|
|
343
|
+
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
headers,
|
|
346
|
+
body: JSON.stringify({
|
|
347
|
+
accountId,
|
|
348
|
+
from: new Date(Date.now() - 35 * DAY).toISOString(),
|
|
349
|
+
to: new Date().toISOString(),
|
|
350
|
+
state: 'OPERATION_STATE_EXECUTED',
|
|
351
|
+
}),
|
|
352
|
+
timeoutMs: 10000,
|
|
353
|
+
}).catch(() => null),
|
|
331
354
|
]);
|
|
332
355
|
if (!rPort.ok) throw new Error(`T-Invest GetPortfolio: HTTP ${rPort.status}`);
|
|
356
|
+
let autoFollowDetected = /автослед|сигнал|copy|follow|comon|strategy/i.test(accountName);
|
|
357
|
+
if (rRecentOps?.ok) {
|
|
358
|
+
const jOps = await rRecentOps.json().catch(() => ({}));
|
|
359
|
+
for (const op of jOps.operations || []) {
|
|
360
|
+
const ot = String(op.operationType || '');
|
|
361
|
+
const tp = String(op.type || '');
|
|
362
|
+
if (ot.includes('TRACK_MFEE') || ot.includes('TRACK_PFEE') || ot.includes('ADVICE_FEE') || /автослед/i.test(tp)) {
|
|
363
|
+
autoFollowDetected = true;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
333
368
|
let marginEnabled = false;
|
|
334
369
|
if (accountType === 'ACCOUNT_TYPE_TINKOFF_IIS' || accountType === 'ACCOUNT_TYPE_INVEST_BOX') {
|
|
335
370
|
marginEnabled = false;
|
|
@@ -424,6 +459,7 @@ async function syncTbank(acc) {
|
|
|
424
459
|
accountName,
|
|
425
460
|
accountType,
|
|
426
461
|
marginEnabled,
|
|
462
|
+
autoFollowDetected,
|
|
427
463
|
totalRub: totalRub || cashRub,
|
|
428
464
|
cashRub: round(cashRub, 2),
|
|
429
465
|
totalPnlRub: round(totalPnlRub, 2),
|
|
@@ -464,8 +500,9 @@ export async function discoverBrokerAccounts(input = {}) {
|
|
|
464
500
|
};
|
|
465
501
|
const out = await Promise.all(rawAccs.map(async (a) => {
|
|
466
502
|
let totalRub = null, cashRub = null, positionsCount = 0, marginEnabled = false;
|
|
503
|
+
let autoFollowDetected = /автослед|сигнал|copy|follow|comon|strategy/i.test(a.name || '');
|
|
467
504
|
try {
|
|
468
|
-
const [rp, rm] = await Promise.all([
|
|
505
|
+
const [rp, rm, rops] = await Promise.all([
|
|
469
506
|
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetPortfolio`, {
|
|
470
507
|
method: 'POST', headers, body: JSON.stringify({ accountId: a.id, currency: 'RUB' }), timeoutMs: 10000,
|
|
471
508
|
}),
|
|
@@ -474,8 +511,30 @@ export async function discoverBrokerAccounts(input = {}) {
|
|
|
474
511
|
method: 'POST', headers, body: JSON.stringify({ accountId: a.id }), timeoutMs: 8000,
|
|
475
512
|
}).catch(() => null)
|
|
476
513
|
: Promise.resolve(null),
|
|
514
|
+
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
headers,
|
|
517
|
+
body: JSON.stringify({
|
|
518
|
+
accountId: a.id,
|
|
519
|
+
from: new Date(Date.now() - 35 * DAY).toISOString(),
|
|
520
|
+
to: new Date().toISOString(),
|
|
521
|
+
state: 'OPERATION_STATE_EXECUTED',
|
|
522
|
+
}),
|
|
523
|
+
timeoutMs: 8000,
|
|
524
|
+
}).catch(() => null),
|
|
477
525
|
]);
|
|
478
526
|
if (rm?.ok) marginEnabled = true;
|
|
527
|
+
if (rops?.ok) {
|
|
528
|
+
const jOps = await rops.json().catch(() => ({}));
|
|
529
|
+
for (const op of jOps.operations || []) {
|
|
530
|
+
const ot = String(op.operationType || '');
|
|
531
|
+
const tp = String(op.type || '');
|
|
532
|
+
if (ot.includes('TRACK_MFEE') || ot.includes('TRACK_PFEE') || ot.includes('ADVICE_FEE') || /автослед/i.test(tp)) {
|
|
533
|
+
autoFollowDetected = true;
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
479
538
|
if (rp.ok) {
|
|
480
539
|
const pj = await rp.json();
|
|
481
540
|
totalRub = round(qVal(pj.totalAmountPortfolio), 0);
|
|
@@ -495,6 +554,7 @@ export async function discoverBrokerAccounts(input = {}) {
|
|
|
495
554
|
typeLabel: TYPE_MAP[a.type] || 'Счёт',
|
|
496
555
|
accessLabel: ACCESS_MAP[a.accessLevel] || '',
|
|
497
556
|
marginEnabled,
|
|
557
|
+
autoFollowDetected,
|
|
498
558
|
totalRub,
|
|
499
559
|
cashRub,
|
|
500
560
|
positionsCount,
|
|
@@ -613,7 +673,9 @@ export async function syncBrokerAccount(id) {
|
|
|
613
673
|
const prevDay = acc.lastSync?.day === today ? acc.lastSync.dayStartRub : data.totalRub;
|
|
614
674
|
const dayStartRub = prevDay > 0 ? prevDay : data.totalRub;
|
|
615
675
|
const ddPct = dayStartRub > 0 ? round(((dayStartRub - data.totalRub) / dayStartRub) * 100, 2) : 0;
|
|
616
|
-
if (
|
|
676
|
+
if (data.autoFollowDetected || /автослед|сигнал|copy|follow|comon/i.test(acc.name || '')) {
|
|
677
|
+
acc.haltedReason = 'Заблокировано: на счёте обнаружено Автоследование (использование счетов автоследования запрещено Публичной офертой и правилами платформы)';
|
|
678
|
+
} else if (ddPct >= acc.maxDailyDrawdownPct) {
|
|
617
679
|
acc.haltedReason = `Сработал дневной стоп по просадке: −${ddPct}% (порог ${acc.maxDailyDrawdownPct}%)`;
|
|
618
680
|
}
|
|
619
681
|
// История оценки счёта (сохраняется на диске и переживает перезапуск сервера)
|
|
@@ -637,13 +699,22 @@ export async function syncBrokerAccount(id) {
|
|
|
637
699
|
return st;
|
|
638
700
|
}
|
|
639
701
|
|
|
640
|
-
//
|
|
702
|
+
// Переключатель (тогл) защиты текущих бумаг на счёте:
|
|
703
|
+
// включает режим «Изолированный карман» (isolate) с фиксацией текущего снимка baselineQty,
|
|
704
|
+
// либо при повторном нажатии переключает обратно в «Плавный переход» (smooth)
|
|
641
705
|
export async function freezeBrokerBaseline(id) {
|
|
642
706
|
const list = loadRawAccounts();
|
|
643
707
|
const acc = list.find((x) => x.id === id);
|
|
644
708
|
if (!acc) throw new Error('Счёт не найден');
|
|
645
709
|
const positions = acc.lastSync?.positions || [];
|
|
646
|
-
acc.baselineQty
|
|
710
|
+
const hasBaseline = acc.baselineQty && Object.keys(acc.baselineQty).length > 0;
|
|
711
|
+
const isProtectedNow = (acc.legacyMode || 'isolate') === 'isolate' && hasBaseline;
|
|
712
|
+
if (isProtectedNow) {
|
|
713
|
+
acc.legacyMode = 'smooth';
|
|
714
|
+
} else {
|
|
715
|
+
acc.legacyMode = 'isolate';
|
|
716
|
+
acc.baselineQty = Object.fromEntries(positions.filter((p) => p.qty !== 0).map((p) => [p.symbol, p.qty]));
|
|
717
|
+
}
|
|
647
718
|
saveRawAccounts(list);
|
|
648
719
|
const st = brokersState();
|
|
649
720
|
hub.emit({ type: 'brokers:update', state: st });
|
|
@@ -669,27 +740,58 @@ export async function prepareBrokerOrders(id) {
|
|
|
669
740
|
if (acc.haltedReason) throw new Error(acc.haltedReason);
|
|
670
741
|
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
671
742
|
|
|
743
|
+
const plan = effectivePlan(currentUser());
|
|
744
|
+
if ((plan.maxBrokerAccounts ?? 0) < 1) {
|
|
745
|
+
throw new Error(`На бесплатном тарифе «${plan.name}» работа с реальными брокерскими счетами недоступна (доступно тестирование в Экспериментах и виртуальный счёт). Оформите тариф «Инвестор», «Профи» или «VIP».`);
|
|
746
|
+
}
|
|
672
747
|
const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
|
|
673
748
|
const params = normalizeParams(baseRun?.params || getSettings());
|
|
674
749
|
const [fc, specs] = await Promise.all([
|
|
675
750
|
forecastNow(params),
|
|
676
751
|
ensureBoardSpecs(),
|
|
677
752
|
]);
|
|
678
|
-
const
|
|
753
|
+
const capCeiling = plan.maxCapitalPerAccountRub > 0 ? plan.maxCapitalPerAccountRub : 1000000000;
|
|
754
|
+
const effectiveMaxCap = Math.min(acc.maxCapitalRub, capCeiling);
|
|
755
|
+
const budget = Math.min(effectiveMaxCap, acc.lastSync?.totalRub > 0 ? acc.lastSync.totalRub : effectiveMaxCap);
|
|
679
756
|
const K = params.modelTopK || 3;
|
|
680
|
-
// Автоматическая подстройка направления торговли
|
|
681
|
-
// если на брокерском счёте отключена маржинальная торговля (или это ИИС), короткие продажи (шорты) автоматически отключаются
|
|
757
|
+
// Автоматическая подстройка направления и мощности торговли под тариф и реальные данные счёта:
|
|
682
758
|
const marginAllowed = acc.lastSync?.marginEnabled !== false;
|
|
759
|
+
const planAllowsShorts = Boolean(plan.allowShorts);
|
|
760
|
+
const vipBoost = Boolean(plan.allowVipBoost);
|
|
683
761
|
const rawDir = params.modelDirection || 'long';
|
|
684
|
-
const dir = marginAllowed ? rawDir : 'long';
|
|
762
|
+
const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
|
|
685
763
|
const autoAdapt = params.modelAutoAdapt !== false;
|
|
686
764
|
const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
|
|
687
765
|
const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
|
|
688
766
|
const slots = dir === 'long' ? K : 2 * K;
|
|
689
|
-
|
|
690
|
-
|
|
767
|
+
// Фильтр мощности слота по тарифу:
|
|
768
|
+
// • Инвестор (до 50% годовых): только покупки (без шортов), консервативный слот 1 / (K + 1) (до 22% капитала)
|
|
769
|
+
// • Профи (до 100% годовых): покупки + шорты, стандартный равный слот 1 / (2K) (до 25% капитала, шаг раз в полный горизонт H)
|
|
770
|
+
// • VIP (без ограничений): полный форсаж (Conviction Slot Sizing до 42% капитала в лучших сигналах + полушаговый перехват H/2)
|
|
771
|
+
let slotRub;
|
|
772
|
+
if (vipBoost && autoAdapt) {
|
|
773
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
|
|
774
|
+
slotRub = budget * maxSlotWeight;
|
|
775
|
+
} else if (plan.id === 'pro') {
|
|
776
|
+
slotRub = budget * Math.min(0.25, 1 / Math.max(2, slots));
|
|
777
|
+
} else {
|
|
778
|
+
slotRub = budget * Math.min(0.22, 1 / Math.max(3, K + 1));
|
|
779
|
+
}
|
|
780
|
+
// Дополнительный мягкий дроссель при опережении годового потолка доходности тарифа (50% для Инвестора, 100% для Профи)
|
|
781
|
+
if (!vipBoost && plan.maxAnnualReturnPct > 0 && Array.isArray(acc.equityHistory) && acc.equityHistory.length >= 2) {
|
|
782
|
+
const firstPt = acc.equityHistory[0];
|
|
783
|
+
const lastPt = acc.equityHistory[acc.equityHistory.length - 1];
|
|
784
|
+
const elapsedDays = Math.max(7, (lastPt.ts - firstPt.ts) / DAY);
|
|
785
|
+
if (firstPt.totalRub > 0 && lastPt.totalRub > firstPt.totalRub) {
|
|
786
|
+
const curGainPct = ((lastPt.totalRub - firstPt.totalRub) / firstPt.totalRub) * 100;
|
|
787
|
+
const targetGainPct = plan.maxAnnualReturnPct * (elapsedDays / 365);
|
|
788
|
+
if (curGainPct > targetGainPct) {
|
|
789
|
+
slotRub *= Math.max(0.25, targetGainPct / curGainPct);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
691
793
|
const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
|
|
692
|
-
const convOf = (r) => (autoAdapt && r.expectedPct ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (Math.abs(r.expectedPct) / needPct))) : 1);
|
|
794
|
+
const convOf = (r) => (vipBoost && autoAdapt && r.expectedPct ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (Math.abs(r.expectedPct) / needPct))) : 1);
|
|
693
795
|
const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
|
|
694
796
|
const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
|
|
695
797
|
const quarantined = new Set(baseRun?.extra?.adaptation?.quarantined || []);
|
|
@@ -697,7 +799,7 @@ export async function prepareBrokerOrders(id) {
|
|
|
697
799
|
|
|
698
800
|
const buyCands = fc.rows.filter((r) => r.signal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad).sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
699
801
|
const rawShortCands = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good).sort((a, b) => a.expectedPct - b.expectedPct).slice(0, K) : [];
|
|
700
|
-
const shortCands = marginAllowed ? rawShortCands : [];
|
|
802
|
+
const shortCands = (marginAllowed && planAllowsShorts) ? rawShortCands : [];
|
|
701
803
|
|
|
702
804
|
const legacyMode = acc.legacyMode || 'isolate';
|
|
703
805
|
const baseline = acc.baselineQty || {};
|
|
@@ -836,11 +938,21 @@ export async function prepareBrokerOrders(id) {
|
|
|
836
938
|
}
|
|
837
939
|
|
|
838
940
|
let diagNote = '';
|
|
941
|
+
let planThrottleNote = '';
|
|
942
|
+
if (plan.id === 'investor') {
|
|
943
|
+
const shInfo = rawShortCands.length ? ` (пропущены сигналы на шорт: ${rawShortCands.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
944
|
+
planThrottleNote = `Тариф «Инвестор» (целевой потолок до 50% годовых, лимит до 1 млн ₽/счёт): активен режим «Только в рост (без шорта)» и консервативный слот 22%${shInfo}. Для включения заработка на падении акций (шорты, до 100% годовых и до 10 млн ₽/счёт) перейдите на тариф «Профи».`;
|
|
945
|
+
} else if (plan.id === 'pro') {
|
|
946
|
+
planThrottleNote = 'Тариф «Профи» (целевой потолок до 100% годовых, лимит до 10 млн ₽/счёт): активны покупки и шорты со стандартным шагом H и базовым слотом 25%. Для снятия всех ограничений (внутричасовой перехват H/2 + концентрация слота Conviction Sizing до 42%, как в максимальных экспериментах) подключите тариф «VIP».';
|
|
947
|
+
}
|
|
839
948
|
if (!orders.length) {
|
|
840
949
|
if ((newBuys.length || newShorts.length) && availRub < 1500) {
|
|
841
950
|
const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
|
|
842
951
|
const cashNow = round(acc.lastSync?.cashRub || 0, 0);
|
|
843
952
|
diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
|
|
953
|
+
} else if (!newBuys.length && !planAllowsShorts && rawShortCands.length > 0) {
|
|
954
|
+
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
955
|
+
diagNote = `По прогнозу есть сигнал на падение (${skippedShorts}), но на тарифе «${plan.name}» разрешена торговля только в рост (без шорта, до 50% годовых). Чтобы зарабатывать и на падении акций (до 100% годовых), перейдите на тариф «Профи» или «VIP».`;
|
|
844
956
|
} else if (!newBuys.length && !marginAllowed && rawShortCands.length > 0) {
|
|
845
957
|
const skippedShorts = rawShortCands.map((r) => `${r.symbol} (${r.expectedPct}%)`).join(', ');
|
|
846
958
|
diagNote = `Счёт автоматически работает в режиме «Только в рост (без шорта)», так как на нём в Т-Инвестициях отключена маржинальная торговля. Сигналы на падение (${skippedShorts}) пропущены; новых сигналов на покупку на свече ${fc.bar} нет. Если хотите зарабатывать и на падении (шортах), включите «Маржинальную торговлю» в настройках этого счёта в приложении Т-Инвестиций.`;
|
|
@@ -857,6 +969,8 @@ export async function prepareBrokerOrders(id) {
|
|
|
857
969
|
availRub: round(availRub, 0),
|
|
858
970
|
effectiveDirection: dir,
|
|
859
971
|
marginAllowed,
|
|
972
|
+
planId: plan.id,
|
|
973
|
+
planThrottleNote,
|
|
860
974
|
edgeWarning: edgeWarn,
|
|
861
975
|
diagNote,
|
|
862
976
|
topSignals: [...buyCands.slice(0, 3).map((r) => ({ symbol: r.symbol, side: 'buy', expectedPct: r.expectedPct })), ...shortCands.slice(0, 3).map((r) => ({ symbol: r.symbol, side: 'short', expectedPct: r.expectedPct }))],
|
|
@@ -1060,7 +1174,7 @@ export function rejectBrokerOrders(id) {
|
|
|
1060
1174
|
return st;
|
|
1061
1175
|
}
|
|
1062
1176
|
|
|
1063
|
-
// ---------- история операций брокерского счёта (покупки, продажи, комиссии, дивиденды/купоны, пополнения) ----------
|
|
1177
|
+
// ---------- история операций брокерского счёта (покупки, продажи, комиссии, дивиденды/купоны, пополнения за все годы) ----------
|
|
1064
1178
|
export async function fetchBrokerHistory(id) {
|
|
1065
1179
|
const list = loadRawAccounts();
|
|
1066
1180
|
const acc = list.find((x) => x.id === id);
|
|
@@ -1073,18 +1187,48 @@ export async function fetchBrokerHistory(id) {
|
|
|
1073
1187
|
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
1074
1188
|
const accountId = acc.accountId || acc.lastSync?.accountId;
|
|
1075
1189
|
if (!accountId) throw new Error('Сначала нажмите «Обновить портфель», чтобы определить ID счёта');
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
const
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1190
|
+
|
|
1191
|
+
// Запрашиваем историю полугодовыми интервалами за последние 5 лет, чтобы не терять операции из-за лимита 1000 записей на один запрос Т-Банка
|
|
1192
|
+
const curYear = new Date(Date.now() + MSK).getUTCFullYear();
|
|
1193
|
+
const intervals = [];
|
|
1194
|
+
for (let y = curYear; y >= curYear - 4; y--) {
|
|
1195
|
+
const startH2 = Date.UTC(y, 6, 1);
|
|
1196
|
+
const endH2 = Math.min(Date.now(), Date.UTC(y, 11, 31, 23, 59, 59));
|
|
1197
|
+
if (startH2 < Date.now()) intervals.push([new Date(startH2).toISOString(), new Date(endH2).toISOString()]);
|
|
1198
|
+
const startH1 = Date.UTC(y, 0, 1);
|
|
1199
|
+
const endH1 = Math.min(Date.now(), Date.UTC(y, 5, 30, 23, 59, 59));
|
|
1200
|
+
if (startH1 < Date.now()) intervals.push([new Date(startH1).toISOString(), new Date(endH1).toISOString()]);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
const chunkResults = await Promise.all(intervals.map(async ([fromIso, toIso]) => {
|
|
1204
|
+
try {
|
|
1205
|
+
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OperationsService/GetOperations`, {
|
|
1206
|
+
method: 'POST',
|
|
1207
|
+
headers,
|
|
1208
|
+
body: JSON.stringify({ accountId, from: fromIso, to: toIso, state: 'OPERATION_STATE_EXECUTED' }),
|
|
1209
|
+
timeoutMs: 20000,
|
|
1210
|
+
});
|
|
1211
|
+
if (!r.ok) return [];
|
|
1212
|
+
const j = await r.json();
|
|
1213
|
+
return j.operations || [];
|
|
1214
|
+
} catch {
|
|
1215
|
+
return [];
|
|
1216
|
+
}
|
|
1217
|
+
}));
|
|
1218
|
+
|
|
1219
|
+
const seenIds = new Set();
|
|
1220
|
+
const rawOps = [];
|
|
1221
|
+
for (const arr of chunkResults) {
|
|
1222
|
+
for (const op of arr) {
|
|
1223
|
+
const key = op.id || `${op.date}_${op.operationType}_${op.figi}_${op.payment?.units}`;
|
|
1224
|
+
if (seenIds.has(key)) continue;
|
|
1225
|
+
seenIds.add(key);
|
|
1226
|
+
rawOps.push(op);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
rawOps.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
|
1230
|
+
|
|
1231
|
+
const figis = [...new Set(rawOps.map((o) => o.figi || o.instrumentUid).filter(Boolean))];
|
|
1088
1232
|
const metaMap = await resolveTbankMetaMap(base, headers, figis);
|
|
1089
1233
|
|
|
1090
1234
|
let tradesCount = 0, buySumRub = 0, sellSumRub = 0, commissionsRub = 0, dividendsRub = 0, inOutRub = 0;
|
|
@@ -1110,9 +1254,7 @@ export async function fetchBrokerHistory(id) {
|
|
|
1110
1254
|
return ['other', 'Операция'];
|
|
1111
1255
|
};
|
|
1112
1256
|
|
|
1113
|
-
|
|
1114
|
-
const recentRaw = [...rawOps].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))).slice(0, 80);
|
|
1115
|
-
const operations = recentRaw.map((op) => {
|
|
1257
|
+
const operations = rawOps.slice(0, 5000).map((op) => {
|
|
1116
1258
|
const m = metaMap[op.figi] || metaMap[op.instrumentUid] || {};
|
|
1117
1259
|
const [kind, kindLabel] = OP_KIND(op.operationType);
|
|
1118
1260
|
return {
|
|
@@ -1171,6 +1313,8 @@ async function tickBrokerSchedule() {
|
|
|
1171
1313
|
await runWithUser(u, async () => {
|
|
1172
1314
|
const list = loadRawAccounts();
|
|
1173
1315
|
if (!list.length) return;
|
|
1316
|
+
const plan = effectivePlan(u);
|
|
1317
|
+
if ((plan.maxBrokerAccounts ?? 0) < 1) return;
|
|
1174
1318
|
for (const acc of list) {
|
|
1175
1319
|
if (acc.autoSchedule === false || acc.haltedReason) continue;
|
|
1176
1320
|
const baseRun = acc.baseRunId ? getRun(Number(acc.baseRunId)) : null;
|
|
@@ -1178,7 +1322,8 @@ async function tickBrokerSchedule() {
|
|
|
1178
1322
|
const autoAdapt = params.modelAutoAdapt !== false;
|
|
1179
1323
|
const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
|
|
1180
1324
|
const H = barsFromHours(effHorizon);
|
|
1181
|
-
|
|
1325
|
+
// Полушаговое сопровождение H/2 (каждые 1 ч 15 мин) активно в режиме VIP (полный форсаж); на тарифах Инвестор и Профи шаг равен полному горизонту H
|
|
1326
|
+
const stepBars = (plan.allowVipBoost && autoAdapt) ? Math.max(2, Math.floor(H / 2)) : H;
|
|
1182
1327
|
if (latest - (acc.lastScheduledBarTs || 0) < stepBars * BAR_MS) continue;
|
|
1183
1328
|
acc.lastScheduledBarTs = latest;
|
|
1184
1329
|
saveRawAccounts(list);
|
package/server/config.js
CHANGED
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';
|