neuroalpha 0.2.0 → 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 +17 -0
- package/README.md +9 -3
- package/bin/cli.js +69 -69
- package/config/seed-run.json +1 -0
- package/config/strategy.json +4 -10
- package/deploy/install.sh +128 -0
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +103 -0
- 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 +171 -0
- package/package.json +15 -5
- package/public/admin.html +449 -0
- package/public/app.js +1182 -379
- package/public/index.html +87 -22
- package/public/landing.html +1217 -870
- package/public/legal.html +51 -8
- package/public/styles.css +71 -16
- package/server/api/routes.js +220 -35
- package/server/auth/index.js +633 -340
- package/server/billing/yookassa.js +1 -0
- package/server/brokers/index.js +1369 -645
- package/server/config.js +6 -4
- package/server/data/moex.js +1 -0
- package/server/db.js +69 -4
- package/server/hub.js +1 -0
- package/server/index.js +10 -2
- package/server/llm/client.js +1 -0
- package/server/llm/providers.js +50 -3
- package/server/model/dataset.js +362 -352
- package/server/model/forecast.js +202 -37
- package/server/model/linalg.js +1 -0
- package/server/model/live.js +476 -486
- package/server/model/metrics.js +1 -0
- package/server/model/models.js +31 -21
- package/server/model/profiles.js +1 -0
- package/server/model/research.js +128 -205
- package/server/model/vocab.js +1 -0
- package/server/news/index.js +1 -0
- package/server/notify/index.js +304 -168
- package/server/params-meta.js +22 -26
- package/server/settings.js +1 -0
- package/server/strategist/analytics.js +2 -1
- package/server/strategist/index.js +7 -7
- package/server/strategist/tools.js +2 -1
- package/server/util.js +1 -0
- package/server/model/gpu-worker.html +0 -11
- package/server/model/gpu-worker.js +0 -60
- package/server/model/tf-node.js +0 -26
- package/server/model/transformer-core.js +0 -330
- package/server/model/transformer-gpu.js +0 -221
- package/server/model/transformer-pool.js +0 -200
- package/server/model/transformer-worker.js +0 -28
- package/server/model/transformer.js +0 -57
package/server/model/research.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
1
2
|
import { normalizeParams } from '../settings.js';
|
|
2
3
|
import { computeReport, downsample, buildPeriods } from './metrics.js';
|
|
3
4
|
import { round } from '../util.js';
|
|
@@ -18,9 +19,7 @@ const MAX_CONCURRENT_RESEARCH = 1;
|
|
|
18
19
|
const tick = () => new Promise((r) => setImmediate(r));
|
|
19
20
|
|
|
20
21
|
// Ключевые параметры для сравнения исследований (включая capital — он влияет на рубли)
|
|
21
|
-
|
|
22
|
-
const TF_KEYS = ['transformerEpochs', 'transformerStride', 'transformerGpu'];
|
|
23
|
-
const REUSE_KEYS = ['modelAutoAdapt', 'modelType', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelMinHoldHours', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelUseMacro', 'modelUseDividends', 'modelTextWeight', 'modelUseTransformer', ...TF_KEYS, 'commissionPct', 'slippagePct', 'oosPct', 'capital'];
|
|
22
|
+
const REUSE_KEYS = ['modelAutoAdapt', 'modelType', 'modelHorizon', 'modelMinEdge', 'modelExitRatio', 'modelTopK', 'modelAllocMode', 'modelDirection', 'modelMarketFilter', 'modelShortFeePct', 'modelTrainDays', 'modelHistoryDays', 'modelEmbedDim', 'modelUseMacro', 'modelUseDividends', 'modelTextWeight', 'commissionPct', 'slippagePct', 'oosPct', 'capital'];
|
|
24
23
|
|
|
25
24
|
// Фильтр падающего рынка: индекс Мосбиржи в среднесрочном нисходящем тренде (ниже уровня 10 торговых дней назад)
|
|
26
25
|
// или в сильном локальном обвале (ниже уровня дня и 3 дней назад на >1%)
|
|
@@ -35,14 +34,13 @@ export function isMarketDown(ds, t) {
|
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
// Версия обработки данных: при её смене прежние прогоны не считаются «такими же» (у старых её нет).
|
|
38
|
-
//
|
|
39
|
-
const DATA_VERSION =
|
|
37
|
+
// 7 — мульти-горизонтное обучение (0.6H / 1.0H / 1.4H) + взвешивание слота по силе сигнала (Conviction Sizing) в Авто-режиме
|
|
38
|
+
const DATA_VERSION = 7;
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
// настройки трансформера важны, только если он участвует
|
|
40
|
+
const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
|
|
43
41
|
function reuseParamsKey(params) {
|
|
44
|
-
const
|
|
45
|
-
return JSON.stringify([params._data ?? 0, ...REUSE_KEYS.map((k) => (
|
|
42
|
+
const autoOn = (params.modelAutoAdapt ?? strategyDefaults.modelAutoAdapt) !== false;
|
|
43
|
+
return JSON.stringify([params._data ?? 0, ...REUSE_KEYS.map((k) => (autoOn && k in AUTO_LOCKED ? AUTO_LOCKED[k] : (params[k] ?? strategyDefaults[k])))]);
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
function findReusableRun(params) {
|
|
@@ -143,7 +141,11 @@ const fmtDay = (ts) => new Date(ts + MSK).toISOString().slice(0, 10).split('-').
|
|
|
143
141
|
|
|
144
142
|
async function run(runId, p, state) {
|
|
145
143
|
const emit = (m) => hub.emit({ type: 'research:progress', runId, ...m });
|
|
146
|
-
const
|
|
144
|
+
const autoAdapt = p.modelAutoAdapt !== false;
|
|
145
|
+
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
146
|
+
const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
|
|
147
|
+
const H = barsFromHours(effHorizon); // горизонт в свечах по 10 минут
|
|
148
|
+
const maxH = autoAdapt ? Math.round(H * 1.4) : H;
|
|
147
149
|
const tillTs = Date.now();
|
|
148
150
|
const researchFrom = tillTs - p.modelHistoryDays * DAY;
|
|
149
151
|
const fromTs = researchFrom - Math.ceil(p.modelTrainDays * 1.5 + 20) * DAY;
|
|
@@ -151,7 +153,7 @@ async function run(runId, p, state) {
|
|
|
151
153
|
emit({ stage: 'data', pct: 0, text: 'Загружаем словарь рынка (состав индекса широкого рынка)…' });
|
|
152
154
|
const ds = await loadDataset({
|
|
153
155
|
fromTs, tillTs, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends, signal: state.ctrl.signal,
|
|
154
|
-
onProgress: (x) => emit({ stage: 'data', pct: Math.round((x.done / x.total) * 100), text: `Загружаем цены: ${x.done} из ${x.total}.
|
|
156
|
+
onProgress: (x) => emit({ stage: 'data', pct: Math.round((x.done / x.total) * 100), text: `Загружаем цены: ${x.done} из ${x.total} (${x.symbol})…`, symbol: x.symbol, done: x.done, total: x.total }),
|
|
155
157
|
});
|
|
156
158
|
const { tokens, T, timeline } = ds;
|
|
157
159
|
const N = tokens.length;
|
|
@@ -172,13 +174,8 @@ async function run(runId, p, state) {
|
|
|
172
174
|
});
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
// три базовые модели
|
|
177
|
+
// три базовые модели всегда сравниваются между собой
|
|
176
178
|
const names = modelNames(p);
|
|
177
|
-
const useTf = names.includes('transformer');
|
|
178
|
-
const tfMod = useTf ? await import('./transformer.js') : null;
|
|
179
|
-
const tfCfg = useTf ? tfMod.transformerCfg(p) : null;
|
|
180
|
-
const tfInfo = useTf ? { folds: [], device: 'процессор', threads: 1, note: '' } : null;
|
|
181
|
-
const fmtTfInfo = ({ backend, device, ...i }) => ({ ...i, valIc: i.valIc === null ? null : round(i.valIc, 4), scale: round(i.scale, 4) });
|
|
182
179
|
const preds = Object.fromEntries(names.map((n) => [n, new Map()])); // t -> Float64Array(N)
|
|
183
180
|
const yraw = new Map(); // t -> Float64Array(N)
|
|
184
181
|
const sigmaAt = new Map(); // t -> обычный размах акций в этом окне обучения
|
|
@@ -187,118 +184,75 @@ async function run(runId, p, state) {
|
|
|
187
184
|
const folds = Math.ceil((T - H - firstTest) / testLen);
|
|
188
185
|
let fi = 0;
|
|
189
186
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
};
|
|
201
|
-
const onAbort = () => tfRunner?.cancel();
|
|
202
|
-
state.ctrl.signal.addEventListener('abort', onAbort, { once: true });
|
|
203
|
-
|
|
204
|
-
try {
|
|
205
|
-
while (testStart < T - H - 2) {
|
|
206
|
-
if (state.cancelled) throw new Error('Отменено');
|
|
207
|
-
const testEnd = Math.min(testStart + testLen, T - H - 2);
|
|
208
|
-
const trainStart = Math.max(FEATURE_WARMUP + H + 2, testStart - trainLen);
|
|
209
|
-
mainText = `Обучение ${fi + 1} из ${folds}: учимся до ${fmtDay(timeline[testStart])}, прогнозируем до ${fmtDay(timeline[testEnd - 1])}`;
|
|
210
|
-
if (tfRunner) progress();
|
|
211
|
-
else emit({ stage: 'sim', pct: Math.round((fi / folds) * 100), text: mainText });
|
|
187
|
+
while (testStart < T - maxH - 2) {
|
|
188
|
+
if (state.cancelled) throw new Error('Отменено');
|
|
189
|
+
const testEnd = Math.min(testStart + testLen, T - maxH - 2);
|
|
190
|
+
const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, testStart - trainLen);
|
|
191
|
+
const mainText = `Обучение ${fi + 1} из ${folds}: учимся до ${fmtDay(timeline[testStart])}, прогнозируем до ${fmtDay(timeline[testEnd - 1])}`;
|
|
192
|
+
await tick();
|
|
193
|
+
const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
|
|
194
|
+
const fitted = {};
|
|
195
|
+
for (const n of names) {
|
|
196
|
+
fitted[n] = await MODELS[n].fit(f);
|
|
212
197
|
await tick();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
tfRunner = r.runner;
|
|
220
|
-
tfInfo.note = r.note;
|
|
221
|
-
if (r.note) emit({ stage: 'sim', pct: Math.round((fi / folds) * 100), text: `Трансформер: ${r.note}` });
|
|
222
|
-
if (tfRunner) { tfInfo.device = tfRunner.where; tfInfo.threads = tfRunner.threads; }
|
|
223
|
-
if (state.cancelled) throw new Error('Отменено');
|
|
224
|
-
}
|
|
225
|
-
const fitNames = tfRunner ? names.filter((n) => n !== 'transformer') : names;
|
|
226
|
-
const fitted = {};
|
|
227
|
-
const foldText = `Обучение ${fi + 1} из ${folds}`;
|
|
228
|
-
try {
|
|
229
|
-
for (const n of fitNames) {
|
|
230
|
-
// трансформер учится по эпохам: прогресс и отмена — между батчами
|
|
231
|
-
const opts = n === 'transformer' ? {
|
|
232
|
-
cfg: tfCfg,
|
|
233
|
-
isCancelled: () => state.cancelled,
|
|
234
|
-
onProgress: (x) => emit({ stage: 'sim', pct: Math.round(((fi + (x.epoch - 1) / x.epochs) / folds) * 100), text: `${foldText}: трансформер, эпоха ${x.epoch}…` }),
|
|
235
|
-
} : undefined;
|
|
236
|
-
fitted[n] = await MODELS[n].fit(f, opts);
|
|
237
|
-
await tick();
|
|
238
|
-
if (state.cancelled) throw new Error('Отменено');
|
|
239
|
-
}
|
|
240
|
-
const us = [];
|
|
241
|
-
for (let t = testStart; t < testEnd; t++) if (f.yok[t - f.lo]) us.push(t - f.lo);
|
|
242
|
-
// тяжёлые модели (трансформер) предсказывают сразу пачкой моментов
|
|
243
|
-
const many = Object.fromEntries(fitNames.filter((n) => MODELS[n].predictMany).map((n) => [n, MODELS[n].predictMany(fitted[n], f, us)]));
|
|
244
|
-
for (const [k, u] of us.entries()) {
|
|
245
|
-
if ((k & 511) === 0) {
|
|
246
|
-
if (state.cancelled) throw new Error('Отменено');
|
|
247
|
-
await tick();
|
|
248
|
-
}
|
|
249
|
-
const t = f.lo + u;
|
|
250
|
-
for (const n of fitNames) preds[n].set(t, many[n] ? many[n].get(u) : MODELS[n].predict(fitted[n], f, u));
|
|
251
|
-
yraw.set(t, Float64Array.from(f.yraw.subarray(u * N, u * N + N)));
|
|
252
|
-
sigmaAt.set(t, f.sigma);
|
|
253
|
-
}
|
|
254
|
-
if (tfRunner) {
|
|
255
|
-
// массивы окна уходят исполнителю без копирования: базовые модели этого окна уже посчитаны
|
|
256
|
-
const k = fi;
|
|
257
|
-
const pr = tfRunner.submit(tfMod.transformerInput(f), us, tfCfg, {
|
|
258
|
-
transfer: true,
|
|
259
|
-
onProgress: (x) => { tfFrac.set(k, (x.epoch - 1) / x.epochs); progress(); },
|
|
260
|
-
}).then((r) => { tfDone++; tfFrac.delete(k); progress(); return r; });
|
|
261
|
-
pr.catch((e) => { tfFail = tfFail || e; });
|
|
262
|
-
tfJobs.push({ lo: f.lo, pr });
|
|
263
|
-
}
|
|
264
|
-
} finally {
|
|
265
|
-
// веса нейросети освобождаем после каждого окна (и при отмене)
|
|
266
|
-
for (const n of fitNames) if (fitted[n]?.dispose) {
|
|
267
|
-
const m = fitted[n];
|
|
268
|
-
m.dispose();
|
|
269
|
-
if (tfInfo && n === 'transformer') tfInfo.folds.push(fmtTfInfo({ ...m.info, tensors: m.tf.memory().numTensors }));
|
|
270
|
-
delete fitted[n];
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
// снимок карты рынка на начало месяца прогноза
|
|
274
|
-
const snap = mapSnapshot(f);
|
|
275
|
-
const pts = alignTo(prevPts, snap.pts);
|
|
276
|
-
const cl = matchClusters(prevCl, snap.clusters, 8);
|
|
277
|
-
snapshots.push({ ts: timeline[testStart], pts: pts.map(([x, y]) => [round(x, 3), round(y, 3)]), cl });
|
|
278
|
-
prevPts = pts; prevCl = cl;
|
|
279
|
-
lastFold = f; lastModels = fitted;
|
|
280
|
-
testStart = testEnd;
|
|
281
|
-
fi++;
|
|
282
|
-
// не копим очередь окон (память): ждём, пока у исполнителя не освободится место
|
|
283
|
-
while (tfRunner && tfRunner.backlog > tfRunner.threads) {
|
|
198
|
+
if (state.cancelled) throw new Error('Отменено');
|
|
199
|
+
}
|
|
200
|
+
const us = [];
|
|
201
|
+
for (let t = testStart; t < testEnd; t++) if (f.yok[t - f.lo]) us.push(t - f.lo);
|
|
202
|
+
for (const [k, u] of us.entries()) {
|
|
203
|
+
if ((k & 511) === 0) {
|
|
284
204
|
if (state.cancelled) throw new Error('Отменено');
|
|
285
|
-
|
|
286
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
205
|
+
await tick();
|
|
287
206
|
}
|
|
207
|
+
const t = f.lo + u;
|
|
208
|
+
for (const n of names) preds[n].set(t, MODELS[n].predict(fitted[n], f, u));
|
|
209
|
+
yraw.set(t, Float64Array.from(f.yraw.subarray(u * N, u * N + N)));
|
|
210
|
+
sigmaAt.set(t, f.sigma);
|
|
288
211
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
212
|
+
// снимок карты рынка на начало месяца прогноза
|
|
213
|
+
const snap = mapSnapshot(f);
|
|
214
|
+
const pts = alignTo(prevPts, snap.pts);
|
|
215
|
+
const cl = matchClusters(prevCl, snap.clusters, 8);
|
|
216
|
+
const ptsRound = pts.map(([x, y]) => [round(x, 3), round(y, 3)]);
|
|
217
|
+
snapshots.push({ ts: timeline[testStart], pts: ptsRound, cl });
|
|
218
|
+
prevPts = pts; prevCl = cl;
|
|
219
|
+
lastFold = f; lastModels = fitted;
|
|
220
|
+
|
|
221
|
+
// живая телеметрия шага обучения для инфографики в интерфейсе
|
|
222
|
+
const attM = fitted.attention;
|
|
223
|
+
let foldIc = null;
|
|
224
|
+
if (preds.attention) {
|
|
225
|
+
let icS = 0, icK = 0;
|
|
226
|
+
for (let t = testStart; t < testEnd; t += Math.max(1, Math.floor(H / 2))) {
|
|
227
|
+
const pr = preds.attention.get(t), y = yraw.get(t);
|
|
228
|
+
if (!pr || !y) continue;
|
|
229
|
+
const ok = [...pr.keys()].filter((i) => tokens[i].liq[t] >= LIQ_MIN);
|
|
230
|
+
if (ok.length >= 10) { icS += spearman(ok.map((i) => pr[i]), ok.map((i) => y[i])); icK++; }
|
|
231
|
+
}
|
|
232
|
+
if (icK > 0) foldIc = round(icS / icK, 4);
|
|
297
233
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
234
|
+
const liveViz = {
|
|
235
|
+
fold: fi + 1,
|
|
236
|
+
folds,
|
|
237
|
+
tokens: N,
|
|
238
|
+
horizon: effHorizon,
|
|
239
|
+
trainFrom: timeline[trainStart],
|
|
240
|
+
testFrom: timeline[testStart],
|
|
241
|
+
testTill: timeline[testEnd - 1],
|
|
242
|
+
periodFrom: timeline[Math.max(0, firstTest - trainLen)],
|
|
243
|
+
periodTill: timeline[T - 1],
|
|
244
|
+
foldIc,
|
|
245
|
+
pts: ptsRound,
|
|
246
|
+
cl,
|
|
247
|
+
...(fi === 0 ? { symbols: tokens.map((tk) => tk.symbol) } : {}),
|
|
248
|
+
...(attM ? {
|
|
249
|
+
heads: HEADS.map((h, i) => ({ label: h.label, coef: round(attM.coef[i], 4) })),
|
|
250
|
+
self: [round(attM.coef[HEADS.length], 4), round(attM.coef[HEADS.length + 1], 4), round(attM.coef[HEADS.length + 2], 4)],
|
|
251
|
+
} : {}),
|
|
252
|
+
};
|
|
253
|
+
emit({ stage: 'sim', pct: Math.round(((fi + 1) / folds) * 100), text: mainText, viz: liveViz });
|
|
254
|
+
testStart = testEnd;
|
|
255
|
+
fi++;
|
|
302
256
|
}
|
|
303
257
|
|
|
304
258
|
emit({ stage: 'sim', pct: 100, text: 'Считаем результаты…' });
|
|
@@ -309,9 +263,7 @@ async function run(runId, p, state) {
|
|
|
309
263
|
// ---------- портфель из лучших прогнозов: покупки, продажи без покупки, пары «нейтрально к рынку» ----------
|
|
310
264
|
const K = p.modelTopK;
|
|
311
265
|
const dir = p.modelDirection;
|
|
312
|
-
const autoAdapt = p.modelAutoAdapt !== false;
|
|
313
266
|
const exitRatio = Number(p.modelExitRatio ?? 0.5);
|
|
314
|
-
const minHoldBars = p.modelMinHoldHours > 0 ? barsFromHours(p.modelMinHoldHours) : 0;
|
|
315
267
|
const mktFilter = p.modelMarketFilter === 'ma';
|
|
316
268
|
const invVol = p.modelAllocMode === 'invvol';
|
|
317
269
|
const realCost = (p.commissionPct + p.slippagePct) / 100;
|
|
@@ -359,9 +311,9 @@ async function run(runId, p, state) {
|
|
|
359
311
|
// Самообучение правил по прошлым закрытым сделкам (временный карантин «акций-убийц» с затуханием и авто-калибровка порога выхода)
|
|
360
312
|
const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
|
|
361
313
|
const stockAllowed = (i) => !autoAdapt || !(stockCnt[i] >= 3 && stockPnl[i] < -0.012);
|
|
362
|
-
let curExitRatio = autoAdapt ? 0.
|
|
314
|
+
let curExitRatio = autoAdapt ? 0.95 : exitRatio;
|
|
363
315
|
const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
|
|
364
|
-
const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) :
|
|
316
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
|
|
365
317
|
|
|
366
318
|
const closePos = (i, h, t, end, why) => {
|
|
367
319
|
const exitPrice = px(i, t);
|
|
@@ -393,67 +345,49 @@ async function run(runId, p, state) {
|
|
|
393
345
|
const pr = preds[name].get(t);
|
|
394
346
|
const sg = sigmaAt.get(t);
|
|
395
347
|
const bear = isMarketDown(ds, t);
|
|
396
|
-
const mktDown = mktFilter &&
|
|
348
|
+
const mktDown = mktFilter && bear;
|
|
397
349
|
let order, entries;
|
|
398
350
|
if (dir !== 'long') {
|
|
399
351
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
400
|
-
const need =
|
|
352
|
+
const need = effMinEdge * 2 * realCost;
|
|
401
353
|
const exitNeed = need * curExitRatio;
|
|
402
354
|
const earlyExitNeed = need * 0.35;
|
|
403
|
-
// держим позицию, пока ожидаемое движение выше порога выхода (
|
|
355
|
+
// держим позицию, пока ожидаемое движение выше порога выхода (первые два полушага — мягкий порог, чтобы не резать сделку от шума)
|
|
404
356
|
for (const [i, h] of [...held]) {
|
|
405
357
|
const e = edge(i);
|
|
406
358
|
const barsHeld = tIn - h.entryT;
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
if ((!lock && !(h.side === 'long' ? e > req : e < -req)) || !liquid(i, t)) closePos(i, h, tIn, false, 'Прогноз ослаб');
|
|
359
|
+
const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
|
|
360
|
+
if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn, false, 'Прогноз ослаб');
|
|
410
361
|
}
|
|
411
|
-
const needL = autoAdapt &&
|
|
412
|
-
const needS = autoAdapt
|
|
362
|
+
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
363
|
+
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
413
364
|
const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]);
|
|
414
365
|
const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) < -needS).sort((a, b) => pr[a] - pr[b]);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
nL = K - countSide('long');
|
|
418
|
-
nS = K - countSide('short');
|
|
419
|
-
} else {
|
|
420
|
-
// нейтрально: равное число покупок и продаж; при autoAdapt уже открытые прибыльные позиции не закрываются принудительно без сигнала
|
|
421
|
-
const pairs = Math.min(K, countSide('long') + candL.length, countSide('short') + candS.length);
|
|
422
|
-
if (!autoAdapt) {
|
|
423
|
-
for (const side of ['long', 'short']) {
|
|
424
|
-
const extra = [...held].filter(([, h]) => h.side === side).sort((a, b) => (side === 'long' ? 1 : -1) * (edge(a[0]) - edge(b[0])));
|
|
425
|
-
for (const [i, h] of extra.slice(0, Math.max(0, extra.length - pairs))) closePos(i, h, tIn, false, 'Нет пары с другой стороны');
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
nL = Math.max(0, pairs - countSide('long'));
|
|
429
|
-
nS = Math.max(0, pairs - countSide('short'));
|
|
430
|
-
}
|
|
366
|
+
const nL = K - countSide('long');
|
|
367
|
+
const nS = K - countSide('short');
|
|
431
368
|
entries = [
|
|
432
369
|
...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]),
|
|
433
370
|
...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)]),
|
|
434
371
|
];
|
|
435
|
-
} else if (
|
|
372
|
+
} else if (effMinEdge > 0) {
|
|
436
373
|
// ожидаемое движение (в долях) = прогноз × обычный размах × √H; входим, только если оно окупает сделку
|
|
437
374
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
438
|
-
const need =
|
|
375
|
+
const need = effMinEdge * 2 * realCost;
|
|
439
376
|
const exitNeed = need * curExitRatio;
|
|
440
377
|
const earlyExitNeed = need * 0.35;
|
|
441
378
|
for (const [i, h] of [...held]) {
|
|
442
379
|
const e = edge(i);
|
|
443
380
|
const barsHeld = tIn - h.entryT;
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
if ((!lock && !(e > req)) || !liquid(i, t)) closePos(i, h, tIn, false);
|
|
381
|
+
const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
|
|
382
|
+
if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn, false);
|
|
447
383
|
}
|
|
448
384
|
const free = K - held.size;
|
|
449
385
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
450
386
|
const cand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, free));
|
|
451
387
|
order = [...held.keys(), ...cand];
|
|
452
388
|
} else {
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
const cand = mktDown ? [] : [...pr.keys()].filter((i) => !lockedSet.has(i) && stockAllowed(i) && pr[i] > 0 && liquid(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - keepLocked.length));
|
|
456
|
-
order = [...keepLocked, ...cand];
|
|
389
|
+
const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && liquid(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
|
|
390
|
+
order = cand;
|
|
457
391
|
const next = new Set(order);
|
|
458
392
|
for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn, false);
|
|
459
393
|
}
|
|
@@ -467,9 +401,12 @@ async function run(runId, p, state) {
|
|
|
467
401
|
}
|
|
468
402
|
const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
469
403
|
let avail = Math.max(0, equity * 0.99 - usedStart);
|
|
404
|
+
const needEdge = effMinEdge * 2 * realCost;
|
|
470
405
|
for (let idx = 0; idx < newEntries.length; idx++) {
|
|
471
406
|
const [i, side, edgeAt] = newEntries[idx];
|
|
472
|
-
const
|
|
407
|
+
const effEdge = edgeAt !== null ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
|
|
408
|
+
const conv = autoAdapt && needEdge > 0 && effEdge > 0 ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (effEdge / needEdge))) : 1;
|
|
409
|
+
const baseAlloc = autoAdapt ? Math.min(equity * maxSlotWeight * conv, avail / Math.max(1, newEntries.length - idx)) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
|
|
473
410
|
let alloc = baseAlloc;
|
|
474
411
|
if (invVol && avgInv > 0 && sg[i] > 0) {
|
|
475
412
|
const w = Math.max(0.4, Math.min(1.8, (1 / sg[i]) / avgInv));
|
|
@@ -499,7 +436,7 @@ async function run(runId, p, state) {
|
|
|
499
436
|
function tradeRec(name, i, h, t, exitPrice, pnl, commission, borrow, end = false, why = '') {
|
|
500
437
|
const heldMin = (t - h.entryT) * BAR_INTERVAL; // минуты
|
|
501
438
|
const short = h.side === 'short';
|
|
502
|
-
const hz = round(
|
|
439
|
+
const hz = round(effHorizon, 2);
|
|
503
440
|
const label = MODELS[name].label;
|
|
504
441
|
const reasonEntry = dir === 'long' ? `Модель «${label}»: акция среди ${K} лучших прогнозов на ${hz} ч вперёд`
|
|
505
442
|
: short ? `Модель «${label}»: продажа: ожидаемо слабее рынка на ${Math.abs(h.expectedPct)}% за ${hz} ч`
|
|
@@ -638,9 +575,6 @@ async function run(runId, p, state) {
|
|
|
638
575
|
heads, self, macroHeads,
|
|
639
576
|
map: { snapshots, eigenvalues: lastFold.eigenvalues.map((v) => round(v, 3)) },
|
|
640
577
|
neighbors,
|
|
641
|
-
// трансформер: число весов и ход обучения по окнам (эпохи, совпадение на проверке)
|
|
642
|
-
// где шло обучение: «процессор» или «видеокарта: <название>»; потоков — сколько окон обучалось одновременно
|
|
643
|
-
...(tfInfo ? { transformer: { params: tfInfo.folds[0]?.params ?? null, epochs: tfCfg.epochs, stride: tfCfg.stride, device: tfInfo.device, threads: tfInfo.threads, ...(tfInfo.note ? { note: tfInfo.note } : {}), folds: tfInfo.folds } } : {}),
|
|
644
578
|
});
|
|
645
579
|
finishRun(runId, { metrics: report, equity: downsample(results[chosen].sim.curve) });
|
|
646
580
|
return report;
|
|
@@ -704,12 +638,12 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
704
638
|
const { tokens, T, timeline } = ds;
|
|
705
639
|
const N = tokens.length, bpd = ds.barsPerDay;
|
|
706
640
|
const trainLen = Math.round(p.modelTrainDays * bpd), testLen = Math.round(20 * bpd);
|
|
707
|
-
const chosen = p.modelType
|
|
641
|
+
const chosen = p.modelType in MODELS ? p.modelType : 'attention';
|
|
708
642
|
const realCost = (p.commissionPct + p.slippagePct) / 100;
|
|
709
643
|
const K = p.modelTopK, dir = p.modelDirection, slots = dir === 'long' ? K : 2 * K;
|
|
710
644
|
const autoAdapt = p.modelAutoAdapt !== false;
|
|
645
|
+
const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
|
|
711
646
|
const exitRatio = Number(p.modelExitRatio ?? 0.5);
|
|
712
|
-
const minHoldBars = p.modelMinHoldHours > 0 ? barsFromHours(p.modelMinHoldHours) : 0;
|
|
713
647
|
const mktFilter = p.modelMarketFilter === 'ma', invVol = p.modelAllocMode === 'invvol';
|
|
714
648
|
const liquid = (i, t) => tokens[i].liq[t] >= LIQ_MIN && tokens[i].open[t + 1] > 0;
|
|
715
649
|
const tsOf = (t) => timeline[Math.min(t, T - 1)];
|
|
@@ -730,17 +664,18 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
730
664
|
const histDays = Math.max(120, Math.min(1100, p.modelHistoryDays + sh));
|
|
731
665
|
const resFrom = periodTill - histDays * DAY;
|
|
732
666
|
let testStart = Math.max(timeline.findIndex((t) => t >= resFrom), trainLen + 10);
|
|
733
|
-
const
|
|
667
|
+
const maxH = autoAdapt ? Math.round(H * 1.4) : H;
|
|
668
|
+
const folds = Math.max(1, Math.ceil((T - maxH - testStart) / testLen));
|
|
734
669
|
let fi = 0;
|
|
735
670
|
const preds = new Map(), sigmaAt = new Map();
|
|
736
|
-
while (testStart < T -
|
|
671
|
+
while (testStart < T - maxH - 2) {
|
|
737
672
|
if (st.cancelled) throw new Error('Отменено');
|
|
738
|
-
const testEnd = Math.min(testStart + testLen, T -
|
|
739
|
-
const trainStart = Math.max(FEATURE_WARMUP +
|
|
673
|
+
const testEnd = Math.min(testStart + testLen, T - maxH - 2);
|
|
674
|
+
const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, testStart - trainLen);
|
|
740
675
|
const pct = Math.min(99, 10 + Math.round(((si + fi / folds) / shifts.length) * 90));
|
|
741
676
|
emitStab({ pct, text: `Проверка устойчивости: сдвиг ${si + 1} из ${shifts.length} (${shLabel}), шаг ${fi + 1} из ${folds}…` });
|
|
742
677
|
await tick();
|
|
743
|
-
const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles });
|
|
678
|
+
const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
|
|
744
679
|
const m = await MODELS[chosen].fit(f);
|
|
745
680
|
for (let t = testStart; t < testEnd; t++) {
|
|
746
681
|
const u = t - f.lo;
|
|
@@ -762,9 +697,9 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
762
697
|
const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
|
|
763
698
|
const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
|
|
764
699
|
const stockAllowed = (i) => !autoAdapt || !(stockCnt[i] >= 3 && stockPnl[i] < -0.012);
|
|
765
|
-
const curExitRatio = autoAdapt ? 0.
|
|
700
|
+
const curExitRatio = autoAdapt ? 0.95 : exitRatio;
|
|
766
701
|
const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
|
|
767
|
-
const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) :
|
|
702
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
|
|
768
703
|
const closePos = (i, h, t) => {
|
|
769
704
|
const exitPrice = px(i, t);
|
|
770
705
|
const gross = h.side === 'short' ? h.alloc * (1 - exitPrice / h.entryPrice) : h.alloc * (exitPrice / h.entryPrice - 1);
|
|
@@ -786,52 +721,38 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
786
721
|
}
|
|
787
722
|
const pr = preds.get(t), sg = sigmaAt.get(t);
|
|
788
723
|
const bear = isMarketDown(ds, t);
|
|
789
|
-
const mktDown = mktFilter &&
|
|
724
|
+
const mktDown = mktFilter && bear;
|
|
790
725
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
791
|
-
const need =
|
|
726
|
+
const need = effMinEdge * 2 * realCost, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
|
|
792
727
|
let order, entries;
|
|
793
728
|
if (dir !== 'long') {
|
|
794
729
|
for (const [i, h] of [...held]) {
|
|
795
730
|
const e = edge(i), barsHeld = tIn - h.entryT;
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
if ((!lock && !(h.side === 'long' ? e > req : e < -req)) || !liquid(i, t)) closePos(i, h, tIn);
|
|
731
|
+
const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
|
|
732
|
+
if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn);
|
|
799
733
|
}
|
|
800
|
-
const needL = autoAdapt &&
|
|
801
|
-
const needS = autoAdapt
|
|
734
|
+
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
735
|
+
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
802
736
|
const candL = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]);
|
|
803
737
|
const candS = [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) < -needS).sort((a, b) => pr[a] - pr[b]);
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if (!autoAdapt) {
|
|
808
|
-
for (const side of ['long', 'short']) {
|
|
809
|
-
const extra = [...held].filter(([, h]) => h.side === side).sort((a, b) => (side === 'long' ? 1 : -1) * (edge(a[0]) - edge(b[0])));
|
|
810
|
-
for (const [i, h] of extra.slice(0, Math.max(0, extra.length - pairs))) closePos(i, h, tIn);
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
nL = Math.max(0, pairs - countSide('long')); nS = Math.max(0, pairs - countSide('short'));
|
|
814
|
-
}
|
|
815
|
-
entries = [...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long']), ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short'])];
|
|
816
|
-
} else if (p.modelMinEdge > 0) {
|
|
738
|
+
const nL = K - countSide('long'), nS = K - countSide('short');
|
|
739
|
+
entries = [...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]), ...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)])];
|
|
740
|
+
} else if (effMinEdge > 0) {
|
|
817
741
|
for (const [i, h] of [...held]) {
|
|
818
742
|
const e = edge(i), barsHeld = tIn - h.entryT;
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
if ((!lock && !(e > req)) || !liquid(i, t)) closePos(i, h, tIn);
|
|
743
|
+
const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
|
|
744
|
+
if (!(e > req) || !liquid(i, t)) closePos(i, h, tIn);
|
|
822
745
|
}
|
|
823
746
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
824
747
|
const cand = mktDown ? [] : [...pr.keys()].filter((i) => !held.has(i) && stockAllowed(i) && liquid(i, t) && edge(i) > needL).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - held.size));
|
|
825
748
|
order = [...held.keys(), ...cand];
|
|
826
749
|
} else {
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
const cand = mktDown ? [] : [...pr.keys()].filter((i) => !lockedSet.has(i) && stockAllowed(i) && pr[i] > 0 && liquid(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, Math.max(0, K - keepLocked.length));
|
|
830
|
-
order = [...keepLocked, ...cand];
|
|
750
|
+
const cand = mktDown ? [] : [...pr.keys()].filter((i) => stockAllowed(i) && pr[i] > 0 && liquid(i, t)).sort((a, b) => pr[b] - pr[a]).slice(0, K);
|
|
751
|
+
order = cand;
|
|
831
752
|
const next = new Set(order);
|
|
832
753
|
for (const [i, h] of [...held]) if (!next.has(i)) closePos(i, h, tIn);
|
|
833
754
|
}
|
|
834
|
-
if (!entries) entries = order.map((i) => [i, 'long']);
|
|
755
|
+
if (!entries) entries = order.map((i) => [i, 'long', null]);
|
|
835
756
|
const newEntries = entries.filter(([i]) => !held.has(i));
|
|
836
757
|
let avgInv = 0;
|
|
837
758
|
if (invVol && newEntries.length && sg) {
|
|
@@ -842,8 +763,10 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
842
763
|
const usedStart = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
843
764
|
let avail = Math.max(0, equity * 0.99 - usedStart);
|
|
844
765
|
for (let idx = 0; idx < newEntries.length; idx++) {
|
|
845
|
-
const [i, side] = newEntries[idx];
|
|
846
|
-
const
|
|
766
|
+
const [i, side, edgeAt] = newEntries[idx];
|
|
767
|
+
const effEdge = edgeAt !== null && edgeAt !== undefined ? Math.abs(edgeAt) : (sg ? Math.abs(pr[i] * sg[i] * Math.sqrt(H)) : 0);
|
|
768
|
+
const conv = autoAdapt && need > 0 && effEdge > 0 ? Math.min(1.20, Math.max(0.85, 0.80 + 0.20 * (effEdge / need))) : 1;
|
|
769
|
+
const baseAlloc = autoAdapt ? Math.min(equity * maxSlotWeight * conv, avail / Math.max(1, newEntries.length - idx)) : Math.min(equity / slots, avail / Math.max(1, newEntries.length - idx));
|
|
847
770
|
let alloc = baseAlloc;
|
|
848
771
|
if (invVol && avgInv > 0 && sg[i] > 0) {
|
|
849
772
|
const w = Math.max(0.4, Math.min(1.8, (1 / sg[i]) / avgInv));
|
package/server/model/vocab.js
CHANGED
package/server/news/index.js
CHANGED