neuroalpha 0.5.1 → 0.5.3
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 +96 -7
- 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.3",
|
|
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,90 @@ 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
|
+
const executionAuditCache = new Map(); // accountId -> { sig, val }
|
|
284
|
+
|
|
285
|
+
// Телеметрия качества исполнения и сверка реальных сделок счёта с ориентиром стресс-теста (с кешированием по срезу истории)
|
|
286
|
+
function computeExecutionAudit(a) {
|
|
287
|
+
const ops = Array.isArray(a.lastHistory?.operations) ? a.lastHistory.operations : [];
|
|
288
|
+
if (!ops.length) return null;
|
|
289
|
+
const sig = `${a.lastHistory?.at || 0}:${ops.length}:${ops[0]?.id || ''}:${ops[ops.length - 1]?.id || ''}`;
|
|
290
|
+
const hit = executionAuditCache.get(a.id);
|
|
291
|
+
if (hit && hit.sig === sig) return hit.val;
|
|
292
|
+
const sorted = [...ops].sort((x, y) => x.date - y.date);
|
|
293
|
+
// отсекаем старую историю автоследования (до последнего удержания комиссии автоследования)
|
|
294
|
+
const lastFollowTs = Math.max(0, ...sorted.filter((o) => o.kind === 'fee' && /автослед/i.test(o.title || '')).map((o) => o.date));
|
|
295
|
+
const algoOps = sorted.filter((o) => o.date > lastFollowTs);
|
|
296
|
+
const trades = algoOps.filter((o) => (o.kind === 'buy' || o.kind === 'sell') && o.qty > 0 && o.price > 0 && o.symbol && o.symbol !== 'LQDT');
|
|
297
|
+
if (!trades.length) return null;
|
|
298
|
+
|
|
299
|
+
let turnoverRub = 0;
|
|
300
|
+
let slipW1 = 0, rubW1 = 0, slipW2 = 0, rubW2 = 0;
|
|
301
|
+
for (const t of trades) {
|
|
302
|
+
const rub = t.qty * t.price;
|
|
303
|
+
turnoverRub += rub;
|
|
304
|
+
const rows = candlePrevStmt.all(t.symbol, t.date);
|
|
305
|
+
const prev = rows.find((r) => r.ts + BAR_MS <= t.date);
|
|
306
|
+
if (prev && prev.close > 0) {
|
|
307
|
+
const sign = t.kind === 'buy' ? 1 : -1;
|
|
308
|
+
const slipPct = sign * (t.price / prev.close - 1) * 100;
|
|
309
|
+
if (Math.abs(slipPct) < 5) {
|
|
310
|
+
if (TIER1_LIQUID_SYMBOLS.has(t.symbol)) { slipW1 += slipPct * rub; rubW1 += rub; }
|
|
311
|
+
else { slipW2 += slipPct * rub; rubW2 += rub; }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const feesRub = algoOps.filter((o) => o.kind === 'fee').reduce((s, o) => s + Math.abs(o.paymentRub || 0), 0);
|
|
316
|
+
const commPct = turnoverRub > 0 ? round((feesRub / turnoverRub) * 100, 3) : 0.04;
|
|
317
|
+
const slipAllPct = (rubW1 + rubW2) > 0 ? round((slipW1 + slipW2) / (rubW1 + rubW2), 3) : null;
|
|
318
|
+
const slipTier1Pct = rubW1 > 0 ? round(slipW1 / rubW1, 3) : null;
|
|
319
|
+
const slipTier2Pct = rubW2 > 0 ? round(slipW2 / rubW2, 3) : null;
|
|
320
|
+
const totalCostPerSidePct = slipAllPct !== null ? round(commPct + Math.max(0, slipAllPct), 3) : commPct;
|
|
321
|
+
|
|
322
|
+
// FIFO закрытых позиций (без старого портфеля baselineQty)
|
|
323
|
+
const book = new Map();
|
|
324
|
+
let winRub = 0, lossRub = 0, closedCount = 0;
|
|
325
|
+
for (const t of trades) {
|
|
326
|
+
const q = t.kind === 'buy' ? t.qty : -t.qty;
|
|
327
|
+
const lots = book.get(t.symbol) || [];
|
|
328
|
+
if (!lots.length && t.kind === 'sell' && (a.baselineQty?.[t.symbol] || 0) > 0) continue;
|
|
329
|
+
let rem = q;
|
|
330
|
+
while (rem !== 0 && lots.length && Math.sign(lots[0].qty) !== Math.sign(rem)) {
|
|
331
|
+
const take = Math.min(Math.abs(rem), Math.abs(lots[0].qty));
|
|
332
|
+
const pnl = lots[0].qty > 0 ? take * (t.price - lots[0].price) : take * (lots[0].price - t.price);
|
|
333
|
+
closedCount++;
|
|
334
|
+
if (pnl >= 0) winRub += pnl; else lossRub += -pnl;
|
|
335
|
+
lots[0].qty -= Math.sign(lots[0].qty) * take;
|
|
336
|
+
rem -= Math.sign(rem) * take;
|
|
337
|
+
if (lots[0].qty === 0) lots.shift();
|
|
338
|
+
}
|
|
339
|
+
if (rem !== 0) lots.push({ qty: rem, price: t.price });
|
|
340
|
+
book.set(t.symbol, lots);
|
|
341
|
+
}
|
|
342
|
+
const grossRub = round(winRub - lossRub, 0);
|
|
343
|
+
const netRub = round(grossRub - feesRub, 0);
|
|
344
|
+
const profitFactorGross = lossRub > 0 ? round(winRub / lossRub, 2) : (winRub > 0 ? 3 : null);
|
|
345
|
+
|
|
346
|
+
const val = {
|
|
347
|
+
fromTs: algoOps[0]?.date || null,
|
|
348
|
+
tradesCount: trades.length,
|
|
349
|
+
closedCount,
|
|
350
|
+
turnoverRub: round(turnoverRub, 0),
|
|
351
|
+
commPct,
|
|
352
|
+
slipAllPct,
|
|
353
|
+
slipTier1Pct,
|
|
354
|
+
slipTier2Pct,
|
|
355
|
+
totalCostPerSidePct,
|
|
356
|
+
breakEvenTargetPct: 0.229,
|
|
357
|
+
grossRub,
|
|
358
|
+
feesRub: round(feesRub, 0),
|
|
359
|
+
netRub,
|
|
360
|
+
profitFactorGross,
|
|
361
|
+
};
|
|
362
|
+
executionAuditCache.set(a.id, { sig, val });
|
|
363
|
+
return val;
|
|
364
|
+
}
|
|
365
|
+
|
|
282
366
|
function publicAccount(a) {
|
|
283
367
|
let baseRunLabel = null;
|
|
284
368
|
if (a.baseRunId) {
|
|
@@ -297,10 +381,11 @@ function publicAccount(a) {
|
|
|
297
381
|
}),
|
|
298
382
|
};
|
|
299
383
|
}
|
|
384
|
+
const executionAudit = computeExecutionAudit(a);
|
|
300
385
|
// Токен брокера никогда не покидает сервер: в браузер, события WebSocket и ответы ИИ уходит только маска
|
|
301
386
|
const { token: rawToken, ...rest } = a;
|
|
302
387
|
const tok = String(rawToken || '').trim();
|
|
303
|
-
return { ...rest, lastSync, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
|
|
388
|
+
return { ...rest, lastSync, executionAudit, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
|
|
304
389
|
}
|
|
305
390
|
|
|
306
391
|
export function brokersState() {
|
|
@@ -787,7 +872,7 @@ async function syncTbank(acc) {
|
|
|
787
872
|
// 2) если лимитная заявка висит неисполненной дольше 12 минут (прошла свеча, цена ушла) — автоматически снимаем её и разблокируем рубли!
|
|
788
873
|
if (acc.mode !== 'readonly' && rawOrders.length > 0) {
|
|
789
874
|
const seenInstDir = new Set();
|
|
790
|
-
const
|
|
875
|
+
const toCancelOrders = [];
|
|
791
876
|
const keptOrders = [];
|
|
792
877
|
const sortedByDate = [...rawOrders].sort((a, b) => Date.parse(b.orderDate || 0) - Date.parse(a.orderDate || 0));
|
|
793
878
|
const nowMs = Date.now();
|
|
@@ -795,18 +880,21 @@ async function syncTbank(acc) {
|
|
|
795
880
|
const k = `${o.instrumentUid || o.figi || o.ticker}:${o.direction}`;
|
|
796
881
|
const ageMs = o.orderDate ? (nowMs - Date.parse(o.orderDate)) : 0;
|
|
797
882
|
if (seenInstDir.has(k) || ageMs > 12 * 60000) {
|
|
798
|
-
if (o.orderId)
|
|
883
|
+
if (o.orderId) toCancelOrders.push({ o, reason: ageMs > 12 * 60000 ? '>12 мин' : 'дубль' });
|
|
799
884
|
} else {
|
|
800
885
|
seenInstDir.add(k);
|
|
801
886
|
keptOrders.push(o);
|
|
802
887
|
}
|
|
803
888
|
}
|
|
804
|
-
if (
|
|
805
|
-
await Promise.all(
|
|
889
|
+
if (toCancelOrders.length > 0) {
|
|
890
|
+
await Promise.all(toCancelOrders.map(({ o }) => (
|
|
806
891
|
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
807
|
-
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 8000,
|
|
892
|
+
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(o.orderId) }), timeoutMs: 8000,
|
|
808
893
|
}).catch(() => null)
|
|
809
894
|
)));
|
|
895
|
+
for (const { o, reason } of toCancelOrders) {
|
|
896
|
+
logBrokerEvent(acc, 'order_expired', `${o.ticker || o.figi || 'заявка'}: снята (${reason}) · ${o.orderId}`);
|
|
897
|
+
}
|
|
810
898
|
rawOrders = keptOrders;
|
|
811
899
|
}
|
|
812
900
|
}
|
|
@@ -1954,6 +2042,7 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
1954
2042
|
const spreadPct = ((bestAsk - bestBid) / midPx) * 100;
|
|
1955
2043
|
const maxSpreadPct = ord.symbol === 'LQDT' ? 0.15 : Math.max(0.28, Math.abs(Number(ord.expectedPct) || 0.6) * 0.45);
|
|
1956
2044
|
if (isEntry && spreadPct > maxSpreadPct) {
|
|
2045
|
+
logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: спред ${round(spreadPct, 2)}% > порога ${round(maxSpreadPct, 2)}% (ожидание ${ord.expectedPct || 0}%)`);
|
|
1957
2046
|
throw Object.assign(new Error(`Широкий спред в стакане ${ord.symbol} (${round(spreadPct, 2)}% > ${round(maxSpreadPct, 2)}%) — вход пропущен, чтобы не потерять на спреде`), { definitive: true });
|
|
1958
2047
|
}
|
|
1959
2048
|
if (isEntry && ord.symbol !== 'LQDT') {
|
|
@@ -2038,7 +2127,7 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2038
2127
|
throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
|
|
2039
2128
|
}
|
|
2040
2129
|
const j = await r.json();
|
|
2041
|
-
logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ · ${body.orderId}`);
|
|
2130
|
+
logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ (сигнал ${ord.refPrice || finalLimitPx} ₽) · ${body.orderId}`);
|
|
2042
2131
|
return { ok: true, orderId: j.orderId || body.orderId, lots: lotsToOrder, limitPrice: finalLimitPx };
|
|
2043
2132
|
}
|
|
2044
2133
|
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];
|