neuroalpha 0.3.15 → 0.3.17
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/README.md +79 -170
- package/config/strategy.json +2 -2
- package/docs//320/221/320/240/320/236/320/232/320/225/320/240/320/253_API.md +4 -3
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +10 -17
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +96 -156
- 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 +29 -45
- package/package.json +1 -1
- package/public/admin.html +9 -8
- package/public/app.js +464 -930
- package/public/index.html +148 -223
- package/public/landing.html +19 -33
- package/public/legal.html +3 -4
- package/public/styles.css +6 -2
- package/server/api/routes.js +10 -3
- package/server/auth/index.js +53 -34
- package/server/brokers/index.js +434 -104
- package/server/data/moex.js +74 -23
- package/server/db.js +3 -2
- package/server/model/dataset.js +10 -2
- package/server/model/forecast.js +56 -19
- package/server/model/live.js +30 -6
- package/server/model/models.js +113 -31
- package/server/model/research.js +100 -26
- package/server/params-meta.js +19 -19
- package/server/strategist/index.js +423 -484
- package/server/strategist/tools.js +486 -303
package/server/data/moex.js
CHANGED
|
@@ -61,6 +61,10 @@ export async function fetchCandles(symbol, interval, from, till, { signal, onBat
|
|
|
61
61
|
return rows;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// Память времени последней проверки правой границы (свежих свечей) по каждому тикеру,
|
|
65
|
+
// чтобы во время серии экспериментов не опрашивать MOEX ISS по всем 100 акциям на каждом запуске
|
|
66
|
+
const lastTailCheckAt = new Map(); // `${symbol}:${interval}` -> timestamp
|
|
67
|
+
|
|
64
68
|
// Догружает недостающие свечи в SQLite и возвращает их из кэша.
|
|
65
69
|
// Метка kv candProbe:symbol:interval — насколько раннюю историю уже спрашивали у биржи:
|
|
66
70
|
// у инструментов, вышедших на биржу позже начала периода, ранней истории нет, и без метки
|
|
@@ -71,24 +75,63 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
71
75
|
const now = Date.now();
|
|
72
76
|
const pKey = `candProbe:${symbol}:${interval}`;
|
|
73
77
|
const probe = kvGet(pKey);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
// Защита от ложных меток candProbe v1 (где из-за старого условия first.ts <= range.min записывалось fromTs без скачивания):
|
|
79
|
+
// валидной считаем только метку v2 (или если range.min реально доходит до fromTs)
|
|
80
|
+
const covered = probe && probe.v === 2 && probe.fromTs <= fromTs + 5 * DAY;
|
|
81
|
+
|
|
82
|
+
if (!range.n) {
|
|
83
|
+
// В базе ещё нет свечей этой бумаги — качаем весь запрошенный диапазон один раз
|
|
84
|
+
await fetchCandles(symbol, interval, tsToDate(fromTs), tillTs < now ? tsToDate(tillTs) : null, {
|
|
85
|
+
signal,
|
|
86
|
+
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
87
|
+
});
|
|
88
|
+
kvSet(pKey, { v: 2, fromTs });
|
|
89
|
+
lastTailCheckAt.set(`${symbol}:${interval}`, now);
|
|
90
|
+
return loadCandles(symbol, interval, fromTs, tillTs);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 1) ЛЕВЫЙ КРАЙ:
|
|
94
|
+
// Если в локальной базе SQLite уже накоплена история (range.n >= 500 свечей), НИКОГДА не лезем на биржу докачивать левый край
|
|
95
|
+
// при смене «Окна обучения» или «Периода исследования» — работаем мгновенно по уже сохранённым в SQLite историческим свечам!
|
|
96
|
+
if (!covered && range.n < 500 && range.min > fromTs + 5 * DAY) {
|
|
97
|
+
const first = (await fetchCandles(symbol, interval, tsToDate(fromTs), null, { signal, pages: 1 }))[0];
|
|
98
|
+
if (!first || first.ts >= range.min - DAY) {
|
|
99
|
+
kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
|
|
100
|
+
} else {
|
|
101
|
+
const leftTill = tsToDate(range.min + DAY);
|
|
102
|
+
await fetchCandles(symbol, interval, tsToDate(fromTs), leftTill, {
|
|
103
|
+
signal,
|
|
104
|
+
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
105
|
+
});
|
|
106
|
+
kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : fromTs) });
|
|
83
107
|
}
|
|
84
|
-
|
|
85
|
-
|
|
108
|
+
} else if (!probe || probe.v !== 2 || probe.fromTs > fromTs) {
|
|
109
|
+
kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, range.min) });
|
|
86
110
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
111
|
+
|
|
112
|
+
// 2) ПРАВЫЙ КРАЙ (свежие свечи текущего дня):
|
|
113
|
+
// Бесплатный контур MOEX ISS отдаёт 10-минутные свечи с задержкой ~20–30 минут.
|
|
114
|
+
// Поэтому если в SQLite уже есть свечи сегодняшней/вчерашней сессии (моложе 45 минут в торговые часы или моложе 16 часов вне сессии),
|
|
115
|
+
// или если мы уже проверяли хвост в последние 30 минут (метка хранится и в памяти, и в probe.tailCheckedAt в SQLite, переживая перезапуск сервера),
|
|
116
|
+
// то вообще не делаем сетевых запросов к Мосбирже!
|
|
117
|
+
const tailKey = `${symbol}:${interval}`;
|
|
118
|
+
const lastCheck = Math.max(lastTailCheckAt.get(tailKey) || 0, probe?.tailCheckedAt || 0);
|
|
119
|
+
const mskDate = new Date(now + MSK);
|
|
120
|
+
const mskDow = mskDate.getUTCDay();
|
|
121
|
+
const mskHour = mskDate.getUTCHours();
|
|
122
|
+
const isTradingHours = mskDow >= 1 && mskDow <= 5 && mskHour >= 9 && mskHour < 24;
|
|
123
|
+
const checkCooldownMs = isTradingHours ? 30 * 60000 : 4 * 3600000;
|
|
124
|
+
const dbIsAlreadyFresh = (now - range.max < 45 * 60000) || (!isTradingHours && now - range.max < 18 * 3600000);
|
|
125
|
+
|
|
126
|
+
if (!dbIsAlreadyFresh && range.max < Math.min(tillTs, now) - barMs(interval) && (now - lastCheck >= checkCooldownMs)) {
|
|
127
|
+
await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now ? tsToDate(tillTs) : null, {
|
|
128
|
+
signal,
|
|
129
|
+
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
130
|
+
});
|
|
131
|
+
lastTailCheckAt.set(tailKey, now);
|
|
132
|
+
kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
|
|
91
133
|
}
|
|
134
|
+
|
|
92
135
|
return loadCandles(symbol, interval, fromTs, tillTs);
|
|
93
136
|
}
|
|
94
137
|
|
|
@@ -147,20 +190,28 @@ async function fetchDividendsSmartlab(symbol, signal) {
|
|
|
147
190
|
return list;
|
|
148
191
|
}
|
|
149
192
|
|
|
150
|
-
// Дивиденды акции с кэшем в kv на
|
|
193
|
+
// Дивиденды акции с кэшем в kv на 7 суток (включая пустой список для бездивидендных бумаг); при ошибке сети — старый кэш
|
|
151
194
|
export async function ensureDividends(symbol, { signal, localOnly = false } = {}) {
|
|
152
195
|
const key = `dividends:${symbol}`;
|
|
153
196
|
const cached = kvGet(key);
|
|
154
197
|
if (localOnly) return cached?.list || [];
|
|
155
|
-
if (cached && Date.now() - cached.at < DAY) return cached.list;
|
|
198
|
+
if (cached && Array.isArray(cached.list) && Date.now() - cached.at < 7 * DAY) return cached.list;
|
|
156
199
|
let list = null;
|
|
157
|
-
|
|
200
|
+
let networkOk = false;
|
|
201
|
+
try {
|
|
202
|
+
list = await fetchDividendsIss(symbol, signal);
|
|
203
|
+
networkOk = true;
|
|
204
|
+
} catch { /* нет подписки/сети */ }
|
|
158
205
|
if (!list?.length) {
|
|
159
|
-
try {
|
|
206
|
+
try {
|
|
207
|
+
const sl = await fetchDividendsSmartlab(symbol, signal);
|
|
208
|
+
if (sl) list = sl;
|
|
209
|
+
networkOk = true;
|
|
210
|
+
} catch { /* пропускаем */ }
|
|
160
211
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
212
|
+
const finalList = Array.isArray(list) ? list : (cached?.list || []);
|
|
213
|
+
if (networkOk || Array.isArray(list)) {
|
|
214
|
+
kvSet(key, { at: Date.now(), list: finalList });
|
|
164
215
|
}
|
|
165
|
-
return
|
|
216
|
+
return finalList;
|
|
166
217
|
}
|
package/server/db.js
CHANGED
|
@@ -196,11 +196,12 @@ for (const [tbl, col, def] of [
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
// Гарантируем наличие локального пользователя по умолчанию (id = 1)
|
|
199
|
+
// Гарантируем наличие локального пользователя по умолчанию (id = 1, режим «Хозяин»)
|
|
200
200
|
if (!db.prepare('SELECT id FROM users WHERE id = 1').get()) {
|
|
201
201
|
db.prepare(`INSERT INTO users (id, email, name, pass_hash, role, plan, plan_until, notify_code, created_at)
|
|
202
|
-
VALUES (1, 'owner@neuroalpha.local', '
|
|
202
|
+
VALUES (1, 'owner@neuroalpha.local', 'Хозяин', '', 'admin', 'owner', 0, 'NA-OWNER1', ?)`).run(Date.now());
|
|
203
203
|
} else {
|
|
204
|
+
db.prepare(`UPDATE users SET plan = 'owner' WHERE id = 1 AND role = 'admin' AND plan IN ('free', 'pro')`).run();
|
|
204
205
|
db.prepare("UPDATE users SET name = 'Пользователь' WHERE id = 1 AND name = 'Владелец платформы'").run();
|
|
205
206
|
}
|
|
206
207
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_api_key ON users(api_key) WHERE api_key IS NOT NULL');
|
package/server/model/dataset.js
CHANGED
|
@@ -203,6 +203,14 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
203
203
|
const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends);
|
|
204
204
|
const hit = datasetCache.get(key);
|
|
205
205
|
if (hit && Date.now() - hit.at < 30 * 60000) return hit.ds;
|
|
206
|
+
// Если в памяти уже есть более широкий датасет (например, при изменении Окна обучения modelTrainDays),
|
|
207
|
+
// который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его мгновенно без пересборки!
|
|
208
|
+
const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
|
|
209
|
+
for (const v of datasetCache.values()) {
|
|
210
|
+
if (v.modeKey === modeKey && Date.now() - v.at < 30 * 60000 && v.fromTs <= fromTs + DAY && Math.abs(v.tillTs - tillTs) < 3600000) {
|
|
211
|
+
return v.ds;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
206
214
|
const vocab = await ensureVocab();
|
|
207
215
|
const symbols = vocab.map((v) => v.symbol);
|
|
208
216
|
const raw = {};
|
|
@@ -356,7 +364,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
356
364
|
}
|
|
357
365
|
}
|
|
358
366
|
const ds = { tokens, symbols: tokens.map((t) => t.symbol), timeline, hourMsk, dowMsk, T, index, mret, mclose: mk.close, mopen: mk.open, macro, barsPerDay: days ? Math.round(T / days) : 54 };
|
|
359
|
-
datasetCache.set(key, { ds, at: Date.now() });
|
|
360
|
-
if (datasetCache.size >
|
|
367
|
+
datasetCache.set(key, { ds, at: Date.now(), modeKey, fromTs, tillTs });
|
|
368
|
+
if (datasetCache.size > 4) datasetCache.delete(datasetCache.keys().next().value);
|
|
361
369
|
return ds;
|
|
362
370
|
}
|
package/server/model/forecast.js
CHANGED
|
@@ -6,6 +6,8 @@ import { buildFold, MODELS, HEADS, KINDS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP
|
|
|
6
6
|
import { loadProfiles } from './profiles.js';
|
|
7
7
|
import { isMarketDown } from './research.js';
|
|
8
8
|
import { newsForSymbols } from '../news/index.js';
|
|
9
|
+
import { kvGet, kvSet } from '../db.js';
|
|
10
|
+
import { hub } from '../hub.js';
|
|
9
11
|
|
|
10
12
|
const trainedModelCache = new Map(); // fitKey -> { dayMsk, trainedAt, fTrain, fitted, snap }
|
|
11
13
|
let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
|
|
@@ -176,22 +178,23 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
|
|
|
176
178
|
}
|
|
177
179
|
}
|
|
178
180
|
|
|
179
|
-
// 2) пересчитываем
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
181
|
+
// 2) пересчитываем все масштабы SCALES в точке u (как в buildInferenceFoldAt)
|
|
182
|
+
SCALES.forEach((sc, si) => {
|
|
183
|
+
const Xp = f.X[0][si], Xf = f.X[1][si];
|
|
184
|
+
const invP = 1 / Math.sqrt(sc.len), invF = invP / 1.5;
|
|
185
|
+
for (let i = 0; i < N; i++) {
|
|
186
|
+
let sp = 0, sf = 0;
|
|
187
|
+
for (let k = 0; k < sc.len; k++) {
|
|
188
|
+
const uk = u - sc.from - k;
|
|
189
|
+
if (uk < 0) break;
|
|
190
|
+
const zk = f.z[uk * N + i];
|
|
191
|
+
sp += zk;
|
|
192
|
+
sf += zk * Math.min(4, ds.tokens[i].valRatio[f.lo + uk]);
|
|
193
|
+
}
|
|
194
|
+
Xp[u * N + i] = sp * invP;
|
|
195
|
+
Xf[u * N + i] = sf * invF;
|
|
191
196
|
}
|
|
192
|
-
|
|
193
|
-
Xf0[u * N + i] = sf * invF;
|
|
194
|
-
}
|
|
197
|
+
});
|
|
195
198
|
|
|
196
199
|
// 3) пересчитываем головы внимания F[hi][u*N + j] в точке u
|
|
197
200
|
const att = fitted.attention;
|
|
@@ -240,7 +243,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
240
243
|
const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
|
|
241
244
|
const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
|
|
242
245
|
const u = idx - f.lo;
|
|
243
|
-
|
|
246
|
+
// Обучаем только выбранную модель (и attention для объяснения соседей и весов голов),
|
|
247
|
+
// не тратя 15 секунд на холостое обучение матрицы leadlag, которая в прогнозе и живой торговле не используется!
|
|
248
|
+
const list = [...new Set(['attention', chosen])];
|
|
244
249
|
const fitted = {};
|
|
245
250
|
for (const n of list) {
|
|
246
251
|
fitted[n] = await MODELS[n].fit(f);
|
|
@@ -283,6 +288,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
283
288
|
const dir = p.modelDirection || 'long';
|
|
284
289
|
const needL = autoAdapt && bear ? threshold * 1.25 : threshold;
|
|
285
290
|
const needS = autoAdapt && dir === 'longshort' ? (bear ? threshold * 0.90 : threshold * 1.25) : threshold;
|
|
291
|
+
const shortableMap = kvGet('tbankShortableMap')?.map || null;
|
|
286
292
|
let sInv = 0, nInv = 0;
|
|
287
293
|
for (let i = 0; i < ds.tokens.length; i++) if (f.sigma[i] > 0 && ds.tokens[i].liq[idx] >= LIQ_MIN) { sInv += 1 / f.sigma[i]; nInv++; }
|
|
288
294
|
const avgInv = nInv ? sInv / nInv : 0;
|
|
@@ -290,6 +296,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
290
296
|
const exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
|
|
291
297
|
const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
|
|
292
298
|
const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
|
|
299
|
+
const rawShortHit = dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -needS;
|
|
300
|
+
const shortBlockedByBroker = Boolean(rawShortHit && shortableMap && shortableMap[tk.symbol] === false);
|
|
293
301
|
const row = {
|
|
294
302
|
i, symbol: tk.symbol, name: tk.name,
|
|
295
303
|
price: round(livePx, 4),
|
|
@@ -300,7 +308,10 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
300
308
|
weight: round(weight, 3),
|
|
301
309
|
signal: tk.liq[idx] >= LIQ_MIN && exp > needL,
|
|
302
310
|
};
|
|
303
|
-
if (dir !== 'long')
|
|
311
|
+
if (dir !== 'long') {
|
|
312
|
+
row.shortSignal = rawShortHit && !shortBlockedByBroker;
|
|
313
|
+
if (shortBlockedByBroker) row.shortBlockedByBroker = true;
|
|
314
|
+
}
|
|
304
315
|
if (explain) {
|
|
305
316
|
const ex = explainAttention(fitted.attention, f, u, i);
|
|
306
317
|
row.influences = ex.neighbors.map((x) => ({ symbol: ds.tokens[x.i].symbol, contribPct: round(x.contrib * f.sigma[i] * Math.sqrt(H) * 100, 3), closeness: round(f.cos[i][x.i], 2) }));
|
|
@@ -308,7 +319,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
308
319
|
}
|
|
309
320
|
return row;
|
|
310
321
|
});
|
|
311
|
-
return { f, u, fitted, chosen, threshold, marketDown: bear, rows };
|
|
322
|
+
return { f, u, fitted, chosen, threshold, needL, needS, marketDown: bear, rows };
|
|
312
323
|
}
|
|
313
324
|
|
|
314
325
|
// Новости — только в прогнозе «сейчас»
|
|
@@ -387,15 +398,36 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
387
398
|
at: now,
|
|
388
399
|
liveTs,
|
|
389
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,
|
|
390
404
|
rows: sortedRows,
|
|
391
405
|
};
|
|
392
406
|
baseState.lastTickResult = result;
|
|
393
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 });
|
|
394
410
|
return withNews(result, p);
|
|
395
411
|
}
|
|
396
412
|
|
|
413
|
+
// 2б) Сразу после перезапуска сервера (пока baseState ещё не прогрет в RAM):
|
|
414
|
+
// если в SQLite есть свежий снимок прогноза с теми же настройками (моложе 4 часов),
|
|
415
|
+
// мгновенно отдаём его пользователю за 0 мс, а пересчёт свежих котировок запускаем параллельно в фоне!
|
|
416
|
+
if (!force && !baseState) {
|
|
417
|
+
const saved = kvGet('lastForecastSnap');
|
|
418
|
+
if (saved?.result && saved.key === key && now - (saved.result.at || 0) < 4 * 3600000) {
|
|
419
|
+
if (!inflight.has(key)) {
|
|
420
|
+
setImmediate(() => {
|
|
421
|
+
forecastNow(p, { force: true }).catch(() => {});
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return withNews(saved.result, p);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
397
428
|
if (!force && inflight.has(key)) return withNews(await inflight.get(key), p);
|
|
398
429
|
const task = (async () => {
|
|
430
|
+
hub.emit({ type: 'trading:pulse', kind: 'train', ts: now, text: 'Обновление котировок и расчёт прогноза…' });
|
|
399
431
|
const trainedToday = !force && isModelTrainedToday(p, now);
|
|
400
432
|
const windowDays = trainedToday ? 7 : Math.ceil(p.modelTrainDays * 1.5 + 25);
|
|
401
433
|
const [ds, liveSnap] = await Promise.all([
|
|
@@ -416,7 +448,10 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
416
448
|
liveTs,
|
|
417
449
|
barTs,
|
|
418
450
|
bar: fmtLiveBarLabel(barTs, liveSnap ? liveTs : null),
|
|
419
|
-
horizon: effHorizon, direction: p.modelDirection, model: r.chosen, thresholdPct: round(r.threshold * 100, 3),
|
|
451
|
+
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),
|
|
454
|
+
marketDown: r.marketDown,
|
|
420
455
|
rows: sortedRows,
|
|
421
456
|
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 },
|
|
422
457
|
heads: HEADS.map((h, i) => ({ label: h.label, coef: round(r.fitted.attention.coef[i], 4) })),
|
|
@@ -437,6 +472,8 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
437
472
|
lastTickResult: result,
|
|
438
473
|
lastTickAt: now,
|
|
439
474
|
};
|
|
475
|
+
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 });
|
|
440
477
|
return result;
|
|
441
478
|
})().finally(() => inflight.delete(key));
|
|
442
479
|
inflight.set(key, task);
|
package/server/model/live.js
CHANGED
|
@@ -328,24 +328,39 @@ class LiveEngine {
|
|
|
328
328
|
saveTrade(s.runId, t);
|
|
329
329
|
hub.emit({ type: 'paper:trade', runId: s.runId, trade: t });
|
|
330
330
|
}
|
|
331
|
-
const
|
|
331
|
+
const rawTopK = Number(s.params.modelTopK ?? 2);
|
|
332
|
+
const K = rawTopK > 0 ? rawTopK : 4;
|
|
332
333
|
const dir = this.#direction(s);
|
|
333
334
|
const slots = dir === 'long' ? K : 2 * K;
|
|
334
335
|
const autoAdapt = s.params.modelAutoAdapt !== false;
|
|
335
|
-
const maxSlotWeight = dir === 'long' ? Math.min(0.
|
|
336
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
|
|
336
337
|
const invVol = s.params.modelAllocMode === 'invvol';
|
|
337
338
|
const effMinEdge = autoAdapt ? 2.5 : s.params.modelMinEdge;
|
|
338
339
|
const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct);
|
|
339
340
|
const buyOrders = s.pending.filter((x) => x.action === 'buy' || x.action === 'short');
|
|
341
|
+
if (autoAdapt && buyOrders.length > 1) {
|
|
342
|
+
buyOrders.sort((a, b) => Math.abs(b.expectedPct || 0) - Math.abs(a.expectedPct || 0));
|
|
343
|
+
}
|
|
340
344
|
const usedStart = [...s.held.values()].reduce((sum, h) => sum + h.alloc, 0);
|
|
341
345
|
let avail = Math.max(0, s.equity * 0.99 - usedStart);
|
|
346
|
+
const activeTotal = Math.max(2, s.held.size + buyOrders.length);
|
|
347
|
+
const dynSlotWeight = autoAdapt
|
|
348
|
+
? Math.min(dir === 'long' ? 0.58 : 0.75, Math.max(maxSlotWeight, (dir === 'long' ? 1.8 : 3.0) / Math.min(slots, activeTotal + 1)))
|
|
349
|
+
: maxSlotWeight;
|
|
350
|
+
let remWeightSum = buyOrders.reduce((sum, o) => sum + Math.max(0.01, Math.abs(o.expectedPct || 0)), 0);
|
|
342
351
|
for (let idx = 0; idx < buyOrders.length; idx++) {
|
|
343
352
|
const o = buyOrders[idx];
|
|
344
353
|
const side = o.action === 'short' ? 'short' : 'long';
|
|
345
354
|
const px = price(o.symbol);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
355
|
+
const wCur = Math.max(0.01, Math.abs(o.expectedPct || 0));
|
|
356
|
+
if (s.held.has(o.symbol) || !(px > 0) || [...s.held.values()].filter((h) => h.side === side).length >= K) {
|
|
357
|
+
remWeightSum = Math.max(0, remWeightSum - wCur);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const conv = autoAdapt && needPct > 0 && o.expectedPct ? Math.min(1.65, Math.max(0.85, 0.80 + 0.30 * (Math.abs(o.expectedPct) / needPct))) : 1;
|
|
361
|
+
const propShare = autoAdapt && remWeightSum > 0 ? avail * (wCur / remWeightSum) : avail / Math.max(1, buyOrders.length - idx);
|
|
362
|
+
remWeightSum = Math.max(0, remWeightSum - wCur);
|
|
363
|
+
const baseAlloc = autoAdapt ? Math.min(s.equity * dynSlotWeight * conv, propShare, avail) : Math.min(s.equity / slots, avail / Math.max(1, buyOrders.length - idx));
|
|
349
364
|
let alloc = baseAlloc;
|
|
350
365
|
if (invVol && o.weight > 0) {
|
|
351
366
|
alloc = Math.min(baseAlloc * o.weight, avail / Math.max(1, buyOrders.length - idx));
|
|
@@ -386,7 +401,9 @@ class LiveEngine {
|
|
|
386
401
|
const r = await predictAt(ds, p, idx);
|
|
387
402
|
const row = new Map(r.rows.map((x) => [x.symbol, x]));
|
|
388
403
|
const dir = this.#direction(s);
|
|
389
|
-
const
|
|
404
|
+
const rawTopK = Number(p.modelTopK ?? 2);
|
|
405
|
+
const autoK = rawTopK === 0 || autoAdapt;
|
|
406
|
+
const K = rawTopK > 0 ? rawTopK : 4;
|
|
390
407
|
const closing = new Set();
|
|
391
408
|
const closePos = (sym, why) => {
|
|
392
409
|
const x = row.get(sym);
|
|
@@ -428,6 +445,13 @@ class LiveEngine {
|
|
|
428
445
|
if (quarantined.has(x.symbol)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
|
|
429
446
|
const b = nb?.get(x.symbol)?.[side === 'buy' ? 'bad' : 'good'];
|
|
430
447
|
if (b) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: `Пропущена: ${side === 'buy' ? 'плохая' : 'хорошая'} новость — ${b.gist}` }); continue; }
|
|
448
|
+
if (autoK && out.length >= 1) {
|
|
449
|
+
const bestE = Math.abs(out[0].expected);
|
|
450
|
+
const curE = Math.abs(x.expected);
|
|
451
|
+
const relMin = out.length === 1 ? 0.62 : 0.78;
|
|
452
|
+
const absMin = out.length === 1 ? r.threshold * 1.15 : r.threshold * 1.45;
|
|
453
|
+
if (!(curE >= bestE * relMin && curE >= absMin)) break;
|
|
454
|
+
}
|
|
431
455
|
out.push(x);
|
|
432
456
|
}
|
|
433
457
|
return out;
|
package/server/model/models.js
CHANGED
|
@@ -131,12 +131,66 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
|
|
|
131
131
|
const diag = Array.from({ length: N }, (_, i) => Math.sqrt(Math.max(C[i][i], 1e-9)));
|
|
132
132
|
for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) C[i][j] /= diag[i] * diag[j];
|
|
133
133
|
const eig = eigenSym(C, { maxSweeps: 30 });
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
const rawEmbDim = Math.round(Number(embedDim ?? 0));
|
|
135
|
+
let reqDim;
|
|
136
|
+
if (rawEmbDim <= 0) {
|
|
137
|
+
// Авто-калибровка размерности эмбеддинга по спектру случайных матриц (закон Марченко–Пастура с поправкой на главные моды):
|
|
138
|
+
// оцениваем дисперсию фонового шума sigmaBulk по средней части спектра (исключая главные рыночные моды сверху
|
|
139
|
+
// и вырожденные парные моды обычка/преф снизу) и отбираем структурные собственные числа выше границы lambdaPlus
|
|
140
|
+
const cutLo = Math.floor(eig.values.length * 0.32);
|
|
141
|
+
const cutHi = Math.max(cutLo + 1, Math.floor(eig.values.length * 0.92));
|
|
142
|
+
const bulkSlice = eig.values.slice(cutLo, cutHi);
|
|
143
|
+
const sigmaBulk = bulkSlice.reduce((a, b) => a + b, 0) / Math.max(1, bulkSlice.length);
|
|
144
|
+
const effSamples = Math.min(u1 - u0, 90 * 93) * 0.90;
|
|
145
|
+
const qRatio = N / Math.max(N + 1, effSamples);
|
|
146
|
+
const lambdaPlus = sigmaBulk * ((1 + Math.sqrt(qRatio)) ** 2);
|
|
147
|
+
let sigCount = 0;
|
|
148
|
+
for (let c = 0; c < eig.values.length; c++) {
|
|
149
|
+
if (eig.values[c] >= lambdaPlus) sigCount++;
|
|
150
|
+
}
|
|
151
|
+
reqDim = Math.max(16, Math.min(36, sigCount));
|
|
152
|
+
} else {
|
|
153
|
+
reqDim = Math.max(2, Math.min(1000, rawEmbDim));
|
|
154
|
+
}
|
|
155
|
+
const baseDim = Math.min(reqDim, N - 1);
|
|
156
|
+
const buildExtendedEmb = (ev) => Array.from({ length: N }, (_, i) => {
|
|
157
|
+
const vec = new Float64Array(reqDim);
|
|
158
|
+
for (let c = 0; c < baseDim; c++) {
|
|
159
|
+
vec[c] = ev.vectors[c][i] * Math.sqrt(Math.max(ev.values[c], 0));
|
|
160
|
+
}
|
|
161
|
+
if (reqDim > baseDim) {
|
|
162
|
+
let idx = baseDim;
|
|
163
|
+
const topM = Math.min(baseDim, 45);
|
|
164
|
+
for (let a = 0; a < topM && idx < reqDim; a++) {
|
|
165
|
+
for (let b = a; b < topM && idx < reqDim; b++) {
|
|
166
|
+
const scale = a === b ? 0.5 : 0.70710678;
|
|
167
|
+
vec[idx++] = vec[a] * vec[b] * scale;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (let a = 0; a < topM && idx < reqDim; a++) {
|
|
171
|
+
for (let b = a + 1; b < topM && idx < reqDim; b++) {
|
|
172
|
+
for (let g = b + 1; g < topM && idx < reqDim; g++) {
|
|
173
|
+
vec[idx++] = vec[a] * vec[b] * vec[g] * 0.5;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return vec;
|
|
179
|
+
});
|
|
180
|
+
const normVec = (e) => {
|
|
181
|
+
let s = 0;
|
|
182
|
+
for (let c = 0; c < e.length; c++) s += e[c] * e[c];
|
|
183
|
+
const n = Math.sqrt(s) || 1;
|
|
184
|
+
const out = new Float64Array(e.length);
|
|
185
|
+
for (let c = 0; c < e.length; c++) out[c] = e[c] / n;
|
|
186
|
+
return out;
|
|
187
|
+
};
|
|
188
|
+
const d = reqDim;
|
|
189
|
+
const emb = buildExtendedEmb(eig);
|
|
190
|
+
const embN = emb.map(normVec);
|
|
137
191
|
const cos = matrix(N, N);
|
|
138
192
|
for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) { let s = 0; for (let c = 0; c < d; c++) s += embN[i][c] * embN[j][c]; cos[i][j] = s; }
|
|
139
|
-
let eigVals = eig.values.slice(0,
|
|
193
|
+
let eigVals = eig.values.slice(0, baseDim);
|
|
140
194
|
|
|
141
195
|
// ---------- смешивание с «текстовой» близостью (устойчивые признаки бизнеса из профилей ИИ) ----------
|
|
142
196
|
// итог: cos = (1−w)·близость по поведению + w·близость по центрированным профилям;
|
|
@@ -164,13 +218,12 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
|
|
|
164
218
|
}
|
|
165
219
|
// эмбеддинги и «головы внимания» дальше используют смешанную матрицу
|
|
166
220
|
const em = eigenSym(cos, { maxSweeps: 30 });
|
|
221
|
+
const extEm = buildExtendedEmb(em);
|
|
167
222
|
for (let i = 0; i < N; i++) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
emb[i] = e;
|
|
171
|
-
embN[i] = e.map((v) => v / n);
|
|
223
|
+
emb[i] = extEm[i];
|
|
224
|
+
embN[i] = normVec(extEm[i]);
|
|
172
225
|
}
|
|
173
|
-
eigVals = em.values.slice(0,
|
|
226
|
+
eigVals = em.values.slice(0, baseDim);
|
|
174
227
|
}
|
|
175
228
|
}
|
|
176
229
|
|
|
@@ -214,7 +267,7 @@ export function buildFold(ds, { trainStart, testStart, testEnd, H, embedDim, tex
|
|
|
214
267
|
if (list.length) macro = { list };
|
|
215
268
|
}
|
|
216
269
|
|
|
217
|
-
return { N, lo, hi, W, trainStart, testStart, testEnd, H, Hfast, Hslow, multiHorizon, beta, sigma, z, has, liq, X, yz, yraw, yok, emb, embN, cos, eigenvalues: eigVals, macro };
|
|
270
|
+
return { N, lo, hi, W, trainStart, testStart, testEnd, H, Hfast, Hslow, multiHorizon, beta, sigma, z, vr, has, liq, X, yz, yraw, yok, emb, embN, cos, eigenvalues: eigVals, macro };
|
|
218
271
|
}
|
|
219
272
|
|
|
220
273
|
// Ликвидная акция: сделки хотя бы в 15 из последних 20 свечей
|
|
@@ -330,31 +383,60 @@ export function attentionMatrices(f) {
|
|
|
330
383
|
export function headFeatures(f, As) {
|
|
331
384
|
const { N, W } = f;
|
|
332
385
|
const F = HEADS.map(() => new Float32Array(W * N));
|
|
386
|
+
// По линейности умножения на матрицу внимания A (постоянную внутри фолда):
|
|
387
|
+
// сначала один раз на свечу u умножаем матрицы A[0] и A[1] на базовые ряды zp = z и zf = z * vr,
|
|
388
|
+
// а затем за O(W * N) берём скользящие суммы по 3 масштабам SCALES (в 3 раза быстрее при 100% совпадении результата)
|
|
389
|
+
const zp = f.z;
|
|
390
|
+
const zf = new Float64Array(W * N);
|
|
391
|
+
if (f.vr) {
|
|
392
|
+
for (let k = 0; k < W * N; k++) zf[k] = zp[k] * f.vr[k];
|
|
393
|
+
}
|
|
394
|
+
const A0 = As[0], A1 = As[1];
|
|
395
|
+
const Ap0 = new Float64Array(W * N), Af0 = new Float64Array(W * N);
|
|
396
|
+
const Ap1 = new Float64Array(W * N), Af1 = new Float64Array(W * N);
|
|
397
|
+
for (let u = 0; u < W; u++) {
|
|
398
|
+
const off = u * N;
|
|
399
|
+
for (let j = 0; j < N; j++) {
|
|
400
|
+
let sp0 = 0, sf0 = 0, sp1 = 0, sf1 = 0;
|
|
401
|
+
const row = j * N;
|
|
402
|
+
for (let i = 0; i < N; i++) {
|
|
403
|
+
const w0 = A0[row + i], w1 = A1[row + i];
|
|
404
|
+
const k = off + i;
|
|
405
|
+
const vp = zp[k], vf = zf[k];
|
|
406
|
+
sp0 += w0 * vp; sf0 += w0 * vf;
|
|
407
|
+
sp1 += w1 * vp; sf1 += w1 * vf;
|
|
408
|
+
}
|
|
409
|
+
const kj = off + j;
|
|
410
|
+
Ap0[kj] = sp0; Af0[kj] = sf0;
|
|
411
|
+
Ap1[kj] = sp1; Af1[kj] = sf1;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
333
414
|
for (let bIdx = 0; bIdx < BETAS.length; bIdx++) {
|
|
334
415
|
const betaVal = BETAS[bIdx].v;
|
|
335
|
-
const
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
416
|
+
const Ap = bIdx === 0 ? Ap0 : Ap1;
|
|
417
|
+
const Af = bIdx === 0 ? Af0 : Af1;
|
|
418
|
+
SCALES.forEach((sc, si) => {
|
|
419
|
+
const hP = HEADS.findIndex((h) => h.beta === betaVal && h.kind === 'price' && h.scale === si);
|
|
420
|
+
const hF = HEADS.findIndex((h) => h.beta === betaVal && h.kind === 'flow' && h.scale === si);
|
|
421
|
+
const Fp = F[hP], Ff = F[hF];
|
|
422
|
+
const invP = 1 / Math.sqrt(sc.len), invF = invP / 1.5;
|
|
423
|
+
const sp = new Float64Array(N), sf = new Float64Array(N);
|
|
424
|
+
for (let u = 0; u < W; u++) {
|
|
425
|
+
const a = u - sc.from, b = u - sc.from - sc.len;
|
|
426
|
+
if (a >= 0) {
|
|
427
|
+
const offA = a * N;
|
|
428
|
+
for (let j = 0; j < N; j++) { sp[j] += Ap[offA + j]; sf[j] += Af[offA + j]; }
|
|
429
|
+
}
|
|
430
|
+
if (b >= 0) {
|
|
431
|
+
const offB = b * N;
|
|
432
|
+
for (let j = 0; j < N; j++) { sp[j] -= Ap[offB + j]; sf[j] -= Af[offB + j]; }
|
|
433
|
+
}
|
|
434
|
+
if (u >= FEATURE_WARMUP) {
|
|
435
|
+
const offU = u * N;
|
|
436
|
+
for (let j = 0; j < N; j++) { Fp[offU + j] = sp[j] * invP; Ff[offU + j] = sf[j] * invF; }
|
|
352
437
|
}
|
|
353
|
-
const kj = off + j;
|
|
354
|
-
F0[kj] = s0; F1[kj] = s1; F2[kj] = s2;
|
|
355
|
-
F3[kj] = s3; F4[kj] = s4; F5[kj] = s5;
|
|
356
438
|
}
|
|
357
|
-
}
|
|
439
|
+
});
|
|
358
440
|
}
|
|
359
441
|
return F;
|
|
360
442
|
}
|