neuroalpha 0.3.17 → 0.5.2
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/config/certs/russian_trusted_root_ca.pem +33 -0
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
- 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
- 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
- package/package.json +60 -53
- package/public/activate.html +36 -0
- package/public/activate.js +33 -0
- package/public/admin.html +126 -7
- package/public/app.js +589 -66
- package/public/index.html +48 -21
- package/public/landing.html +106 -37
- package/public/legal.html +4 -4
- package/public/styles.css +152 -36
- package/server/api/routes.js +308 -37
- package/server/auth/index.js +79 -15
- package/server/brokers/index.js +1095 -168
- package/server/build-info.js +7 -0
- package/server/config.js +13 -6
- package/server/data/moex.js +6 -4
- package/server/db.js +22 -18
- package/server/desktop-entry.js +103 -0
- package/server/hub.js +28 -14
- package/server/index.js +82 -12
- package/server/instance/agent.js +259 -0
- package/server/instance/central.js +491 -0
- package/server/instance/identity.js +79 -0
- package/server/instance/provision.js +166 -0
- package/server/instance/signing.js +71 -0
- package/server/model/edge.js +19 -0
- package/server/model/forecast.js +107 -57
- package/server/model/live.js +1 -1
- package/server/model/models.js +12 -3
- package/server/model/research.js +218 -30
- package/server/notify/index.js +51 -16
- package/server/role.js +44 -0
- package/server/security.js +40 -0
- package/server/strategist/index.js +86 -47
- package/server/strategist/tools.js +231 -42
- 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);
|
package/server/model/forecast.js
CHANGED
|
@@ -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
|
-
// Быстрое получение живых цен
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
332
|
+
lastMovePct,
|
|
308
333
|
weight: round(weight, 3),
|
|
309
|
-
signal: tk.liq[idx] >= LIQ_MIN && exp >
|
|
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) Если с последнего живого тика прошло меньше
|
|
383
|
-
if (!force && baseState && baseState.key === key && baseState.lastTickResult && now - baseState.lastTickAt <
|
|
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) Если
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
492
|
+
const finishNow = Date.now();
|
|
493
|
+
const liveTs = liveSnap?.ts || finishNow;
|
|
444
494
|
const barTs = ds.timeline[last];
|
|
445
495
|
const result = {
|
|
446
|
-
at:
|
|
447
|
-
trainedAt: r.trainedAt ||
|
|
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 ||
|
|
462
|
-
dsLoadedAt:
|
|
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:
|
|
523
|
+
lastTickAt: finishNow,
|
|
474
524
|
};
|
|
475
525
|
kvSet('lastForecastSnap', { key, result });
|
|
476
|
-
hub.emit({ type: 'trading:pulse', kind: 'radar', ts:
|
|
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
|
|
530
|
+
return task;
|
|
481
531
|
}
|
package/server/model/live.js
CHANGED
|
@@ -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
|
|
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 {
|
package/server/model/models.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
// Равный ансамбль 50/50 с базовой моделью своей инерции (при наличии весов 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.50 * s + 0.50 * 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.50 : 1, wIn = m.inertiaCoef ? 0.50 : 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
|
}
|