neuroalpha 0.3.12 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -17
- package/README.md +4 -2
- package/bin/cli.js +1 -1
- package/deploy/install.sh +1 -1
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -1
- package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +1 -1
- package/package.json +2 -2
- package/public/admin.html +43 -20
- package/public/app.js +524 -211
- package/public/index.html +43 -3
- package/public/landing.html +328 -115
- package/public/legal.html +37 -16
- package/public/styles.css +24 -3
- package/server/api/routes.js +61 -29
- package/server/auth/index.js +109 -20
- package/server/billing/yookassa.js +1 -1
- package/server/brokers/index.js +179 -34
- package/server/config.js +1 -1
- package/server/data/moex.js +1 -1
- package/server/db.js +17 -3
- package/server/hub.js +1 -1
- package/server/index.js +4 -4
- package/server/llm/client.js +1 -1
- package/server/llm/providers.js +1 -1
- package/server/model/dataset.js +1 -1
- package/server/model/forecast.js +188 -17
- package/server/model/linalg.js +1 -1
- package/server/model/live.js +6 -19
- package/server/model/metrics.js +1 -1
- package/server/model/models.js +1 -1
- package/server/model/profiles.js +1 -1
- package/server/model/research.js +12 -36
- package/server/model/vocab.js +1 -1
- package/server/news/index.js +1 -1
- package/server/notify/index.js +1 -1
- package/server/params-meta.js +4 -3
- package/server/settings.js +1 -1
- package/server/strategist/analytics.js +2 -2
- package/server/strategist/index.js +2 -2
- package/server/strategist/tools.js +1 -1
- package/server/util.js +1 -1
package/server/model/forecast.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { normalizeParams } from '../settings.js';
|
|
3
3
|
import { round, DAY, fmtBarMsk } from '../util.js';
|
|
4
|
-
import { loadDataset, barsFromHours, BAR_MS
|
|
5
|
-
import { buildFold, MODELS, HEADS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
|
|
4
|
+
import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
|
|
5
|
+
import { buildFold, MODELS, HEADS, 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
8
|
import { newsForSymbols } from '../news/index.js';
|
|
9
9
|
|
|
10
|
-
let
|
|
10
|
+
let baseState = null; // { key, trainedAt, ds, last, r, snap, prevExpMap, prevRankMap, lastTickResult, lastTickAt }
|
|
11
11
|
const inflight = new Map();
|
|
12
12
|
|
|
13
13
|
export const lastClosedIndex = (ds, now = Date.now()) => {
|
|
@@ -16,9 +16,100 @@ export const lastClosedIndex = (ds, now = Date.now()) => {
|
|
|
16
16
|
return last;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
// Быстрое получение живых цен Мосбиржи (TQBR + IMOEX) за 1 лёгкий запрос (~80 мс)
|
|
20
|
+
async function fetchLiveMoexSnapshot() {
|
|
21
|
+
try {
|
|
22
|
+
const [rShares, rIdx] = await Promise.all([
|
|
23
|
+
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) }),
|
|
24
|
+
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),
|
|
25
|
+
]);
|
|
26
|
+
if (!rShares.ok) return null;
|
|
27
|
+
const j = await rShares.json();
|
|
28
|
+
const cols = j.marketdata?.columns || [];
|
|
29
|
+
const iSec = cols.indexOf('SECID');
|
|
30
|
+
const iLast = cols.indexOf('LAST');
|
|
31
|
+
const iMkt = cols.indexOf('MARKETPRICE');
|
|
32
|
+
const iCur = cols.indexOf('LCURRENTPRICE');
|
|
33
|
+
const prices = new Map();
|
|
34
|
+
for (const row of j.marketdata?.data || []) {
|
|
35
|
+
const sym = row[iSec];
|
|
36
|
+
const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
|
|
37
|
+
if (sym && px > 0) prices.set(sym, px);
|
|
38
|
+
}
|
|
39
|
+
let imoexPrice = 0;
|
|
40
|
+
if (rIdx?.ok) {
|
|
41
|
+
const ji = await rIdx.json().catch(() => null);
|
|
42
|
+
const ic = ji?.marketdata?.columns || [];
|
|
43
|
+
const ir = ji?.marketdata?.data?.[0];
|
|
44
|
+
if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
|
|
45
|
+
}
|
|
46
|
+
return { prices, imoexPrice, ts: Date.now() };
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Мгновенное обновление последней точки контекста u в уже обученной модели по живым котировкам (за ~2 мс)
|
|
53
|
+
function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
|
|
54
|
+
if (!liveSnap?.prices?.size) return;
|
|
55
|
+
const idx = f.lo + u;
|
|
56
|
+
if (idx < 1 || idx >= ds.T) return;
|
|
57
|
+
const N = f.N;
|
|
58
|
+
const mPrev = ds.mclose?.[idx - 1] || 0;
|
|
59
|
+
const mLive = liveSnap.imoexPrice > 0 && mPrev > 0 ? Math.log(liveSnap.imoexPrice / mPrev) : ds.mret[idx];
|
|
60
|
+
|
|
61
|
+
// 1) пересчитываем z[u*N + i] по живой цене акции относительно закрытия предыдущей свечи
|
|
62
|
+
for (let i = 0; i < N; i++) {
|
|
63
|
+
const tk = ds.tokens[i];
|
|
64
|
+
const prevPx = tk.close[idx - 1];
|
|
65
|
+
const livePx = liveSnap.prices.get(tk.symbol);
|
|
66
|
+
if (livePx > 0 && prevPx > 0) {
|
|
67
|
+
const ret = Math.log(livePx / prevPx);
|
|
68
|
+
const e = ret - f.beta[i] * mLive;
|
|
69
|
+
f.z[u * N + i] = Math.max(-8, Math.min(8, e / f.sigma[i]));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2) пересчитываем признаки масштаба 0 (последний час) в точке u
|
|
74
|
+
const sc0 = SCALES[0];
|
|
75
|
+
const invP = 1 / Math.sqrt(sc0.len), invF = invP / 1.5;
|
|
76
|
+
const Xp0 = f.X[0][0], Xf0 = f.X[1][0];
|
|
77
|
+
for (let i = 0; i < N; i++) {
|
|
78
|
+
let sp = 0, sf = 0;
|
|
79
|
+
for (let k = 0; k < sc0.len; k++) {
|
|
80
|
+
const uk = u - k;
|
|
81
|
+
if (uk < 0) break;
|
|
82
|
+
const zk = f.z[uk * N + i];
|
|
83
|
+
sp += zk;
|
|
84
|
+
sf += zk * Math.min(4, ds.tokens[i].valRatio[f.lo + uk]);
|
|
85
|
+
}
|
|
86
|
+
Xp0[u * N + i] = sp * invP;
|
|
87
|
+
Xf0[u * N + i] = sf * invF;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 3) пересчитываем головы внимания F[hi][u*N + j] в точке u
|
|
91
|
+
const att = fitted.attention;
|
|
92
|
+
if (att?.As && att?.F) {
|
|
93
|
+
const off = u * N;
|
|
94
|
+
for (let hi = 0; hi < HEADS.length; hi++) {
|
|
95
|
+
const h = HEADS[hi];
|
|
96
|
+
const bIdx = BETAS.findIndex((b) => b.v === h.beta);
|
|
97
|
+
const A = att.As[bIdx];
|
|
98
|
+
const X = f.X[h.kind === 'price' ? 0 : 1][h.scale];
|
|
99
|
+
const Fh = att.F[hi];
|
|
100
|
+
for (let j = 0; j < N; j++) {
|
|
101
|
+
let s = 0;
|
|
102
|
+
const row = j * N;
|
|
103
|
+
for (let i = 0; i < N; i++) s += A[row + i] * X[off + i];
|
|
104
|
+
Fh[off + j] = s;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
19
110
|
// Обучение на окне, заканчивающемся свечой idx, и прогноз по этой свече.
|
|
20
111
|
// Возвращает строки по всем акциям: ожидаемое движение (относительно рынка), сигнал покупки, кто влияет.
|
|
21
|
-
export async function predictAt(ds, p, idx, { explain = true } = {}) {
|
|
112
|
+
export async function predictAt(ds, p, idx, { explain = true, liveSnap = null } = {}) {
|
|
22
113
|
const autoAdapt = p.modelAutoAdapt !== false;
|
|
23
114
|
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
24
115
|
const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
|
|
@@ -33,16 +124,23 @@ export async function predictAt(ds, p, idx, { explain = true } = {}) {
|
|
|
33
124
|
const chosen = p.modelType in MODELS ? p.modelType : 'attention';
|
|
34
125
|
const list = [...new Set(explain ? Object.keys(MODELS) : ['attention', chosen])];
|
|
35
126
|
const fitted = {};
|
|
36
|
-
const preds = {};
|
|
37
127
|
for (const n of list) {
|
|
38
128
|
fitted[n] = await MODELS[n].fit(f);
|
|
129
|
+
}
|
|
130
|
+
if (liveSnap) applyLivePricesToFold(ds, f, u, fitted, liveSnap);
|
|
131
|
+
return buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, autoAdapt, explain, liveSnap = null) {
|
|
135
|
+
const preds = {};
|
|
136
|
+
for (const n of Object.keys(fitted)) {
|
|
39
137
|
preds[n] = MODELS[n].predict(fitted[n], f, u);
|
|
40
138
|
}
|
|
41
139
|
const cost = (p.commissionPct + p.slippagePct) / 100;
|
|
42
140
|
const threshold = effMinEdge * 2 * cost;
|
|
43
141
|
const bear = isMarketDown(ds, idx);
|
|
44
142
|
const dir = p.modelDirection || 'long';
|
|
45
|
-
const needL = autoAdapt && bear
|
|
143
|
+
const needL = autoAdapt && bear ? threshold * 1.25 : threshold;
|
|
46
144
|
const needS = autoAdapt && dir === 'longshort' ? (bear ? threshold * 0.90 : threshold * 1.25) : threshold;
|
|
47
145
|
let sInv = 0, nInv = 0;
|
|
48
146
|
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++; }
|
|
@@ -50,8 +148,10 @@ export async function predictAt(ds, p, idx, { explain = true } = {}) {
|
|
|
50
148
|
const rows = ds.tokens.map((tk, i) => {
|
|
51
149
|
const exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
|
|
52
150
|
const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
|
|
151
|
+
const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
|
|
53
152
|
const row = {
|
|
54
153
|
i, symbol: tk.symbol, name: tk.name,
|
|
154
|
+
price: round(livePx, 4),
|
|
55
155
|
liquid: tk.liq[idx] >= LIQ_MIN,
|
|
56
156
|
expected: exp,
|
|
57
157
|
expectedPct: round(exp * 100, 3),
|
|
@@ -70,9 +170,7 @@ export async function predictAt(ds, p, idx, { explain = true } = {}) {
|
|
|
70
170
|
return { f, u, fitted, chosen, threshold, marketDown: bear, rows };
|
|
71
171
|
}
|
|
72
172
|
|
|
73
|
-
// Новости — только в прогнозе
|
|
74
|
-
// и ИИ, знающий будущее, исказил бы проверку). Строкам с новостями за последние 24 ч добавляем
|
|
75
|
-
// news: { count, tone — средний взвешенный по важности, top — суть самой важной }
|
|
173
|
+
// Новости — только в прогнозе «сейчас»
|
|
76
174
|
function withNews(result, p) {
|
|
77
175
|
const nm = p.newsEnabled ? newsForSymbols(24) : null;
|
|
78
176
|
for (const r of result.rows) {
|
|
@@ -83,31 +181,104 @@ function withNews(result, p) {
|
|
|
83
181
|
return result;
|
|
84
182
|
}
|
|
85
183
|
|
|
86
|
-
//
|
|
184
|
+
// Добавляет к каждой строке дельту ожидания (deltaPct) и смещение в рейтинге (rankDelta) относительно предыдущего замера
|
|
185
|
+
function attachDeltas(rows, stateObj) {
|
|
186
|
+
const prevExp = stateObj.prevExpMap;
|
|
187
|
+
const prevRank = stateObj.prevRankMap;
|
|
188
|
+
const nextExp = new Map();
|
|
189
|
+
const nextRank = new Map();
|
|
190
|
+
let changed = false;
|
|
191
|
+
|
|
192
|
+
rows.forEach((r, idx) => {
|
|
193
|
+
const rank = idx + 1;
|
|
194
|
+
nextExp.set(r.symbol, r.expectedPct);
|
|
195
|
+
nextRank.set(r.symbol, rank);
|
|
196
|
+
if (prevExp && prevExp.has(r.symbol)) {
|
|
197
|
+
const d = round(r.expectedPct - prevExp.get(r.symbol), 3);
|
|
198
|
+
if (Math.abs(d) >= 0.001) changed = true;
|
|
199
|
+
r.deltaPct = d !== 0 ? d : (r.deltaPct || 0);
|
|
200
|
+
r.rankDelta = prevRank?.has(r.symbol) ? (prevRank.get(r.symbol) - rank) : 0;
|
|
201
|
+
} else {
|
|
202
|
+
r.deltaPct = 0;
|
|
203
|
+
r.rankDelta = 0;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (!prevExp || changed) {
|
|
208
|
+
stateObj.prevExpMap = nextExp;
|
|
209
|
+
stateObj.prevRankMap = nextRank;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Прогноз на ближайшие H часов с живым поминутным обновлением котировок (без переобучения тяжёлого окна каждую минуту)
|
|
87
214
|
export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
88
215
|
const p = normalizeParams(input);
|
|
89
216
|
const autoAdapt = p.modelAutoAdapt !== false;
|
|
90
217
|
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
91
218
|
const effMinEdge = autoAdapt ? 2.5 : p.modelMinEdge;
|
|
92
219
|
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]);
|
|
93
|
-
|
|
220
|
+
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
// 1) Если с последнего живого тика прошло меньше 15 секунд и нет force — отдаём мгновенно из памяти
|
|
223
|
+
if (!force && baseState && baseState.key === key && baseState.lastTickResult && now - baseState.lastTickAt < 15000) {
|
|
224
|
+
return withNews(baseState.lastTickResult, p);
|
|
225
|
+
}
|
|
226
|
+
// 2) Если базовая модель обучена менее 10 минут назад и нет force — делаем быстрый живой пересчёт (~80 мс запрос + 2 мс математика)
|
|
227
|
+
if (!force && baseState && baseState.key === key && now - baseState.trainedAt < 10 * 60000) {
|
|
228
|
+
const liveSnap = await fetchLiveMoexSnapshot();
|
|
229
|
+
if (liveSnap) applyLivePricesToFold(baseState.ds, baseState.f, baseState.u, baseState.fitted, liveSnap);
|
|
230
|
+
const H = barsFromHours(effHorizon);
|
|
231
|
+
const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f, baseState.u, baseState.fitted, baseState.chosen, H, effMinEdge, autoAdapt, true, liveSnap);
|
|
232
|
+
const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: baseState.snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
|
|
233
|
+
attachDeltas(sortedRows, baseState);
|
|
234
|
+
const result = {
|
|
235
|
+
...baseState.lastTickResult,
|
|
236
|
+
at: now,
|
|
237
|
+
liveTs: liveSnap?.ts || now,
|
|
238
|
+
rows: sortedRows,
|
|
239
|
+
};
|
|
240
|
+
baseState.lastTickResult = result;
|
|
241
|
+
baseState.lastTickAt = now;
|
|
242
|
+
return withNews(result, p);
|
|
243
|
+
}
|
|
244
|
+
|
|
94
245
|
if (!force && inflight.has(key)) return withNews(await inflight.get(key), p);
|
|
95
246
|
const task = (async () => {
|
|
96
|
-
const
|
|
97
|
-
|
|
247
|
+
const [ds, liveSnap] = await Promise.all([
|
|
248
|
+
loadDataset({ fromTs: now - Math.ceil(p.modelTrainDays * 1.5 + 25) * DAY, tillTs: now, useMacro: p.modelUseMacro, useDividends: p.modelUseDividends, onProgress }),
|
|
249
|
+
fetchLiveMoexSnapshot(),
|
|
250
|
+
]);
|
|
98
251
|
const last = lastClosedIndex(ds, now);
|
|
99
|
-
const r = await predictAt(ds, p, last);
|
|
252
|
+
const r = await predictAt(ds, p, last, { explain: true, liveSnap });
|
|
100
253
|
const snap = mapSnapshot(r.f);
|
|
254
|
+
const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
|
|
255
|
+
const stateObj = baseState && baseState.key === key ? baseState : { prevExpMap: null, prevRankMap: null };
|
|
256
|
+
attachDeltas(sortedRows, stateObj);
|
|
101
257
|
const result = {
|
|
102
258
|
at: now,
|
|
259
|
+
liveTs: liveSnap?.ts || now,
|
|
103
260
|
barTs: ds.timeline[last],
|
|
104
261
|
bar: fmtBarMsk(ds.timeline[last]),
|
|
105
262
|
horizon: effHorizon, direction: p.modelDirection, model: r.chosen, thresholdPct: round(r.threshold * 100, 3), marketDown: r.marketDown,
|
|
106
|
-
rows:
|
|
263
|
+
rows: sortedRows,
|
|
107
264
|
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 },
|
|
108
265
|
heads: HEADS.map((h, i) => ({ label: h.label, coef: round(r.fitted.attention.coef[i], 4) })),
|
|
109
266
|
};
|
|
110
|
-
|
|
267
|
+
baseState = {
|
|
268
|
+
key,
|
|
269
|
+
trainedAt: now,
|
|
270
|
+
ds,
|
|
271
|
+
last,
|
|
272
|
+
f: r.f,
|
|
273
|
+
u: r.u,
|
|
274
|
+
fitted: r.fitted,
|
|
275
|
+
chosen: r.chosen,
|
|
276
|
+
snap,
|
|
277
|
+
prevExpMap: stateObj.prevExpMap,
|
|
278
|
+
prevRankMap: stateObj.prevRankMap,
|
|
279
|
+
lastTickResult: result,
|
|
280
|
+
lastTickAt: now,
|
|
281
|
+
};
|
|
111
282
|
return result;
|
|
112
283
|
})().finally(() => inflight.delete(key));
|
|
113
284
|
inflight.set(key, task);
|
package/server/model/linalg.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
// Небольшая линейная алгебра без зависимостей: матрицы — массивы Float64Array (строки)
|
|
3
3
|
|
|
4
4
|
export const matrix = (n, m) => Array.from({ length: n }, () => new Float64Array(m));
|
package/server/model/live.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { createRun, finishRun, saveTrade, updateRunMetrics, getRun, getTrades, deleteRun, kvGet, kvSet } from '../db.js';
|
|
3
3
|
import { normalizeParams } from '../settings.js';
|
|
4
4
|
import { round, DAY, MSK, fmtBarMsk } from '../util.js';
|
|
@@ -308,7 +308,7 @@ class LiveEngine {
|
|
|
308
308
|
const dir = this.#direction(s);
|
|
309
309
|
const slots = dir === 'long' ? K : 2 * K;
|
|
310
310
|
const autoAdapt = s.params.modelAutoAdapt !== false;
|
|
311
|
-
const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) :
|
|
311
|
+
const maxSlotWeight = dir === 'long' ? Math.min(0.45, Math.max(1 / K, 1.35 / K)) : Math.min(0.35, 1.4 / (2 * K));
|
|
312
312
|
const invVol = s.params.modelAllocMode === 'invvol';
|
|
313
313
|
const effMinEdge = autoAdapt ? 2.5 : s.params.modelMinEdge;
|
|
314
314
|
const needPct = effMinEdge * 2 * (s.params.commissionPct + s.params.slippagePct);
|
|
@@ -373,7 +373,7 @@ class LiveEngine {
|
|
|
373
373
|
closing.add(sym);
|
|
374
374
|
};
|
|
375
375
|
const exitRatio = autoAdapt ? 0.95 : Number(p.modelExitRatio ?? 0.5);
|
|
376
|
-
const mktDown = p.modelMarketFilter === 'ma' &&
|
|
376
|
+
const mktDown = p.modelMarketFilter === 'ma' && r.marketDown;
|
|
377
377
|
const quarantined = new Set();
|
|
378
378
|
if (autoAdapt && s.trades.length >= 3) {
|
|
379
379
|
const stMap = new Map();
|
|
@@ -418,22 +418,9 @@ class LiveEngine {
|
|
|
418
418
|
const open = (x) => !s.held.has(x.symbol) || closing.has(x.symbol);
|
|
419
419
|
const candL = mktDown ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
|
|
420
420
|
const candS = r.rows.filter((x) => x.shortSignal && open(x)).sort((a, b) => a.expected - b.expected);
|
|
421
|
-
if (mktDown
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
nL = K - sideHeld('long').length;
|
|
425
|
-
nS = K - sideHeld('short').length;
|
|
426
|
-
} else {
|
|
427
|
-
const pairs = Math.min(K, sideHeld('long').length + candL.length, countSideShort(sideHeld('short').length, candS.length));
|
|
428
|
-
if (!autoAdapt) {
|
|
429
|
-
for (const side of ['long', 'short']) {
|
|
430
|
-
const extra = sideHeld(side).sort((a, b) => (side === 'long' ? 1 : -1) * ((row.get(a[0])?.expected ?? 0) - (row.get(b[0])?.expected ?? 0)));
|
|
431
|
-
for (const [sym] of extra.slice(0, Math.max(0, extra.length - pairs))) closePos(sym, 'Нет пары с другой стороны');
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
nL = Math.max(0, pairs - sideHeld('long').length);
|
|
435
|
-
nS = Math.max(0, pairs - sideHeld('short').length);
|
|
436
|
-
}
|
|
421
|
+
if (mktDown) step.decisions.push({ action: 'skip', symbol: 'IMOEX', text: 'Новые покупки пропущены: индекс Мосбиржи падает' });
|
|
422
|
+
const nL = K - sideHeld('long').length;
|
|
423
|
+
const nS = K - sideHeld('short').length;
|
|
437
424
|
entries = [...take(candL, 'buy', nL).map((x) => [x, 'buy']), ...take(candS, 'short', nS).map((x) => [x, 'short'])];
|
|
438
425
|
}
|
|
439
426
|
for (const [x, action] of entries) {
|
package/server/model/metrics.js
CHANGED
package/server/model/models.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { eigenSym, ridgeSolve, matrix, spearman, kmeans } from './linalg.js';
|
|
3
3
|
import { BAR_INTERVAL } from './dataset.js';
|
|
4
4
|
|
package/server/model/profiles.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { kvGet, kvSet } from '../db.js';
|
|
3
3
|
import { ensureVocab } from './vocab.js';
|
|
4
4
|
import { chatStream, extractJson, isLlmEnabled } from '../llm/client.js';
|
package/server/model/research.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { normalizeParams } from '../settings.js';
|
|
3
3
|
import { computeReport, downsample, buildPeriods } from './metrics.js';
|
|
4
4
|
import { round } from '../util.js';
|
|
@@ -313,7 +313,7 @@ async function run(runId, p, state) {
|
|
|
313
313
|
const stockAllowed = (i) => !autoAdapt || !(stockCnt[i] >= 3 && stockPnl[i] < -0.012);
|
|
314
314
|
let curExitRatio = autoAdapt ? 0.95 : exitRatio;
|
|
315
315
|
const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
|
|
316
|
-
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));
|
|
317
317
|
|
|
318
318
|
const closePos = (i, h, t, end, why) => {
|
|
319
319
|
const exitPrice = px(i, t);
|
|
@@ -345,7 +345,7 @@ async function run(runId, p, state) {
|
|
|
345
345
|
const pr = preds[name].get(t);
|
|
346
346
|
const sg = sigmaAt.get(t);
|
|
347
347
|
const bear = isMarketDown(ds, t);
|
|
348
|
-
const mktDown = mktFilter &&
|
|
348
|
+
const mktDown = mktFilter && bear;
|
|
349
349
|
let order, entries;
|
|
350
350
|
if (dir !== 'long') {
|
|
351
351
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
@@ -359,26 +359,12 @@ async function run(runId, p, state) {
|
|
|
359
359
|
const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
|
|
360
360
|
if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn, false, 'Прогноз ослаб');
|
|
361
361
|
}
|
|
362
|
-
const needL = autoAdapt &&
|
|
363
|
-
const needS = autoAdapt
|
|
362
|
+
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
363
|
+
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
364
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]);
|
|
365
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]);
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
nL = K - countSide('long');
|
|
369
|
-
nS = K - countSide('short');
|
|
370
|
-
} else {
|
|
371
|
-
// нейтрально: равное число покупок и продаж; при autoAdapt уже открытые прибыльные позиции не закрываются принудительно без сигнала
|
|
372
|
-
const pairs = Math.min(K, countSide('long') + candL.length, countSide('short') + candS.length);
|
|
373
|
-
if (!autoAdapt) {
|
|
374
|
-
for (const side of ['long', 'short']) {
|
|
375
|
-
const extra = [...held].filter(([, h]) => h.side === side).sort((a, b) => (side === 'long' ? 1 : -1) * (edge(a[0]) - edge(b[0])));
|
|
376
|
-
for (const [i, h] of extra.slice(0, Math.max(0, extra.length - pairs))) closePos(i, h, tIn, false, 'Нет пары с другой стороны');
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
nL = Math.max(0, pairs - countSide('long'));
|
|
380
|
-
nS = Math.max(0, pairs - countSide('short'));
|
|
381
|
-
}
|
|
366
|
+
const nL = K - countSide('long');
|
|
367
|
+
const nS = K - countSide('short');
|
|
382
368
|
entries = [
|
|
383
369
|
...candL.slice(0, Math.max(0, nL)).map((i) => [i, 'long', edge(i)]),
|
|
384
370
|
...candS.slice(0, Math.max(0, nS)).map((i) => [i, 'short', edge(i)]),
|
|
@@ -713,7 +699,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
713
699
|
const stockAllowed = (i) => !autoAdapt || !(stockCnt[i] >= 3 && stockPnl[i] < -0.012);
|
|
714
700
|
const curExitRatio = autoAdapt ? 0.95 : exitRatio;
|
|
715
701
|
const stepBars = autoAdapt ? Math.max(2, Math.floor(H / 2)) : H;
|
|
716
|
-
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));
|
|
717
703
|
const closePos = (i, h, t) => {
|
|
718
704
|
const exitPrice = px(i, t);
|
|
719
705
|
const gross = h.side === 'short' ? h.alloc * (1 - exitPrice / h.entryPrice) : h.alloc * (exitPrice / h.entryPrice - 1);
|
|
@@ -735,7 +721,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
735
721
|
}
|
|
736
722
|
const pr = preds.get(t), sg = sigmaAt.get(t);
|
|
737
723
|
const bear = isMarketDown(ds, t);
|
|
738
|
-
const mktDown = mktFilter &&
|
|
724
|
+
const mktDown = mktFilter && bear;
|
|
739
725
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
740
726
|
const need = effMinEdge * 2 * realCost, exitNeed = need * curExitRatio, earlyExitNeed = need * 0.35;
|
|
741
727
|
let order, entries;
|
|
@@ -745,21 +731,11 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
745
731
|
const req = autoAdapt && barsHeld <= 2 * stepBars ? earlyExitNeed : exitNeed;
|
|
746
732
|
if (!(h.side === 'long' ? e > req : e < -req) || !liquid(i, t)) closePos(i, h, tIn);
|
|
747
733
|
}
|
|
748
|
-
const needL = autoAdapt &&
|
|
749
|
-
const needS = autoAdapt
|
|
734
|
+
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
735
|
+
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
750
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]);
|
|
751
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]);
|
|
752
|
-
|
|
753
|
-
if (dir === 'neutral') {
|
|
754
|
-
const pairs = Math.min(K, countSide('long') + candL.length, countSide('short') + candS.length);
|
|
755
|
-
if (!autoAdapt) {
|
|
756
|
-
for (const side of ['long', 'short']) {
|
|
757
|
-
const extra = [...held].filter(([, h]) => h.side === side).sort((a, b) => (side === 'long' ? 1 : -1) * (edge(a[0]) - edge(b[0])));
|
|
758
|
-
for (const [i, h] of extra.slice(0, Math.max(0, extra.length - pairs))) closePos(i, h, tIn);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
nL = Math.max(0, pairs - countSide('long')); nS = Math.max(0, pairs - countSide('short'));
|
|
762
|
-
}
|
|
738
|
+
const nL = K - countSide('long'), nS = K - countSide('short');
|
|
763
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)])];
|
|
764
740
|
} else if (effMinEdge > 0) {
|
|
765
741
|
for (const [i, h] of [...held]) {
|
package/server/model/vocab.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { kvGet, kvSet } from '../db.js';
|
|
3
3
|
|
|
4
4
|
// «Словарь» модели: акции индекса широкого рынка МосБиржи (MOEXBMI, ~100 бумаг)
|
package/server/news/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
import { hub } from '../hub.js';
|
|
4
4
|
import { getSettings } from '../settings.js';
|
package/server/notify/index.js
CHANGED
package/server/params-meta.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
// Описание всех настроек: для формы в интерфейсе, для ИИ-стратега и для проверки значений.
|
|
3
3
|
// hidden — не показывать в интерфейсе; ai:false — ИИ менять запрещено (честность проверки).
|
|
4
4
|
export const PARAMS = [
|
|
5
5
|
{ key: 'modelAutoAdapt', label: 'Самообучение правил торговли (Авто)', hint: 'Полный автомат: стратегия сама учится на мульти-горизонте (1,5–3,5 ч), автоматически управляет порогами входа и выхода под вашу комиссию, масштабирует слоты по силе сигнала и временно ставит на карантин убыточные акции. Ручная настройка горизонта и порогов не требуется.', type: 'checkbox' },
|
|
6
6
|
{ key: 'modelTopK', label: 'Сколько акций держать', hint: 'Сколько акций с лучшим прогнозом можно держать одновременно (при продажах — и столько же акций на продажу).', min: 1, max: 20 },
|
|
7
|
-
{ key: 'modelDirection', label: 'Направление торговли', hint: '«Только в рост (без шорта)» — классическая покупка акций и их продажа при фиксации прибыли (работает на любом счёте и ИИС). «В рост и в падение (с шортом)» — дополнительно зарабатывает на падающих акциях за счёт коротких продаж (требуется включённая маржинальная торговля у брокера; на брокерских счетах без маржиналки шорты отключаются автоматически).
|
|
7
|
+
{ key: 'modelDirection', label: 'Направление торговли', hint: '«Только в рост (без шорта)» — классическая покупка акций и их продажа при фиксации прибыли (работает на любом счёте и ИИС). «В рост и в падение (с шортом)» — дополнительно зарабатывает на падающих акциях за счёт коротких продаж (требуется включённая маржинальная торговля у брокера; на брокерских счетах без маржиналки шорты отключаются автоматически).', type: 'select', options: [['long', 'Только в рост (без шорта)'], ['longshort', 'В рост и в падение (с шортом)']] },
|
|
8
8
|
{ key: 'modelHorizon', label: 'Горизонт прогноза, часов (ручной режим)', hint: 'При включённом Авто-режиме управляется автоматически (мульти-горизонт 1,5–3,5 ч). При выключенном Авто — на сколько часов вперёд предсказывать движение.', min: 0.5, max: 8 },
|
|
9
9
|
{ key: 'modelMinEdge', label: 'Порог выгоды на вход (ручной режим)', hint: 'При включённом Авто-режиме управляется автоматически (2,5× издержек). При выключенном Авто — во сколько раз ожидаемое движение должно превышать стоимость сделки.', min: 0, max: 10 },
|
|
10
10
|
{ key: 'modelExitRatio', label: 'Порог на выход (ручной режим)', hint: 'При включённом Авто-режиме управляется автоматически (двухступенчатый выход 0,35 / 0,95). При выключенном Авто — до какой доли от порога входа держать позицию.', min: 0, max: 1, step: 0.05 },
|
|
@@ -37,7 +37,8 @@ export function validateParam(key, raw) {
|
|
|
37
37
|
if (!m) return { error: `нет такой настройки «${key}»` };
|
|
38
38
|
if (m.type === 'checkbox') return { value: raw === true || raw === 'true' || raw === 1 };
|
|
39
39
|
if (m.type === 'select') {
|
|
40
|
-
const
|
|
40
|
+
const normRaw = key === 'modelDirection' && String(raw) === 'neutral' ? 'longshort' : raw;
|
|
41
|
+
const opt = m.options.find(([v]) => String(v) === String(normRaw));
|
|
41
42
|
return opt ? { value: opt[0] } : { error: `${key}: допустимо ${m.options.map(([v]) => v).join(', ')}` };
|
|
42
43
|
}
|
|
43
44
|
// число; запятая допустима как десятичный разделитель («2,5» = 2.5)
|
package/server/settings.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { strategyDefaults } from './config.js';
|
|
3
3
|
import { userKvGet, userKvSet, addSettingsHistory, getSettingsHistory } from './db.js';
|
|
4
4
|
import { validateParam, normalizeKey, PARAM_MAP } from './params-meta.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { strategyDefaults } from '../config.js';
|
|
3
3
|
import { getRun, getTrades, listRuns } from '../db.js';
|
|
4
4
|
import { round, MSK } from '../util.js';
|
|
@@ -45,7 +45,7 @@ export function runSummary(run, { revealOOS = false } = {}) {
|
|
|
45
45
|
s.горизонт_часов = m.model.horizon;
|
|
46
46
|
s.акций = m.model.tokens;
|
|
47
47
|
const dir = run.params?.modelDirection || 'long';
|
|
48
|
-
if (dir !== 'long') s.направление =
|
|
48
|
+
if (dir !== 'long') s.направление = 'в рост и в падение (с шортом)';
|
|
49
49
|
s.период_настройки = Object.fromEntries(Object.entries(cmp).map(([n, c]) => [LBL[n] || n, seg(c, 'inSample', dir)]));
|
|
50
50
|
s.контрольный_период = revealOOS
|
|
51
51
|
? Object.fromEntries(Object.entries(cmp).map(([n, c]) => [LBL[n] || n, seg(c, 'outOfSample', dir)]))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { PARAMS } from '../params-meta.js';
|
|
3
3
|
import { chatStream, activeModelInfo, isLlmEnabled } from '../llm/client.js';
|
|
4
4
|
import { hub } from '../hub.js';
|
|
@@ -86,7 +86,7 @@ const PARAMS_VISIBLE = PARAMS.filter((p) => !p.hidden && !p.fixed);
|
|
|
86
86
|
доход портфеля из лучших прогнозов с комиссиями и БЕЗ комиссий (разница показывает, сколько съедают издержки), сравнение с рынком.
|
|
87
87
|
Сделки исполняются по цене открытия следующей свечи (честно). Порог выгоды modelMinEdge: покупаем, только если ожидаемое движение больше стоимости сделки × порог.
|
|
88
88
|
У стратегии включено самообучение правил торговли (modelAutoAdapt=true по умолчанию — Полный Автомат): мульти-горизонтное обучение (1.5–3.5 ч), сопровождение на полушаге H/2 (мягкий порог 0.35 на первых двух полушагах и 0.95 по зрелости), взвешивание слота по силе сигнала (Conviction Sizing), адаптация порогов покупок и продаж к среднесрочному тренду индекса Мосбиржи и временный карантин убыточных «акций-убийц» с затуханием. Дополнительно есть настройки: число акций (modelTopK), направление (modelDirection), способ распределения долей (modelAllocMode: equal / invvol), фильтр падающего рынка (modelMarketFilter: off / ma), а при выключенном modelAutoAdapt — ручные горизонт (modelHorizon), порог входа (modelMinEdge) и порог выхода (modelExitRatio).
|
|
89
|
-
Направление modelDirection: long — только
|
|
89
|
+
Направление modelDirection: long — только в рост (без шорта); longshort — в рост и в падение (с шортом, по умолчанию): покупает лидеров и шортит акции, ожидаемо слабее рынка (ожидание ниже −порога), с авто-балансировкой по тренду индекса. За продажу без покупки брокер берёт плату за заём акций modelShortFeePct (% годовых за время удержания). В разборах есть вклад покупок и продаж и разбивка по стороне — смотри, какая сторона зарабатывает.
|
|
90
90
|
Разница в итоге меньше 5 п.п. между двумя исследованиями обычно в пределах случайности — не называй её надёжной без контрольного периода.
|
|
91
91
|
model_forecast — прогноз сейчас, market_neighbors — соседи акции на карте.
|
|
92
92
|
get_news — свежие новости и раскрытия эмитентов; новости существуют только «сейчас» (в проверках на истории их нет — там они не используются).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { PARAMS, PARAM_MAP } from '../params-meta.js';
|
|
3
3
|
import { getSettings, updateSettings, validateChanges } from '../settings.js';
|
|
4
4
|
import { getRun, listRuns, addJournal, listJournal, kvGet, kvSet } from '../db.js';
|
package/server/util.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
export const DAY = 86400000;
|
|
3
3
|
export const MSK = 3 * 3600000;
|
|
4
4
|
export const round = (v, d = 2) => (Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : v);
|