neuroalpha 0.3.17 → 0.5.1

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 (39) hide show
  1. package/config/certs/russian_trusted_root_ca.pem +33 -0
  2. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -4
  4. 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 +2 -2
  5. package/package.json +60 -53
  6. package/public/activate.html +36 -0
  7. package/public/activate.js +33 -0
  8. package/public/admin.html +126 -7
  9. package/public/app.js +573 -66
  10. package/public/index.html +48 -21
  11. package/public/landing.html +106 -37
  12. package/public/legal.html +4 -4
  13. package/public/styles.css +152 -36
  14. package/server/api/routes.js +308 -37
  15. package/server/auth/index.js +79 -15
  16. package/server/brokers/index.js +1014 -168
  17. package/server/build-info.js +7 -0
  18. package/server/config.js +13 -6
  19. package/server/data/moex.js +6 -4
  20. package/server/db.js +22 -18
  21. package/server/desktop-entry.js +103 -0
  22. package/server/hub.js +28 -14
  23. package/server/index.js +82 -12
  24. package/server/instance/agent.js +259 -0
  25. package/server/instance/central.js +491 -0
  26. package/server/instance/identity.js +79 -0
  27. package/server/instance/provision.js +166 -0
  28. package/server/instance/signing.js +71 -0
  29. package/server/model/edge.js +19 -0
  30. package/server/model/forecast.js +107 -57
  31. package/server/model/live.js +1 -1
  32. package/server/model/models.js +12 -3
  33. package/server/model/research.js +218 -30
  34. package/server/notify/index.js +51 -16
  35. package/server/role.js +44 -0
  36. package/server/security.js +40 -0
  37. package/server/strategist/index.js +86 -47
  38. package/server/strategist/tools.js +231 -42
  39. package/server/tls-ca.js +32 -0
