neuroalpha 0.5.27 → 0.5.29
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/package.json +1 -1
- package/public/app.js +260 -151
- package/server/api/routes.js +42 -3
- package/server/brokers/index.js +230 -73
- package/server/model/dataset.js +15 -3
- package/server/model/forecast.js +50 -33
- package/server/notify/index.js +11 -3
package/server/model/dataset.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
import { ensureCandles, ensureDividends, exDate, mskToTs, tsToDate } from '../data/moex.js';
|
|
2
|
+
import { ensureCandles, ensureDividends, exDate, mskToTs, tsToDate, barMs } from '../data/moex.js';
|
|
3
3
|
import { ensureVocab, ensureIndexHistory } from './vocab.js';
|
|
4
4
|
import { kvGet, kvSet } from '../db.js';
|
|
5
5
|
|
|
@@ -221,13 +221,25 @@ export async function loadAlignedOpen(ds, symbol, { fromTs, tillTs, signal } = {
|
|
|
221
221
|
// цены с переносом последнего значения, обороты, доходности, рынок (IMOEX).
|
|
222
222
|
// Цены акций скорректированы на дивиденды (полная доходность; useDividends=false — без этого), макро — только при useMacro.
|
|
223
223
|
export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, useMacro = false, useDividends = true, localOnly = false, onProgress = () => {}, signal }) {
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
const mskDate = new Date(now + MSK);
|
|
226
|
+
const mskDow = mskDate.getUTCDay();
|
|
227
|
+
const mskMins = mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes();
|
|
228
|
+
const isTradingNow = !localOnly && mskDow >= 1 && mskDow <= 5 && mskMins >= 7 * 60 && mskMins <= 23 * 60 + 50 && tillTs >= now - 2 * 60000;
|
|
229
|
+
const bMs = barMs(interval);
|
|
230
|
+
const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - bMs) / bMs) * bMs;
|
|
231
|
+
const isCacheFreshForSlot = (entry) => {
|
|
232
|
+
if (!isTradingNow || now - entry.at < 50000) return true;
|
|
233
|
+
const lastBar = entry.ds?.timeline?.[entry.ds.T - 1] || 0;
|
|
234
|
+
return lastBar >= latestClosedSlotTs;
|
|
235
|
+
};
|
|
224
236
|
const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
|
|
225
237
|
const hit = datasetCache.get(key);
|
|
226
|
-
if (hit &&
|
|
238
|
+
if (hit && now - hit.at < 4 * 60000 && isCacheFreshForSlot(hit)) return hit.ds;
|
|
227
239
|
// Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
|
|
228
240
|
const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
|
|
229
241
|
for (const v of datasetCache.values()) {
|
|
230
|
-
if (v.modeKey === modeKey && (!v.localOnly || localOnly) &&
|
|
242
|
+
if (v.modeKey === modeKey && (!v.localOnly || localOnly) && now - v.at < 4 * 60000 && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000 && isCacheFreshForSlot(v)) {
|
|
231
243
|
return v.ds;
|
|
232
244
|
}
|
|
233
245
|
}
|
package/server/model/forecast.js
CHANGED
|
@@ -163,42 +163,53 @@ export const isModelTrainedToday = (p, now = Date.now()) => {
|
|
|
163
163
|
// в первую очередь берёт секундные цены реального времени из T-Invest API (GetLastPrices, 0 секунд задержки!),
|
|
164
164
|
// а недостающие тикеры и индекс IMOEX дополняет из MOEX ISS
|
|
165
165
|
async function fetchLiveMoexSnapshot() {
|
|
166
|
+
let timer = null;
|
|
166
167
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
return await Promise.race([
|
|
169
|
+
(async () => {
|
|
170
|
+
const [tbPrices, rShares, rIdx] = await Promise.all([
|
|
171
|
+
fetchTbankRealtimePrices().catch(() => null),
|
|
172
|
+
fetch('https://iss.moex.com/iss/engines/stock/markets/shares/boards/TQBR/securities.json?iss.meta=off&iss.only=marketdata&marketdata.columns=SECID,LAST,MARKETPRICE,LCURRENTPRICE,VALTODAY', { signal: AbortSignal.timeout(6000) }).catch(() => null),
|
|
173
|
+
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),
|
|
174
|
+
]);
|
|
175
|
+
const prices = new Map();
|
|
176
|
+
if (rShares?.ok) {
|
|
177
|
+
const j = await rShares.json().catch(() => null);
|
|
178
|
+
const cols = j?.marketdata?.columns || [];
|
|
179
|
+
const iSec = cols.indexOf('SECID');
|
|
180
|
+
const iLast = cols.indexOf('LAST');
|
|
181
|
+
const iMkt = cols.indexOf('MARKETPRICE');
|
|
182
|
+
const iCur = cols.indexOf('LCURRENTPRICE');
|
|
183
|
+
for (const row of j?.marketdata?.data || []) {
|
|
184
|
+
const sym = row[iSec];
|
|
185
|
+
const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
|
|
186
|
+
if (sym && px > 0) prices.set(sym, px);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (tbPrices?.size) {
|
|
190
|
+
for (const [sym, px] of tbPrices) {
|
|
191
|
+
if (px > 0) prices.set(sym, px);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!prices.size) return null;
|
|
195
|
+
let imoexPrice = 0;
|
|
196
|
+
if (rIdx?.ok) {
|
|
197
|
+
const ji = await rIdx.json().catch(() => null);
|
|
198
|
+
const ic = ji?.marketdata?.columns || [];
|
|
199
|
+
const ir = ji?.marketdata?.data?.[0];
|
|
200
|
+
if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
|
|
201
|
+
}
|
|
202
|
+
return { prices, imoexPrice, ts: Date.now(), realtime: Boolean(tbPrices?.size) };
|
|
203
|
+
})(),
|
|
204
|
+
new Promise((resolve) => {
|
|
205
|
+
timer = setTimeout(() => resolve(null), 7000);
|
|
206
|
+
timer.unref?.();
|
|
207
|
+
}),
|
|
171
208
|
]);
|
|
172
|
-
const prices = new Map();
|
|
173
|
-
if (rShares?.ok) {
|
|
174
|
-
const j = await rShares.json().catch(() => null);
|
|
175
|
-
const cols = j?.marketdata?.columns || [];
|
|
176
|
-
const iSec = cols.indexOf('SECID');
|
|
177
|
-
const iLast = cols.indexOf('LAST');
|
|
178
|
-
const iMkt = cols.indexOf('MARKETPRICE');
|
|
179
|
-
const iCur = cols.indexOf('LCURRENTPRICE');
|
|
180
|
-
for (const row of j?.marketdata?.data || []) {
|
|
181
|
-
const sym = row[iSec];
|
|
182
|
-
const px = Number(row[iLast] || row[iMkt] || row[iCur] || 0);
|
|
183
|
-
if (sym && px > 0) prices.set(sym, px);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
if (tbPrices?.size) {
|
|
187
|
-
for (const [sym, px] of tbPrices) {
|
|
188
|
-
if (px > 0) prices.set(sym, px);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
if (!prices.size) return null;
|
|
192
|
-
let imoexPrice = 0;
|
|
193
|
-
if (rIdx?.ok) {
|
|
194
|
-
const ji = await rIdx.json().catch(() => null);
|
|
195
|
-
const ic = ji?.marketdata?.columns || [];
|
|
196
|
-
const ir = ji?.marketdata?.data?.[0];
|
|
197
|
-
if (ir) imoexPrice = Number(ir[ic.indexOf('CURRENTVALUE')] || ir[ic.indexOf('LASTVALUE')] || 0);
|
|
198
|
-
}
|
|
199
|
-
return { prices, imoexPrice, ts: Date.now(), realtime: Boolean(tbPrices?.size) };
|
|
200
209
|
} catch {
|
|
201
210
|
return null;
|
|
211
|
+
} finally {
|
|
212
|
+
if (timer) clearTimeout(timer);
|
|
202
213
|
}
|
|
203
214
|
}
|
|
204
215
|
|
|
@@ -367,10 +378,16 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
367
378
|
const p30m = tk.close[idx30m] || 0;
|
|
368
379
|
const p1d = tk.close[idx1d] || 0;
|
|
369
380
|
const p3d = tk.close[idx3d] || 0;
|
|
381
|
+
let maxPx1d = livePx;
|
|
382
|
+
for (let t = idx1d; t <= idx; t++) {
|
|
383
|
+
const c = tk.close[t];
|
|
384
|
+
if (c > maxPx1d) maxPx1d = c;
|
|
385
|
+
}
|
|
370
386
|
const excess30m = (p30m > 0 && livePx > 0 ? (livePx / p30m - 1) : 0) - m30m;
|
|
371
387
|
const excess1d = (p1d > 0 && livePx > 0 ? (livePx / p1d - 1) : 0) - m1d;
|
|
388
|
+
const peakExcess1d = (p1d > 0 && maxPx1d > 0 ? (maxPx1d / p1d - 1) : 0) - m1d;
|
|
372
389
|
const excess3d = (p3d > 0 && livePx > 0 ? (livePx / p3d - 1) : 0) - m3d;
|
|
373
|
-
const isOverheated = excess1d >= 0.045 || excess3d >= 0.09;
|
|
390
|
+
const isOverheated = excess1d >= 0.045 || peakExcess1d >= 0.045 || excess3d >= 0.09;
|
|
374
391
|
const ex = (explain || isOverheated) ? explainAttention(fitted.attention, f, u, i) : null;
|
|
375
392
|
let selfRaw = ex ? ex.self * f.sigma[i] * Math.sqrt(H) : 0;
|
|
376
393
|
if (isOverheated && selfRaw > 0) {
|
package/server/notify/index.js
CHANGED
|
@@ -192,9 +192,17 @@ export async function notifyUser(userId, text, buttons = null) {
|
|
|
192
192
|
|
|
193
193
|
export async function notifyBrokerBatch(userId, acc, batch) {
|
|
194
194
|
if (!batch?.orders?.length) return;
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
195
|
+
const actionLabel = (a) => (a === 'buy' ? 'Купить' : a === 'cover' ? 'Закрыть шорт (купить)' : a === 'short' ? 'Шорт (продать)' : 'Продать');
|
|
196
|
+
const lines = batch.orders.map((o) => {
|
|
197
|
+
const why = o.reason ? `\n ↳ ${o.reason}` : '';
|
|
198
|
+
return `• ${actionLabel(o.action)} ${o.symbol}: ${o.lots} л. (${o.shares} шт.) по ${o.limitPrice} ₽ ≈ ${round(o.amountRub, 0)} ₽${why}`;
|
|
199
|
+
});
|
|
200
|
+
const isExecuted = batch.status === 'executed' || batch.status === 'partial';
|
|
201
|
+
const header = isExecuted
|
|
202
|
+
? `Выставлены лимитные заявки (свеча ${batch.bar || '—'}):`
|
|
203
|
+
: `Сформирован пакет лимитных заявок (свеча ${batch.bar || '—'}):`;
|
|
204
|
+
const text = `НейроАльфа · Счёт «${acc.name}»\n${header}\n${lines.join('\n')}`;
|
|
205
|
+
const buttons = acc.mode === 'confirm' && !isExecuted ? [
|
|
198
206
|
{ label: `✓ Исполнить (${batch.orders.length})`, data: `exec:${userId}:${acc.id}`, action: 'exec', accountId: acc.id, batchId: batch.id || '' },
|
|
199
207
|
{ label: '✕ Отклонить', data: `rej:${userId}:${acc.id}`, action: 'rej', accountId: acc.id, batchId: batch.id || '' },
|
|
200
208
|
] : null;
|