neuroalpha 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +18 -2
- package/server/brokers/index.js +83 -2
- package/server/model/models.js +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -2304,6 +2304,7 @@ function renderBrokers() {
|
|
|
2304
2304
|
<span class="k">Маржинальная торговля:</span><span class="v">${ls ? (ls.marginEnabled ? '<b class="pos">вкл. (шорт доступен)</b>' : '<b>выкл. у брокера (только в рост)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
2305
2305
|
<span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
2306
2306
|
<span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · отступ лимитки <b>${fmtNum(a.limitOffsetPct, 2)}%</b>${a.broker === 'tbank' ? ` · стоп-заявки у брокера: <b>${a.brokerStops ? `<span class="pos">вкл.</span> (${Object.keys(a.brokerStopBySymbol || {}).length} активн.)` : 'выкл.'}</b> · кэш в LQDT: <b>${a.cashSweep ? '<span class="pos">вкл.</span>' : 'выкл.'}</b>` : ''}${a.baseRunLabel ? ` · эксперимент <b>${esc(a.baseRunLabel)}</b>` : ''}</span>
|
|
2307
|
+
${a.executionAudit ? `<span class="k">Реальные издержки (сверка):</span><span class="v" title="Сверка фактических сделок счёта (после отключения автоследования) с ориентиром стресс-теста">комиссия <b>${fmtNum(a.executionAudit.commPct, 3)}%</b>${a.executionAudit.slipAllPct !== null ? ` · проскальзывание к сигналу <b>${a.executionAudit.slipAllPct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipAllPct, 3)}%</b>${a.executionAudit.slipTier1Pct !== null ? ` (1-й эш. ${a.executionAudit.slipTier1Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier1Pct, 3)}%${a.executionAudit.slipTier2Pct !== null ? `, 2–3-й эш. ${a.executionAudit.slipTier2Pct > 0 ? '+' : ''}${fmtNum(a.executionAudit.slipTier2Pct, 3)}%` : ''})` : ''}` : ''} · итого <b class="${a.executionAudit.totalCostPerSidePct <= a.executionAudit.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(a.executionAudit.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности стресс-теста ≤ ${fmtNum(a.executionAudit.breakEvenTargetPct, 3)}%, закрыто ${a.executionAudit.closedCount} поз.${a.executionAudit.profitFactorGross !== null ? `, PF до ком. ${fmtNum(a.executionAudit.profitFactorGross, 2)}` : ''})</span>` : ''}
|
|
2307
2308
|
</div>
|
|
2308
2309
|
${ls?.ok ? `<div class="kpis">
|
|
2309
2310
|
<div class="kpi"><div class="v">${fmtNum(ls.totalRub, 0)} ₽</div><div class="l">всего на счёте (${ls.positions?.length || 0} поз.)</div></div>
|
|
@@ -3227,6 +3228,8 @@ const helpCache = new Map();
|
|
|
3227
3228
|
// ---------- журнал исполнения по счёту ----------
|
|
3228
3229
|
const EVENT_LABELS = {
|
|
3229
3230
|
order_sent: ['заявка отправлена', ''],
|
|
3231
|
+
order_expired: ['заявка снята (>12 мин)', 'muted'],
|
|
3232
|
+
spread_skip: ['пропуск входа (широкий спред)', 'mid'],
|
|
3230
3233
|
order_error: ['ошибка заявки', 'neg'],
|
|
3231
3234
|
protective_stop: ['защитный стоп', 'neg'],
|
|
3232
3235
|
protective_stop_error: ['ошибка защитного стопа', 'neg'],
|
|
@@ -3245,12 +3248,25 @@ async function openBrokerEvents(id) {
|
|
|
3245
3248
|
$('#events-modal').hidden = false;
|
|
3246
3249
|
try {
|
|
3247
3250
|
const { events } = await api(`/api/brokers/${encodeURIComponent(id)}/events?limit=300`);
|
|
3248
|
-
|
|
3251
|
+
const sentCnt = events.filter((e) => e.kind === 'order_sent').length;
|
|
3252
|
+
const expCnt = events.filter((e) => e.kind === 'order_expired').length;
|
|
3253
|
+
const skipCnt = events.filter((e) => e.kind === 'spread_skip').length;
|
|
3254
|
+
const errCnt = events.filter((e) => e.kind === 'order_error').length;
|
|
3255
|
+
const fillPct = sentCnt > 0 ? Math.max(0, Math.round(((sentCnt - expCnt) / sentCnt) * 100)) : null;
|
|
3256
|
+
const ea = acc?.executionAudit;
|
|
3257
|
+
const summaryHtml = (ea || sentCnt > 0)
|
|
3258
|
+
? `<div class="stab-box" style="margin-bottom:10px"><div class="stab-head" style="flex-wrap:wrap;gap:10px">
|
|
3259
|
+
<b>Сверка исполнения со стресс-тестом</b>
|
|
3260
|
+
${ea ? `<span class="small">Сделок: <b>${ea.tradesCount}</b> (закрыто <b>${ea.closedCount}</b> поз.) · Комиссия: <b>${fmtNum(ea.commPct, 3)}%</b> · Отклонение от сигнала: <b>${ea.slipAllPct !== null ? `${ea.slipAllPct > 0 ? '+' : ''}${fmtNum(ea.slipAllPct, 3)}%` : '—'}</b> · Итого издержки: <b class="${ea.totalCostPerSidePct <= ea.breakEvenTargetPct ? 'pos' : 'neg'}">${fmtNum(ea.totalCostPerSidePct, 3)}%/сторона</b> (порог безубыточности ≤ ${fmtNum(ea.breakEvenTargetPct, 3)}%)</span>` : ''}
|
|
3261
|
+
${sentCnt > 0 ? `<span class="small muted">По журналу: отправлено ${sentCnt}, исполнено ~${fillPct}% (снято >12 мин: ${expCnt}, пропущено по спреду: ${skipCnt}, ошибок: ${errCnt})</span>` : ''}
|
|
3262
|
+
</div></div>`
|
|
3263
|
+
: '';
|
|
3264
|
+
$('#events-body').innerHTML = summaryHtml + (events.length
|
|
3249
3265
|
? `<div style="overflow-x:auto"><table><thead><tr><th>Время (МСК)</th><th>Событие</th><th>Подробности</th></tr></thead><tbody>${events.map((ev) => {
|
|
3250
3266
|
const [label, cls] = EVENT_LABELS[ev.kind] || [ev.kind, ''];
|
|
3251
3267
|
return `<tr><td class="small" style="white-space:nowrap">${esc(new Date(ev.ts + MSK).toISOString().slice(0, 19).replace('T', ' '))}</td><td class="small ${cls}">${esc(label)}</td><td class="small">${esc(ev.text)}</td></tr>`;
|
|
3252
3268
|
}).join('')}</tbody></table></div>`
|
|
3253
|
-
: '<p class="muted">Событий пока нет. Здесь появятся отправленные заявки, ошибки брокера, срабатывания стопов и ручные действия.</p>';
|
|
3269
|
+
: '<p class="muted">Событий пока нет. Здесь появятся отправленные заявки, ошибки брокера, срабатывания стопов и ручные действия.</p>');
|
|
3254
3270
|
} catch (e) { $('#events-body').textContent = e.message; }
|
|
3255
3271
|
}
|
|
3256
3272
|
$('#events-close')?.addEventListener('click', () => { $('#events-modal').hidden = true; });
|
package/server/brokers/index.js
CHANGED
|
@@ -279,6 +279,84 @@ export function brokerAccountsSummary(uid = 1) {
|
|
|
279
279
|
}).filter(Boolean);
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
const candlePrevStmt = db.prepare('SELECT ts, open, close FROM candles WHERE symbol = ? AND interval = 10 AND ts <= ? ORDER BY ts DESC LIMIT 3');
|
|
283
|
+
|
|
284
|
+
// Телеметрия качества исполнения и сверка реальных сделок счёта с ориентиром стресс-теста
|
|
285
|
+
function computeExecutionAudit(a) {
|
|
286
|
+
const ops = Array.isArray(a.lastHistory?.operations) ? a.lastHistory.operations : [];
|
|
287
|
+
if (!ops.length) return null;
|
|
288
|
+
const sorted = [...ops].sort((x, y) => x.date - y.date);
|
|
289
|
+
// отсекаем старую историю автоследования (до последнего удержания комиссии автоследования)
|
|
290
|
+
const lastFollowTs = Math.max(0, ...sorted.filter((o) => o.kind === 'fee' && /автослед/i.test(o.title || '')).map((o) => o.date));
|
|
291
|
+
const algoOps = sorted.filter((o) => o.date > lastFollowTs);
|
|
292
|
+
const trades = algoOps.filter((o) => (o.kind === 'buy' || o.kind === 'sell') && o.qty > 0 && o.price > 0 && o.symbol && o.symbol !== 'LQDT');
|
|
293
|
+
if (!trades.length) return null;
|
|
294
|
+
|
|
295
|
+
let turnoverRub = 0;
|
|
296
|
+
let slipW1 = 0, rubW1 = 0, slipW2 = 0, rubW2 = 0;
|
|
297
|
+
for (const t of trades) {
|
|
298
|
+
const rub = t.qty * t.price;
|
|
299
|
+
turnoverRub += rub;
|
|
300
|
+
const rows = candlePrevStmt.all(t.symbol, t.date);
|
|
301
|
+
const prev = rows.find((r) => r.ts + BAR_MS <= t.date);
|
|
302
|
+
if (prev && prev.close > 0) {
|
|
303
|
+
const sign = t.kind === 'buy' ? 1 : -1;
|
|
304
|
+
const slipPct = sign * (t.price / prev.close - 1) * 100;
|
|
305
|
+
if (Math.abs(slipPct) < 5) {
|
|
306
|
+
if (TIER1_LIQUID_SYMBOLS.has(t.symbol)) { slipW1 += slipPct * rub; rubW1 += rub; }
|
|
307
|
+
else { slipW2 += slipPct * rub; rubW2 += rub; }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const feesRub = algoOps.filter((o) => o.kind === 'fee').reduce((s, o) => s + Math.abs(o.paymentRub || 0), 0);
|
|
312
|
+
const commPct = turnoverRub > 0 ? round((feesRub / turnoverRub) * 100, 3) : 0.04;
|
|
313
|
+
const slipAllPct = (rubW1 + rubW2) > 0 ? round((slipW1 + slipW2) / (rubW1 + rubW2), 3) : null;
|
|
314
|
+
const slipTier1Pct = rubW1 > 0 ? round(slipW1 / rubW1, 3) : null;
|
|
315
|
+
const slipTier2Pct = rubW2 > 0 ? round(slipW2 / rubW2, 3) : null;
|
|
316
|
+
const totalCostPerSidePct = slipAllPct !== null ? round(commPct + Math.max(0, slipAllPct), 3) : commPct;
|
|
317
|
+
|
|
318
|
+
// FIFO закрытых позиций (без старого портфеля baselineQty)
|
|
319
|
+
const book = new Map();
|
|
320
|
+
let winRub = 0, lossRub = 0, closedCount = 0;
|
|
321
|
+
for (const t of trades) {
|
|
322
|
+
const q = t.kind === 'buy' ? t.qty : -t.qty;
|
|
323
|
+
const lots = book.get(t.symbol) || [];
|
|
324
|
+
if (!lots.length && t.kind === 'sell' && (a.baselineQty?.[t.symbol] || 0) > 0) continue;
|
|
325
|
+
let rem = q;
|
|
326
|
+
while (rem !== 0 && lots.length && Math.sign(lots[0].qty) !== Math.sign(rem)) {
|
|
327
|
+
const take = Math.min(Math.abs(rem), Math.abs(lots[0].qty));
|
|
328
|
+
const pnl = lots[0].qty > 0 ? take * (t.price - lots[0].price) : take * (lots[0].price - t.price);
|
|
329
|
+
closedCount++;
|
|
330
|
+
if (pnl >= 0) winRub += pnl; else lossRub += -pnl;
|
|
331
|
+
lots[0].qty -= Math.sign(lots[0].qty) * take;
|
|
332
|
+
rem -= Math.sign(rem) * take;
|
|
333
|
+
if (lots[0].qty === 0) lots.shift();
|
|
334
|
+
}
|
|
335
|
+
if (rem !== 0) lots.push({ qty: rem, price: t.price });
|
|
336
|
+
book.set(t.symbol, lots);
|
|
337
|
+
}
|
|
338
|
+
const grossRub = round(winRub - lossRub, 0);
|
|
339
|
+
const netRub = round(grossRub - feesRub, 0);
|
|
340
|
+
const profitFactorGross = lossRub > 0 ? round(winRub / lossRub, 2) : (winRub > 0 ? 3 : null);
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
fromTs: algoOps[0]?.date || null,
|
|
344
|
+
tradesCount: trades.length,
|
|
345
|
+
closedCount,
|
|
346
|
+
turnoverRub: round(turnoverRub, 0),
|
|
347
|
+
commPct,
|
|
348
|
+
slipAllPct,
|
|
349
|
+
slipTier1Pct,
|
|
350
|
+
slipTier2Pct,
|
|
351
|
+
totalCostPerSidePct,
|
|
352
|
+
breakEvenTargetPct: 0.255,
|
|
353
|
+
grossRub,
|
|
354
|
+
feesRub: round(feesRub, 0),
|
|
355
|
+
netRub,
|
|
356
|
+
profitFactorGross,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
282
360
|
function publicAccount(a) {
|
|
283
361
|
let baseRunLabel = null;
|
|
284
362
|
if (a.baseRunId) {
|
|
@@ -297,10 +375,11 @@ function publicAccount(a) {
|
|
|
297
375
|
}),
|
|
298
376
|
};
|
|
299
377
|
}
|
|
378
|
+
const executionAudit = computeExecutionAudit(a);
|
|
300
379
|
// Токен брокера никогда не покидает сервер: в браузер, события WebSocket и ответы ИИ уходит только маска
|
|
301
380
|
const { token: rawToken, ...rest } = a;
|
|
302
381
|
const tok = String(rawToken || '').trim();
|
|
303
|
-
return { ...rest, lastSync, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
|
|
382
|
+
return { ...rest, lastSync, executionAudit, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
|
|
304
383
|
}
|
|
305
384
|
|
|
306
385
|
export function brokersState() {
|
|
@@ -807,6 +886,7 @@ async function syncTbank(acc) {
|
|
|
807
886
|
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 8000,
|
|
808
887
|
}).catch(() => null)
|
|
809
888
|
)));
|
|
889
|
+
logBrokerEvent(acc, 'order_expired', `авто-снятие ${toCancelIds.length} неисполненных/дублирующих заявок (>12 мин в стакане)`);
|
|
810
890
|
rawOrders = keptOrders;
|
|
811
891
|
}
|
|
812
892
|
}
|
|
@@ -1954,6 +2034,7 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1954
2034
|
const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
|
|
1955
2035
|
const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
|
|
1956
2036
|
if (isEntry && spreadPct > maxSpreadPct) {
|
|
2037
|
+
logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
|
|
1957
2038
|
throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
|
|
1958
2039
|
}
|
|
1959
2040
|
if (isEntry && ord.symbol !== 'LQDT') {
|
|
@@ -2038,7 +2119,7 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2038
2119
|
throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
|
|
2039
2120
|
}
|
|
2040
2121
|
const j = await r.json();
|
|
2041
|
-
logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ · ${body.orderId}`);
|
|
2122
|
+
logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ (сигнал ${ord.refPrice || finalLimitPx} ₽) · ${body.orderId}`);
|
|
2042
2123
|
return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder, limitPrice: finalLimitPx };
|
|
2043
2124
|
}
|
|
2044
2125
|
if (acc.broker === 'finam') {
|
package/server/model/models.js
CHANGED
|
@@ -479,12 +479,12 @@ function predictAttention(m, f, u) {
|
|
|
479
479
|
for (let q = 0; q < 3; q++) zz += m.coef[HEADS.length + 3 + mi * 3 + q] * zs[q * f.W + u];
|
|
480
480
|
s += ML[mi].b[i] * zz;
|
|
481
481
|
}
|
|
482
|
-
//
|
|
483
|
-
//
|
|
482
|
+
// Равный ансамбль 50/50 с базовой моделью своей инерции (при наличии весов inertiaCoef):
|
|
483
|
+
// без подгонки весов объединяет переток по карте соседей и собственный импульс/возврат акции
|
|
484
484
|
if (m.inertiaCoef) {
|
|
485
485
|
let sIn = 0;
|
|
486
486
|
for (let q = 0; q < 3; q++) sIn += m.inertiaCoef[q] * f.X[0][q][k];
|
|
487
|
-
s = 0.
|
|
487
|
+
s = 0.50 * s + 0.50 * sIn;
|
|
488
488
|
}
|
|
489
489
|
out[i] = s;
|
|
490
490
|
}
|
|
@@ -504,7 +504,7 @@ export const modelNames = () => Object.keys(MODELS);
|
|
|
504
504
|
export function explainAttention(m, f, u, j, top = 3) {
|
|
505
505
|
const N = f.N;
|
|
506
506
|
const contrib = new Float64Array(N);
|
|
507
|
-
const wAtt = m.inertiaCoef ? 0.
|
|
507
|
+
const wAtt = m.inertiaCoef ? 0.50 : 1, wIn = m.inertiaCoef ? 0.50 : 0;
|
|
508
508
|
HEADS.forEach((h, hi) => {
|
|
509
509
|
const A = m.As[BETAS.findIndex((b) => b.v === h.beta)];
|
|
510
510
|
const X = f.X[h.kind === 'price' ? 0 : 1][h.scale];
|