@@ -0,0 +1,166 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Автоматическое развёртывание облачных машин клиентов. Драйвер выбирается переменной PAAS_DRIVER:
3
+ // k8s — напрямую через API Kubernetes (server-side apply). Внутри кластера берётся токен сервисного аккаунта,
4
+ // снаружи — K8S_API_URL + K8S_TOKEN (+ K8S_CA_FILE)
5
+ // webhook — POST на PAAS_WEBHOOK_URL с подписью HMAC-SHA256 (заголовок X-NA-Signature) — для собственного PaaS
6
+ // none — по умолчанию: машины разворачиваются вручную по шаблону deploy/k8s/instance.yaml
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import https from 'node:https';
10
+ import tls from 'node:tls';
11
+
12
+ export const PAAS_DRIVER = String(process.env.PAAS_DRIVER || 'none').trim().toLowerCase();
13
+ const IMAGE = String(process.env.INSTANCE_IMAGE || 'registry.odant.org/neuroalpha:latest').trim();
14
+ const INGRESS_CLASS = String(process.env.INSTANCE_INGRESS_CLASS || 'nginx').trim();
15
+ const TLS_SECRET = String(process.env.INSTANCE_TLS_SECRET || 'odant-wildcard-tls').trim();
16
+ const STORAGE = String(process.env.INSTANCE_STORAGE || '5Gi').trim();
17
+ const STORAGE_CLASS = String(process.env.INSTANCE_STORAGE_CLASS || '').trim();
18
+ const FIELD_MANAGER = 'neuroalpha-central';
19
+
20
+ export const provisioningEnabled = () => PAAS_DRIVER === 'k8s' || PAAS_DRIVER === 'webhook';
21
+
22
+ // ---------- манифесты машины (то же, что deploy/k8s/instance.yaml) ----------
23
+ function manifests(inst, env) {
24
+ const ns = inst.id;
25
+ const labels = { 'app.kubernetes.io/part-of': 'neuroalpha', 'neuroalpha/instance': inst.id };
26
+ const app = { app: 'neuroalpha' };
27
+ return [
28
+ { apiVersion: 'v1', kind: 'Namespace', metadata: { name: ns, labels } },
29
+ { apiVersion: 'v1', kind: 'Secret', metadata: { name: 'neuroalpha-instance', namespace: ns, labels }, type: 'Opaque', stringData: env },
30
+ {
31
+ apiVersion: 'v1', kind: 'PersistentVolumeClaim', metadata: { name: 'neuroalpha-data', namespace: ns, labels },
32
+ spec: { accessModes: ['ReadWriteOnce'], resources: { requests: { storage: STORAGE } }, ...(STORAGE_CLASS ? { storageClassName: STORAGE_CLASS } : {}) },
33
+ },
34
+ {
35
+ apiVersion: 'apps/v1', kind: 'Deployment', metadata: { name: 'neuroalpha', namespace: ns, labels },
36
+ spec: {
37
+ replicas: 1,
38
+ strategy: { type: 'Recreate' },
39
+ selector: { matchLabels: app },
40
+ template: {
41
+ metadata: { labels: { ...app, ...labels }, annotations: { 'neuroalpha/key-rev': crypto.createHash('sha256').update(env.INSTANCE_KEY).digest('hex').slice(0, 12) } },
42
+ spec: {
43
+ securityContext: { runAsNonRoot: true, runAsUser: 1000, fsGroup: 1000 },
44
+ containers: [{
45
+ name: 'neuroalpha',
46
+ image: IMAGE,
47
+ ports: [{ containerPort: 3000 }],
48
+ envFrom: [{ secretRef: { name: 'neuroalpha-instance' } }],
49
+ volumeMounts: [{ name: 'data', mountPath: '/data' }],
50
+ resources: { requests: { cpu: '500m', memory: '1Gi' }, limits: { cpu: '2', memory: '3Gi' } },
51
+ readinessProbe: { httpGet: { path: '/healthz', port: 3000 }, initialDelaySeconds: 10, periodSeconds: 15 },
52
+ livenessProbe: { httpGet: { path: '/healthz', port: 3000 }, initialDelaySeconds: 60, periodSeconds: 30, failureThreshold: 4 },
53
+ securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ['ALL'] } },
54
+ }],
55
+ volumes: [{ name: 'data', persistentVolumeClaim: { claimName: 'neuroalpha-data' } }],
56
+ },
57
+ },
58
+ },
59
+ },
60
+ { apiVersion: 'v1', kind: 'Service', metadata: { name: 'neuroalpha', namespace: ns, labels }, spec: { selector: app, ports: [{ port: 80, targetPort: 3000 }] } },
61
+ {
62
+ apiVersion: 'networking.k8s.io/v1', kind: 'Ingress',
63
+ metadata: { name: 'neuroalpha', namespace: ns, labels, annotations: { 'nginx.ingress.kubernetes.io/proxy-read-timeout': '3600', 'nginx.ingress.kubernetes.io/proxy-send-timeout': '3600' } },
64
+ spec: {
65
+ ingressClassName: INGRESS_CLASS,
66
+ tls: [{ hosts: [inst.host], secretName: TLS_SECRET }],
67
+ rules: [{ host: inst.host, http: { paths: [{ path: '/', pathType: 'Prefix', backend: { service: { name: 'neuroalpha', port: { number: 80 } } } }] } }],
68
+ },
69
+ },
70
+ {
71
+ apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy', metadata: { name: 'neuroalpha-ingress-only', namespace: ns, labels },
72
+ spec: {
73
+ podSelector: { matchLabels: app },
74
+ policyTypes: ['Ingress'],
75
+ ingress: [{ from: [{ namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': String(process.env.INSTANCE_INGRESS_NAMESPACE || 'ingress-nginx') } } }], ports: [{ port: 3000 }] }],
76
+ },
77
+ },
78
+ ];
79
+ }
80
+
81
+ // ---------- драйвер k8s ----------
82
+ function k8sConfig() {
83
+ const saDir = '/var/run/secrets/kubernetes.io/serviceaccount';
84
+ const inCluster = process.env.KUBERNETES_SERVICE_HOST && fs.existsSync(`${saDir}/token`);
85
+ const apiUrl = String(process.env.K8S_API_URL || (inCluster ? `https://${process.env.KUBERNETES_SERVICE_HOST}:${process.env.KUBERNETES_SERVICE_PORT || 443}` : '')).replace(/\/+$/, '');
86
+ const token = String(process.env.K8S_TOKEN || (inCluster ? fs.readFileSync(`${saDir}/token`, 'utf8') : '')).trim();
87
+ const caFile = String(process.env.K8S_CA_FILE || (inCluster ? `${saDir}/ca.crt` : '')).trim();
88
+ if (!apiUrl || !token) throw new Error('PAAS_DRIVER=k8s: не заданы K8S_API_URL и K8S_TOKEN (или сервер запущен вне кластера)');
89
+ const ca = caFile && fs.existsSync(caFile) ? [...tls.rootCertificates, fs.readFileSync(caFile, 'utf8')] : undefined;
90
+ return { apiUrl, token, ca };
91
+ }
92
+
93
+ const PLURAL = { Namespace: 'namespaces', Secret: 'secrets', PersistentVolumeClaim: 'persistentvolumeclaims', Deployment: 'deployments', Service: 'services', Ingress: 'ingresses', NetworkPolicy: 'networkpolicies' };
94
+
95
+ function objectPath(o) {
96
+ const base = o.apiVersion === 'v1' ? '/api/v1' : `/apis/${o.apiVersion}`;
97
+ const plural = PLURAL[o.kind];
98
+ if (o.kind === 'Namespace') return `${base}/namespaces/${o.metadata.name}`;
99
+ return `${base}/namespaces/${o.metadata.namespace}/${plural}/${o.metadata.name}`;
100
+ }
101
+
102
+ function k8sRequest(cfg, method, urlPath, body, contentType = 'application/json') {
103
+ return new Promise((resolve, reject) => {
104
+ const u = new URL(cfg.apiUrl + urlPath);
105
+ const data = body ? Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)) : null;
106
+ const req = https.request({
107
+ hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method, ca: cfg.ca, timeout: 20000,
108
+ headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json', ...(data ? { 'Content-Type': contentType, 'Content-Length': data.length } : {}) },
109
+ }, (res) => {
110
+ let t = '';
111
+ res.setEncoding('utf8');
112
+ res.on('data', (c) => { t += c; });
113
+ res.on('end', () => resolve({ status: res.statusCode, text: t }));
114
+ });
115
+ req.on('timeout', () => req.destroy(new Error('Kubernetes API: таймаут')));
116
+ req.on('error', reject);
117
+ if (data) req.write(data);
118
+ req.end();
119
+ });
120
+ }
121
+
122
+ async function k8sApply(inst, env) {
123
+ const cfg = k8sConfig();
124
+ for (const o of manifests(inst, env)) {
125
+ const r = await k8sRequest(cfg, 'PATCH', `${objectPath(o)}?fieldManager=${FIELD_MANAGER}&force=true`, o, 'application/apply-patch+yaml');
126
+ if (r.status >= 300) throw new Error(`Kubernetes ${o.kind}: HTTP ${r.status} ${r.text.slice(0, 200)}`);
127
+ }
128
+ }
129
+
130
+ async function k8sDelete(inst) {
131
+ const cfg = k8sConfig();
132
+ const r = await k8sRequest(cfg, 'DELETE', `/api/v1/namespaces/${inst.id}`);
133
+ if (r.status >= 300 && r.status !== 404) throw new Error(`Kubernetes Namespace: HTTP ${r.status} ${r.text.slice(0, 200)}`);
134
+ }
135
+
136
+ // ---------- драйвер webhook (собственный PaaS) ----------
137
+ async function webhook(action, inst, env = null) {
138
+ const url = String(process.env.PAAS_WEBHOOK_URL || '').trim();
139
+ const secret = String(process.env.PAAS_WEBHOOK_SECRET || '').trim();
140
+ if (!url || !secret) throw new Error('PAAS_DRIVER=webhook: не заданы PAAS_WEBHOOK_URL и PAAS_WEBHOOK_SECRET');
141
+ const body = JSON.stringify({
142
+ action, // apply | delete
143
+ instanceId: inst.id,
144
+ host: inst.host,
145
+ image: IMAGE,
146
+ env, // переменные для Secret (только при apply)
147
+ manifests: env ? manifests(inst, env) : undefined,
148
+ ts: Date.now(),
149
+ });
150
+ const sig = crypto.createHmac('sha256', secret).update(body).digest('hex');
151
+ const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-NA-Signature': `sha256=${sig}` }, body, signal: AbortSignal.timeout(30000) });
152
+ if (!r.ok) throw new Error(`PaaS webhook ${action}: HTTP ${r.status} ${(await r.text().catch(() => '')).slice(0, 200)}`);
153
+ }
154
+
155
+ // ---------- общий интерфейс ----------
156
+ export async function provisionApply(inst, env) {
157
+ if (PAAS_DRIVER === 'k8s') return k8sApply(inst, env);
158
+ if (PAAS_DRIVER === 'webhook') return webhook('apply', inst, env);
159
+ throw new Error('Автоматическое развёртывание выключено (PAAS_DRIVER=none)');
160
+ }
161
+
162
+ export async function provisionDelete(inst) {
163
+ if (PAAS_DRIVER === 'k8s') return k8sDelete(inst);
164
+ if (PAAS_DRIVER === 'webhook') return webhook('delete', inst);
165
+ return null;
166
+ }
@@ -0,0 +1,71 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Подписанные токены центра (Ed25519): лицензия машины и одноразовый вход (SSO).
3
+ // Формат: base64url(JSON-полезная нагрузка) + "." + base64url(подпись)
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { DATA_DIR } from '../config.js';
8
+ import { centralPublicKeyCandidates } from './identity.js';
9
+
10
+ const ISSUER = 'neuroalpha-central';
11
+
12
+ const b64u = (buf) => Buffer.from(buf).toString('base64url');
13
+
14
+ // ---------- ключ подписи центра ----------
15
+ let privateKey = null;
16
+ let publicKeyPem = '';
17
+
18
+ function loadSigningKey() {
19
+ if (privateKey) return privateKey;
20
+ const envPem = String(process.env.LICENSE_SIGNING_KEY || '').trim();
21
+ const file = String(process.env.LICENSE_SIGNING_KEY_FILE || '').trim() || path.join(DATA_DIR, '.license-ed25519.pem');
22
+ let pem = envPem.includes('BEGIN') ? envPem.replace(/\\n/g, '\n') : '';
23
+ if (!pem && fs.existsSync(file)) pem = fs.readFileSync(file, 'utf8');
24
+ if (!pem) {
25
+ const { privateKey: k } = crypto.generateKeyPairSync('ed25519');
26
+ pem = k.export({ type: 'pkcs8', format: 'pem' });
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ fs.writeFileSync(file, pem, { mode: 0o600 });
29
+ console.log(`✓ Создан ключ подписи лицензий центра: ${file}`);
30
+ }
31
+ privateKey = crypto.createPrivateKey(pem);
32
+ publicKeyPem = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
33
+ return privateKey;
34
+ }
35
+
36
+ export function centralPublicKeyPem() {
37
+ loadSigningKey();
38
+ return publicKeyPem;
39
+ }
40
+
41
+ export function signToken(payload, ttlSec) {
42
+ const now = Math.floor(Date.now() / 1000);
43
+ const body = { iss: ISSUER, iat: now, exp: now + Math.max(1, Math.round(ttlSec)), ...payload };
44
+ const data = b64u(JSON.stringify(body));
45
+ const sig = crypto.sign(null, Buffer.from(data), loadSigningKey());
46
+ return `${data}.${b64u(sig)}`;
47
+ }
48
+
49
+ // ---------- проверка на машине клиента (только открытый ключ центра) ----------
50
+ function loadVerifyKey() {
51
+ const pem = centralPublicKeyCandidates()[0];
52
+ return pem ? crypto.createPublicKey(pem) : null;
53
+ }
54
+
55
+ export function verifyToken(token, { typ, aud, leewaySec = 120 } = {}) {
56
+ const key = loadVerifyKey();
57
+ if (!key) throw new Error('Не задан открытый ключ центра (LICENSE_PUBLIC_KEY)');
58
+ const [data, sig] = String(token || '').split('.');
59
+ if (!data || !sig) throw new Error('Некорректный токен');
60
+ const ok = crypto.verify(null, Buffer.from(data), key, Buffer.from(sig, 'base64url'));
61
+ if (!ok) throw new Error('Подпись токена не прошла проверку');
62
+ let p;
63
+ try { p = JSON.parse(Buffer.from(data, 'base64url').toString('utf8')); } catch { throw new Error('Некорректный токен'); }
64
+ const now = Math.floor(Date.now() / 1000);
65
+ if (p.iss !== ISSUER) throw new Error('Токен выпущен не центром НейроАльфа');
66
+ if (typ && p.typ !== typ) throw new Error('Неверный тип токена');
67
+ if (aud && String(p.aud || '').toLowerCase() !== String(aud).toLowerCase()) throw new Error('Токен выпущен для другой машины');
68
+ if (!(p.exp > now - leewaySec)) throw new Error('Срок действия токена истёк');
69
+ if (p.iat && p.iat > now + leewaySec) throw new Error('Токен из будущего — проверьте время на сервере');
70
+ return p;
71
+ }
@@ -0,0 +1,19 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Множители порога входа и выхода стратегии и классификация 1-го эшелона ликвидности Мосбиржи.
3
+ // Базовый порог = 2,5 × (комиссия + проскальзывание) × 2 стороны; множители поднимают его так,
4
+ // чтобы ожидаемое движение покрывало РЕАЛЬНЫЕ издержки с запасом.
5
+ // По фактическим сделкам счёта Т-Банка (111 сделок) и стресс-тесту:
6
+ // • бумаги 1-го эшелона (SBER, T, GAZP, LKOH, YDEX и др.): реальное проскальзывание ~0,01–0,03% + комиссия 0,04% → порог ×2 (от 0,50%);
7
+ // • бумаги 2–3 эшелона (MRKC, NKHP, AQUA и др.): широкий спред в стакане, проскальзывание до 0,10–0,25% → порог ещё в 1,35× выше (от 0,675%).
8
+ export const ENTRY_EDGE_MULT = 1.65;
9
+ export const TIER2_EDGE_MULT = 1.20;
10
+
11
+ // Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный спред):
12
+ export const TIER1_LIQUID_SYMBOLS = new Set([
13
+ 'SBER', 'SBERP', 'LKOH', 'GAZP', 'T', 'YDEX', 'ROSN', 'NVTK', 'GMKN', 'PLZL',
14
+ 'VTBR', 'TATN', 'TATNP', 'SNGS', 'SNGSP', 'ALRS', 'CHMF', 'NLMK', 'MAGN', 'MOEX',
15
+ 'OZON', 'HEAD', 'PHOR', 'AFLT', 'VKCO', 'MTSS', 'IRAO', 'TRNFP', 'RUAL', 'AFKS',
16
+ 'PIKK', 'SMLT', 'POSI', 'BSPB', 'SVCB', 'SOVCOM', 'CBOM', 'FLOT', 'X5',
17
+ ]);
18
+
19
+ export const symbolEdgeMult = (symbol) => (TIER1_LIQUID_SYMBOLS.has(symbol) ? 1 : TIER2_EDGE_MULT);
@@ -5,7 +5,10 @@ import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
5
5
  import { buildFold, MODELS, HEADS, KINDS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
6
6
  import { loadProfiles } from './profiles.js';
7
7
  import { isMarketDown } from './research.js';
8
+ import { ENTRY_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from './edge.js';
9
+ export { TIER1_LIQUID_SYMBOLS };
8
10
  import { newsForSymbols } from '../news/index.js';
11
+ import { fetchTbankRealtimePrices } from '../brokers/index.js';
9
12
  import { kvGet, kvSet } from '../db.js';
10
13
  import { hub } from '../hub.js';
11
14
 
@@ -124,26 +127,36 @@ export const isModelTrainedToday = (p, now = Date.now()) => {
124
127
  return false;
125
128
  };
126
129
 
127
- // Быстрое получение живых цен Мосбиржи (TQBR + IMOEX) за 1 лёгкий запрос (~80 мс)
130
+ // Быстрое получение живых цен Мосбиржи:
131
+ // в первую очередь берёт секундные цены реального времени из T-Invest API (GetLastPrices, 0 секунд задержки!),
132
+ // а недостающие тикеры и индекс IMOEX дополняет из MOEX ISS
128
133
  async function fetchLiveMoexSnapshot() {
129
134
  try {
130
- const [rShares, rIdx] = await Promise.all([
131
- fetch('https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,LAST,MARKETPRICE,LCURRENTPRICE,VALTODAY', { signal: AbortSignal.timeout(6000) }),
135
+ const [tbPrices, rShares, rIdx] = await Promise.all([
136
+ fetchTbankRealtimePrices().catch(() => null),
137
+ fetch('https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,LAST,MARKETPRICE,LCURRENTPRICE,VALTODAY', { signal: AbortSignal.timeout(6000) }).catch(() => null),
132
138
  fetch('https://iss.moex.com/iss/engines/stock/markets/index/boards/SNDX/securities/IMOEX.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,CURRENTVALUE,LASTVALUE', { signal: AbortSignal.timeout(6000) }).catch(() => null),
133
139
  ]);
134
- if (!rShares.ok) return null;
135
- const j = await rShares.json();
136
- const cols = j.marketdata?.columns || [];
137
- const iSec = cols.indexOf('SECID');
138
- const iLast = cols.indexOf('LAST');
139
- const iMkt = cols.indexOf('MARKETPRICE');
140
- const iCur = cols.indexOf('LCURRENTPRICE');
141
140
  const prices = new Map();
142
- for (const row of j.marketdata?.data || []) {
143
- const sym = row[iSec];
144
- const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
145
- if (sym && px > 0) prices.set(sym, px);
141
+ if (rShares?.ok) {
142
+ const j = await rShares.json().catch(() => null);
143
+ const cols = j?.marketdata?.columns || [];
144
+ const iSec = cols.indexOf('SECID');
145
+ const iLast = cols.indexOf('LAST');
146
+ const iMkt = cols.indexOf('MARKETPRICE');
147
+ const iCur = cols.indexOf('LCURRENTPRICE');
148
+ for (const row of j?.marketdata?.data || []) {
149
+ const sym = row[iSec];
150
+ const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
151
+ if (sym && px > 0) prices.set(sym, px);
152
+ }
153
+ }
154
+ if (tbPrices?.size) {
155
+ for (const [sym, px] of tbPrices) {
156
+ if (px > 0) prices.set(sym, px);
157
+ }
146
158
  }
159
+ if (!prices.size) return null;
147
160
  let imoexPrice = 0;
148
161
  if (rIdx?.ok) {
149
162
  const ji = await rIdx.json().catch(() => null);
@@ -151,7 +164,7 @@ async function fetchLiveMoexSnapshot() {
151
164
  const ir = ji?.marketdata?.data?.[0];
152
165
  if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
153
166
  }
154
- return { prices, imoexPrice, ts: Date.now() };
167
+ return { prices, imoexPrice, ts: Date.now(), realtime: Boolean(tbPrices?.size) };
155
168
  } catch {
156
169
  return null;
157
170
  }
@@ -283,7 +296,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
283
296
  preds[n] = MODELS[n].predict(fitted[n], f, u);
284
297
  }
285
298
  const cost = (p.commissionPct + p.slippagePct) / 100;
286
- const threshold = effMinEdge * 2 * cost;
299
+ // порог ×ENTRY_EDGE_MULT: при реальных издержках ~0,09% на сторону базовый порог убыточен (см. server/model/edge.js)
300
+ const threshold = effMinEdge * 2 * cost * ENTRY_EDGE_MULT;
287
301
  const bear = isMarketDown(ds, idx);
288
302
  const dir = p.modelDirection || 'long';
289
303
  const needL = autoAdapt && bear ? threshold * 1.25 : threshold;
@@ -296,7 +310,18 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
296
310
  const exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
297
311
  const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
298
312
  const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
299
- const rawShortHit = dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -needS;
313
+ const lastMovePct = round(f.z[u * f.N + i] * f.sigma[i] * 100, 3);
314
+ // Спред-фильтр по эшелонам ликвидности:
315
+ // для 1-го эшелона базовый порог needL/needS (≥ 0.50%), для 2–3 эшелона (широкий спред стакана) — в TIER2_EDGE_MULT (1.35×) выше
316
+ const tierMult = symbolEdgeMult(tk.symbol);
317
+ const symNeedL = Math.max(needL * tierMult, 0.0030);
318
+ const symNeedS = Math.max(needS * tierMult, 0.0031);
319
+ // Внутрисвечной предохранитель реального времени:
320
+ // не даём сигнал на покупку («не ловим падающий нож»), если на текущей живой свече акция проваливается против рынка сильнее чем на -0.35%,
321
+ // и не даём сигнал на шорт, если на текущей живой свече акция взлетает вверх сильнее чем на +0.35%!
322
+ const liveBuyOk = !(liveSnap?.realtime && lastMovePct < -0.35);
323
+ const liveShortOk = !(liveSnap?.realtime && lastMovePct > 0.35);
324
+ const rawShortHit = dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
300
325
  const shortBlockedByBroker = Boolean(rawShortHit && shortableMap && shortableMap[tk.symbol] === false);
301
326
  const row = {
302
327
  i, symbol: tk.symbol, name: tk.name,
@@ -304,9 +329,9 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
304
329
  liquid: tk.liq[idx] >= LIQ_MIN,
305
330
  expected: exp,
306
331
  expectedPct: round(exp * 100, 3),
307
- lastMovePct: round(f.z[u * f.N + i] * f.sigma[i] * 100, 3),
332
+ lastMovePct,
308
333
  weight: round(weight, 3),
309
- signal: tk.liq[idx] >= LIQ_MIN && exp > needL,
334
+ signal: tk.liq[idx] >= LIQ_MIN && exp > symNeedL && liveBuyOk,
310
335
  };
311
336
  if (dir !== 'long') {
312
337
  row.shortSignal = rawShortHit && !shortBlockedByBroker;
@@ -370,6 +395,35 @@ const fmtLiveBarLabel = (barTs, liveSnapTs) => {
370
395
  return fmtBarMsk(barTs || liveSnapTs || Date.now());
371
396
  };
372
397
 
398
+ // Мгновенный пересчёт последнего среза прогноза по живым секундным котировкам (без обращения к истории свечей)
399
+ async function tickLiveForecastState(p, now = Date.now()) {
400
+ const liveSnap = await fetchLiveMoexSnapshot();
401
+ if (liveSnap) applyLivePricesToFold(baseState.ds, baseState.f, baseState.u, baseState.fitted, liveSnap);
402
+ const effHorizon = p.modelAutoAdapt !== false ? 2.5 : p.modelHorizon;
403
+ const effMinEdge = p.modelAutoAdapt !== false ? 2.5 : p.modelMinEdge;
404
+ const H = barsFromHours(effHorizon);
405
+ const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f, baseState.u, baseState.fitted, baseState.chosen, H, effMinEdge, p.modelAutoAdapt !== false, true, liveSnap);
406
+ const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: baseState.snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
407
+ attachDeltas(sortedRows, baseState);
408
+ const liveTs = liveSnap?.ts || now;
409
+ const barTs = baseState.ds.timeline[baseState.last];
410
+ const result = {
411
+ ...baseState.lastTickResult,
412
+ at: now,
413
+ liveTs,
414
+ bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
415
+ buyThresholdPct: round(Math.max((r.needL ?? r.threshold) * 100, 0.30), 3),
416
+ shortThresholdPct: round(Math.max((r.needS ?? r.threshold) * 100, 0.31), 3),
417
+ marketDown: r.marketDown,
418
+ rows: sortedRows,
419
+ };
420
+ baseState.lastTickResult = result;
421
+ baseState.lastTickAt = now;
422
+ kvSet('lastForecastSnap', { key: baseState.key, result });
423
+ hub.emit({ type: 'trading:pulse', kind: 'radar', ts: now, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
424
+ return result;
425
+ }
426
+
373
427
  // Прогноз на ближайшие H часов с живым поминутным обновлением котировок (без переобучения тяжёлого окна каждую минуту)
374
428
  export async function forecastNow(input, { force = false, onProgress } = {}) {
375
429
  const p = normalizeParams(input);
@@ -379,34 +433,20 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
379
433
  const key = JSON.stringify([autoAdapt, p.modelType, effHorizon, p.modelTrainDays, p.modelEmbedDim, effMinEdge, p.modelDirection, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight, p.commissionPct, p.slippagePct]);
380
434
 
381
435
  const now = Date.now();
382
- // 1) Если с последнего живого тика прошло меньше 15 секунд и нет force — отдаём мгновенно из памяти
383
- if (!force && baseState && baseState.key === key && baseState.lastTickResult && now - baseState.lastTickAt < 15000) {
436
+ // 1) Если с последнего живого тика прошло меньше 8 секунд и нет force — отдаём мгновенно из памяти
437
+ if (!force && baseState && baseState.key === key && baseState.lastTickResult && now - baseState.lastTickAt < 8000) {
384
438
  return withNews(baseState.lastTickResult, p);
385
439
  }
386
- // 2) Если срез свечей обновлялся менее 2 минут назад и нет force — делаем быстрый живой пересчёт (~80 мс запрос + 2 мс математика)
387
- if (!force && baseState && baseState.key === key && now - baseState.dsLoadedAt < 2 * 60000) {
388
- const liveSnap = await fetchLiveMoexSnapshot();
389
- if (liveSnap) applyLivePricesToFold(baseState.ds, baseState.f, baseState.u, baseState.fitted, liveSnap);
390
- const H = barsFromHours(effHorizon);
391
- const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f, baseState.u, baseState.fitted, baseState.chosen, H, effMinEdge, autoAdapt, true, liveSnap);
392
- const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: baseState.snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
393
- attachDeltas(sortedRows, baseState);
394
- const liveTs = liveSnap?.ts || now;
395
- const barTs = baseState.ds.timeline[baseState.last];
396
- const result = {
397
- ...baseState.lastTickResult,
398
- at: now,
399
- liveTs,
400
- bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
401
- buyThresholdPct: round((r.needL ?? r.threshold) * 100, 3),
402
- shortThresholdPct: round((r.needS ?? r.threshold) * 100, 3),
403
- marketDown: r.marketDown,
404
- rows: sortedRows,
405
- };
406
- baseState.lastTickResult = result;
407
- baseState.lastTickAt = now;
408
- kvSet('lastForecastSnap', { key, result });
409
- hub.emit({ type: 'trading:pulse', kind: 'radar', ts: now, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
440
+ // 2) Если в RAM уже прогрето ядро модели (baseState):
441
+ // ВСЕГДА выполняем быстрый живой пересчёт по секундным котировкам Т-Банка/Мосбиржи (~80 мс, никогда не блокируя UI),
442
+ // а если с момента последнего обновления 10-минутных свечей прошло больше 10 минут — запускаем догрузку свечей параллельно в фоне!
443
+ if (!force && baseState && baseState.key === key) {
444
+ if (now - baseState.dsLoadedAt >= 10 * 60000 && !inflight.has(key)) {
445
+ setImmediate(() => {
446
+ refreshDatasetStateInBackground(p, key).catch(() => {});
447
+ });
448
+ }
449
+ const result = await tickLiveForecastState(p, now);
410
450
  return withNews(result, p);
411
451
  }
412
452
 
@@ -418,14 +458,23 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
418
458
  if (saved?.result && saved.key === key && now - (saved.result.at || 0) < 4 * 3600000) {
419
459
  if (!inflight.has(key)) {
420
460
  setImmediate(() => {
421
- forecastNow(p, { force: true }).catch(() => {});
461
+ refreshDatasetStateInBackground(p, key, { force: true }).catch(() => {});
422
462
  });
423
463
  }
424
464
  return withNews(saved.result, p);
425
465
  }
426
466
  }
427
467
 
428
- if (!force && inflight.has(key)) return withNews(await inflight.get(key), p);
468
+ return withNews(await refreshDatasetStateInBackground(p, key, { force, onProgress }), p);
469
+ }
470
+
471
+ // Фоновая догрузка свежих 10-минутных свечей и пересборка контекста u (не блокирует живой тик котировок)
472
+ function refreshDatasetStateInBackground(p, key, { force = false, onProgress } = {}) {
473
+ if (!force && inflight.has(key)) return inflight.get(key);
474
+ const autoAdapt = p.modelAutoAdapt !== false;
475
+ const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
476
+ const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
477
+ const now = Date.now();
429
478
  const task = (async () => {
430
479
  hub.emit({ type: 'trading:pulse', kind: 'train', ts: now, text: 'Обновление котировок и расчёт прогноза…' });
431
480
  const trainedToday = !force && isModelTrainedToday(p, now);
@@ -440,17 +489,18 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
440
489
  const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
441
490
  const stateObj = baseState && baseState.key === key ? baseState : { prevExpMap: null, prevRankMap: null };
442
491
  attachDeltas(sortedRows, stateObj);
443
- const liveTs = liveSnap?.ts || now;
492
+ const finishNow = Date.now();
493
+ const liveTs = liveSnap?.ts || finishNow;
444
494
  const barTs = ds.timeline[last];
445
495
  const result = {
446
- at: now,
447
- trainedAt: r.trainedAt || now,
496
+ at: finishNow,
497
+ trainedAt: r.trainedAt || finishNow,
448
498
  liveTs,
449
499
  barTs,
450
500
  bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
451
501
  horizon: effHorizon, direction: p.modelDirection, model: r.chosen, thresholdPct: round(r.threshold * 100, 3),
452
- buyThresholdPct: round((r.needL ?? r.threshold) * 100, 3),
453
- shortThresholdPct: round((r.needS ?? r.threshold) * 100, 3),
502
+ buyThresholdPct: round(Math.max((r.needL ?? r.threshold) * 100, 0.30), 3),
503
+ shortThresholdPct: round(Math.max((r.needS ?? r.threshold) * 100, 0.31), 3),
454
504
  marketDown: r.marketDown,
455
505
  rows: sortedRows,
456
506
  map: { symbols: ds.tokens.map((t) => t.symbol), names: ds.tokens.map((t) => t.name), pts: snap.pts.map(([x, y]) => [round(x, 3), round(y, 3)]), cl: snap.clusters },
@@ -458,8 +508,8 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
458
508
  };
459
509
  baseState = {
460
510
  key,
461
- trainedAt: r.trainedAt || now,
462
- dsLoadedAt: now,
511
+ trainedAt: r.trainedAt || finishNow,
512
+ dsLoadedAt: finishNow,
463
513
  ds,
464
514
  last,
465
515
  f: r.f,
@@ -470,12 +520,12 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
470
520
  prevExpMap: stateObj.prevExpMap,
471
521
  prevRankMap: stateObj.prevRankMap,
472
522
  lastTickResult: result,
473
- lastTickAt: now,
523
+ lastTickAt: finishNow,
474
524
  };
475
525
  kvSet('lastForecastSnap', { key, result });
476
- hub.emit({ type: 'trading:pulse', kind: 'radar', ts: now, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
526
+ hub.emit({ type: 'trading:pulse', kind: 'radar', ts: finishNow, bar: result.bar, signals: sortedRows.filter((x) => x.signal || x.shortSignal).length });
477
527
  return result;
478
528
  })().finally(() => inflight.delete(key));
479
529
  inflight.set(key, task);
480
- return withNews(await task, p);
530
+ return task;
481
531
  }
@@ -120,7 +120,7 @@ class LiveEngine {
120
120
  }
121
121
 
122
122
  status(uid = currentUserId()) {
123
- const list = [...this.accounts.values()].filter((s) => (s.userId || 1) === (uid || 1)).sort((a, b) => b.runId - a.runId).map((s) => this.#accountStatus(s));
123
+ const list = [...this.accounts.values()].filter((s) => (s.userId || 1) === (uid ?? 1)).sort((a, b) => b.runId - a.runId).map((s) => this.#accountStatus(s));
124
124
  const primary = list.find((a) => a.running) || list[0] || null;
125
125
  if (!primary) return { running: false, accounts: [] };
126
126
  return {
@@ -462,7 +462,8 @@ export function fitAttention(f) {
462
462
  }
463
463
  }, p, 1e-2);
464
464
  const macroHeads = ML.flatMap((m, mi) => SCALES.map((sc, si) => ({ label: `${m.label}, ${sc.label}`, coef: coef[Hn + 3 + mi * 3 + si] })));
465
- return { name: 'attention', coef, As, F, macroHeads };
465
+ const inertiaCoef = fitInertia(f).coef;
466
+ return { name: 'attention', coef, inertiaCoef, As, F, macroHeads };
466
467
  }
467
468
  function predictAttention(m, f, u) {
468
469
  const ML = f.macro ? f.macro.list : [];
@@ -478,6 +479,13 @@ function predictAttention(m, f, u) {
478
479
  for (let q = 0; q < 3; q++) zz += m.coef[HEADS.length + 3 + mi * 3 + q] * zs[q * f.W + u];
479
480
  s += ML[mi].b[i] * zz;
480
481
  }
482
+ // Ансамбль с базовой моделью своей инерции (при наличии весов inertiaCoef):
483
+ // сглаживает ошибки внимания к соседям на тонких бумагах и усиливает самые чистые сигналы возврата/импульса
484
+ if (m.inertiaCoef) {
485
+ let sIn = 0;
486
+ for (let q = 0; q < 3; q++) sIn += m.inertiaCoef[q] * f.X[0][q][k];
487
+ s = 0.45 * s + 0.55 * sIn;
488
+ }
481
489
  out[i] = s;
482
490
  }
483
491
  return out;
@@ -496,13 +504,14 @@ export const modelNames = () => Object.keys(MODELS);
496
504
  export function explainAttention(m, f, u, j, top = 3) {
497
505
  const N = f.N;
498
506
  const contrib = new Float64Array(N);
507
+ const wAtt = m.inertiaCoef ? 0.45 : 1, wIn = m.inertiaCoef ? 0.55 : 0;
499
508
  HEADS.forEach((h, hi) => {
500
509
  const A = m.As[BETAS.findIndex((b) => b.v === h.beta)];
501
510
  const X = f.X[h.kind === 'price' ? 0 : 1][h.scale];
502
- for (let i = 0; i < N; i++) contrib[i] += m.coef[hi] * A[j * N + i] * X[u * N + i];
511
+ for (let i = 0; i < N; i++) contrib[i] += wAtt * m.coef[hi] * A[j * N + i] * X[u * N + i];
503
512
  });
504
513
  let self = 0;
505
- for (let q = 0; q < 3; q++) self += m.coef[HEADS.length + q] * f.X[0][q][u * N + j];
514
+ for (let q = 0; q < 3; q++) self += (wAtt * m.coef[HEADS.length + q] + wIn * (m.inertiaCoef?.[q] || 0)) * f.X[0][q][u * N + j];
506
515
  const idx = [...contrib.keys()].filter((i) => i !== j).sort((a, b) => Math.abs(contrib[b]) - Math.abs(contrib[a])).slice(0, top);
507
516
  return { neighbors: idx.map((i) => ({ i, contrib: contrib[i] })), self };
508
517
  }