neuroalpha 0.6.6 → 0.6.8
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 +19 -1
- package/server/brokers/index.js +177 -47
- package/server/model/edge.js +118 -2
- package/server/model/forecast.js +137 -36
- package/server/model/live.js +5 -4
- package/server/model/research.js +10 -10
- package/server/model/simulator.js +7 -5
- package/server/strategist/index.js +20 -2
- package/server/strategist/tools.js +29 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -1099,6 +1099,18 @@ async function loadForecast(force, silent = false) {
|
|
|
1099
1099
|
if (!label) label = '⛔ новости';
|
|
1100
1100
|
}
|
|
1101
1101
|
}
|
|
1102
|
+
if (r.blockedByQuarantine) {
|
|
1103
|
+
reasons.push(r.blockedByQuarantine);
|
|
1104
|
+
if (!label) label = '⛔ карантин';
|
|
1105
|
+
}
|
|
1106
|
+
if (r.blockedByCooldown) {
|
|
1107
|
+
reasons.push(r.blockedByCooldown);
|
|
1108
|
+
if (!label) label = '⛔ пауза';
|
|
1109
|
+
}
|
|
1110
|
+
if (r.blockedByCorridor) {
|
|
1111
|
+
reasons.push(r.blockedByCorridor);
|
|
1112
|
+
if (!label) label = '⛔ коридор';
|
|
1113
|
+
}
|
|
1102
1114
|
if (r.liquid === false) {
|
|
1103
1115
|
reasons.push('Ставки запрещены из-за низкой ликвидности бумаги');
|
|
1104
1116
|
if (!label) label = '⛔ неликвид';
|
|
@@ -3008,6 +3020,7 @@ function renderBrokers() {
|
|
|
3008
3020
|
})() : ''}
|
|
3009
3021
|
<span class="k">Контроль автоследования:</span><span class="v">${ls ? (ls.autoFollowDetected ? '<b class="neg">обнаружено ✕ (торговля заблокирована)</b>' : '<b class="pos">не используется ✓ (счёт допущен)</b>') : '<span class="muted">не проверено</span>'}</span>
|
|
3010
3022
|
<span class="k">Предохранители и настройки:</span><span class="v">${capSpecHtml} · дневной стоп <b>−${fmtNum(a.maxDailyDrawdownPct, 1)}%</b> · плечо <b>${fmtNum(a.maxLeverage || 1, 1)}×</b> · стоп по общей просадке <b>${a.maxDrawdownPct > 0 ? `−${fmtNum(a.maxDrawdownPct, 0)}%` : 'выкл.'}</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>
|
|
3023
|
+
${a.quarantinedStocks?.length ? `<span class="k">Карантин бумаг:</span><span class="v" title="Временное ограничение после убыточных сделок (с затуханием ~15 торговых дней): обычные сигналы пропускаются, допускаются только усиленные сигналы ≥110% от порога">${a.quarantinedStocks.map((q) => `<b>${esc(q.symbol)}</b> <span class="neg">(${fmtNum(q.pnlPct, 2)}%, до ${esc(q.untilText)})</span>`).join(' · ')} <span class="muted small">— пропуск сигналов <110% порога</span></span>` : ''}
|
|
3011
3024
|
${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>` : ''}
|
|
3012
3025
|
</div>
|
|
3013
3026
|
${hasPortfolioSnapshot ? `<div class="kpis">
|
|
@@ -3978,8 +3991,13 @@ const helpCache = new Map();
|
|
|
3978
3991
|
// ---------- журнал исполнения по счёту ----------
|
|
3979
3992
|
const EVENT_LABELS = {
|
|
3980
3993
|
order_sent: ['заявка отправлена', ''],
|
|
3981
|
-
order_expired: ['заявка снята
|
|
3994
|
+
order_expired: ['заявка снята', 'muted'],
|
|
3982
3995
|
spread_skip: ['пропуск входа (широкий спред)', 'mid'],
|
|
3996
|
+
signal_skip: ['сигнал пропущен фильтром', 'mid'],
|
|
3997
|
+
order_skip: ['вход не отправлен (устарел/вне окна)', 'muted'],
|
|
3998
|
+
entry_day_block: ['блок входа до конца дня (коридор)', 'neg'],
|
|
3999
|
+
profit_lock: ['фиксация прибыли (Take-Profit)', 'pos'],
|
|
4000
|
+
news_exit: ['выход по важной новости', 'mid'],
|
|
3983
4001
|
order_error: ['ошибка заявки', 'neg'],
|
|
3984
4002
|
protective_stop: ['защитный стоп', 'neg'],
|
|
3985
4003
|
protective_stop_error: ['ошибка защитного стопа', 'neg'],
|
package/server/brokers/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { getSettings, normalizeParams } from '../settings.js';
|
|
|
11
11
|
import { latestBarTs, registerRealtimeCandleFetcher } from '../data/moex.js';
|
|
12
12
|
import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
|
|
13
13
|
import { forecastNow } from '../model/forecast.js';
|
|
14
|
-
import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
|
|
14
|
+
import { TIER1_LIQUID_SYMBOLS, QUARANTINE_OVERRIDE_RATIO, symbolEdgeMult, isStockQuarantined, computeAccountQuarantine, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
|
|
15
15
|
import { liveDividendGuard } from '../model/dividends.js';
|
|
16
16
|
import { STRATEGY_POLICY, sessionEdgeWarning, evaluateProfitLock, protectiveStopPct, exitHoldRatio, tierSizeMult, MAX_LEVERAGE } from '../model/policy.js';
|
|
17
17
|
import { MODELS } from '../model/models.js';
|
|
@@ -489,10 +489,42 @@ function publicAccount(a) {
|
|
|
489
489
|
};
|
|
490
490
|
}
|
|
491
491
|
const executionAudit = computeExecutionAudit(a);
|
|
492
|
+
const heldAlgoSet = new Set((a.lastSync?.positions || []).filter((p) => p.qty !== 0 && algoQtyFor(a, p.symbol, p.qty) !== 0).map((p) => p.symbol));
|
|
493
|
+
const quarantinedStocks = [...computeAccountQuarantine(a.lastHistory?.operations, heldAlgoSet).values()];
|
|
492
494
|
// Токен брокера никогда не покидает сервер: в браузер, события WebSocket и ответы ИИ уходит только маска
|
|
493
495
|
const { token: rawToken, ...rest } = a;
|
|
494
496
|
const tok = String(rawToken || '').trim();
|
|
495
|
-
return { ...rest, lastSync, executionAudit, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
|
|
497
|
+
return { ...rest, lastSync, executionAudit, quarantinedStocks, hasToken: Boolean(tok), tokenMask: tok ? `${tok.slice(0, 2)}…${tok.slice(-4)}` : '', baseRunLabel };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function getActiveAccountSignalFilters(nowTs = Date.now()) {
|
|
501
|
+
try {
|
|
502
|
+
const list = loadRawAccounts();
|
|
503
|
+
const acc = list.find((a) => a.mode === 'auto' || a.mode === 'confirm') || list[0];
|
|
504
|
+
if (!acc) return null;
|
|
505
|
+
const heldSet = new Set((acc.lastSync?.positions || []).filter((p) => p.qty !== 0 && algoQtyFor(acc, p.symbol, p.qty) !== 0).map((p) => p.symbol));
|
|
506
|
+
const quarantinedMap = computeAccountQuarantine(acc.lastHistory?.operations, heldSet, nowTs);
|
|
507
|
+
const cooldownMap = new Map();
|
|
508
|
+
for (const [sym, ts] of Object.entries(acc.exitCooldownBySymbol || {})) {
|
|
509
|
+
const remMin = Math.ceil((60 * 60000 - (nowTs - ts)) / 60000);
|
|
510
|
+
if (remMin > 0) cooldownMap.set(sym, `Пауза после недавнего выхода (${remMin} мин)`);
|
|
511
|
+
}
|
|
512
|
+
for (const [sym, ts] of Object.entries(acc.entryFailCooldownBySymbol || {})) {
|
|
513
|
+
const remMin = Math.ceil((5 * 60000 - (nowTs - ts)) / 60000);
|
|
514
|
+
if (remMin > 0 && !cooldownMap.has(sym)) {
|
|
515
|
+
const why = acc.entryFailReasonBySymbol?.[sym] || 'отказ шлюза брокера';
|
|
516
|
+
cooldownMap.set(sym, `Пауза ${remMin} мин после отказа (${why})`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const todayMsk = new Date(nowTs + MSK).toISOString().slice(0, 10);
|
|
520
|
+
const dayBlockMap = new Map();
|
|
521
|
+
for (const [sym, b] of Object.entries(acc.entryDayBlockBySymbol || {})) {
|
|
522
|
+
if (b?.day === todayMsk) dayBlockMap.set(sym, b);
|
|
523
|
+
}
|
|
524
|
+
return { accountId: acc.id, accountName: acc.name, quarantinedMap, cooldownMap, dayBlockMap };
|
|
525
|
+
} catch {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
496
528
|
}
|
|
497
529
|
|
|
498
530
|
export function brokersState() {
|
|
@@ -1802,6 +1834,29 @@ const isExitOrder = (o) => o.action === 'sell' || o.action === 'cover';
|
|
|
1802
1834
|
const ACCOUNT_LOCK_TTL_MS = 75000;
|
|
1803
1835
|
const accountBusyLocks = new Map(); // id -> { since, token }
|
|
1804
1836
|
const entryImpulseWatch = new Map(); // `${accId}:${symbol}:${side}` -> { firstTs, lastTs, lastPx, extremePx }
|
|
1837
|
+
const loggedSignalSkips = new Map(); // `${accId}:${barKey}:${symbol}:${side}` -> ts
|
|
1838
|
+
|
|
1839
|
+
function recordSignalSkipOnce(acc, fc, r, side, reason) {
|
|
1840
|
+
if (!acc || !r?.symbol) return;
|
|
1841
|
+
const barKey = String(fc?.barTs || String(fc?.bar || '').slice(0, 16) || Math.floor(Date.now() / (10 * 60000)));
|
|
1842
|
+
const k = `${acc.id}:${barKey}:${r.symbol}:${side}`;
|
|
1843
|
+
if (loggedSignalSkips.has(k)) return;
|
|
1844
|
+
loggedSignalSkips.set(k, Date.now());
|
|
1845
|
+
if (loggedSignalSkips.size > 600) {
|
|
1846
|
+
const cutoff = Date.now() - 6 * 3600000;
|
|
1847
|
+
for (const [ek, ets] of loggedSignalSkips) if (ets < cutoff) loggedSignalSkips.delete(ek);
|
|
1848
|
+
}
|
|
1849
|
+
const thr = side === 'short' ? r.shortThresholdPct : r.buyThresholdPct;
|
|
1850
|
+
const ratioPct = thr > 0 ? Math.round((Math.abs(Number(r.expectedPct) || 0) / thr) * 100) : 0;
|
|
1851
|
+
logBrokerEvent(acc, 'signal_skip', `${side === 'short' ? 'short' : 'buy'} ${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%, порог ${thr}%, ${ratioPct}%): ${reason}`);
|
|
1852
|
+
logTradeDecision(acc, {
|
|
1853
|
+
symbol: r.symbol,
|
|
1854
|
+
action: 'skip',
|
|
1855
|
+
refPrice: r.price,
|
|
1856
|
+
reason,
|
|
1857
|
+
decision: { bar: fc?.bar || '', side, expectedPct: r.expectedPct, thresholdPct: thr, ratioPct },
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1805
1860
|
|
|
1806
1861
|
// Фильтр затухания минутного импульса перед входом (Impulse Exhaustion):
|
|
1807
1862
|
// не даёт продавать в шорт посередине вертикально растущей минутной свечи (или покупать в полёт падающего ножа),
|
|
@@ -1940,7 +1995,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1940
1995
|
hub.emit({ type: 'trading:pulse', kind: 'broker_calc', ts: Date.now(), accountId: acc.id, name: acc.name, text: `Пересчёт заявок по счёту «${acc.name}»…` });
|
|
1941
1996
|
// При плановом шаге снимаем старые неисполненные заявки со стакана, а при быстром доборе (onlyEntries) не трогаем уже стоящие свежие заявки!
|
|
1942
1997
|
if (!onlyEntries && acc.token && acc.mode !== 'readonly' && acc.broker === 'tbank' && acc.lastSync?.activeOrders?.length > 0) {
|
|
1943
|
-
await cancelBrokerOrder(id, 'all').catch(() => {});
|
|
1998
|
+
await cancelBrokerOrder(id, 'all', 'не исполнена к плановому шагу').catch(() => {});
|
|
1944
1999
|
list = loadRawAccounts();
|
|
1945
2000
|
acc = list.find((x) => x.id === id) || acc;
|
|
1946
2001
|
} else if (acc.token && (!acc.lastSync || Date.now() - (acc.lastSync.at || 0) > 45000)) {
|
|
@@ -2022,33 +2077,20 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2022
2077
|
const todayMsk = new Date(nowTs + MSK).toISOString().slice(0, 10);
|
|
2023
2078
|
const entryDayBlockMap = acc.entryDayBlockBySymbol || {};
|
|
2024
2079
|
const isDayBlocked = (sym, side) => entryDayBlockMap[sym]?.day === todayMsk && entryDayBlockMap[sym]?.side === side;
|
|
2025
|
-
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
|
|
2026
|
-
// если
|
|
2027
|
-
//
|
|
2028
|
-
const
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
const
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
const pay = Number(op.paymentRub) || 0;
|
|
2040
|
-
if (op.kind === 'buy') { st.buys++; st.buyRub += Math.abs(pay); st.netRub += pay; }
|
|
2041
|
-
else if (op.kind === 'sell') { st.sells++; st.netRub += pay; }
|
|
2042
|
-
else if (op.kind === 'fee') { st.netRub += pay; }
|
|
2043
|
-
}
|
|
2044
|
-
for (const [sym, st] of Object.entries(statBySym)) {
|
|
2045
|
-
const closedRounds = Math.min(st.buys, st.sells);
|
|
2046
|
-
const netPct = st.buyRub > 10000 ? (st.netRub / st.buyRub) : 0;
|
|
2047
|
-
if (st.buyRub > 10000 && isStockQuarantined(closedRounds, netPct)) {
|
|
2048
|
-
quarantined.add(sym);
|
|
2049
|
-
}
|
|
2050
|
-
}
|
|
2051
|
-
}
|
|
2080
|
+
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с simulator.js / research.js):
|
|
2081
|
+
// если по закрытым FIFO-кругам накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%, с затуханием QUARANTINE_DECAY),
|
|
2082
|
+
// бумага пропускает обычные сигналы, но пропускает усиленные сигналы >= 110% индивидуального порога (QUARANTINE_OVERRIDE_RATIO).
|
|
2083
|
+
const heldNowSet = new Set((acc.lastSync?.positions || []).filter((p) => p.qty !== 0 && algoQtyFor(acc, p.symbol, p.qty) !== 0).map((p) => p.symbol));
|
|
2084
|
+
const quarantinedMap = autoAdapt
|
|
2085
|
+
? computeAccountQuarantine(acc.lastHistory?.operations, heldNowSet, nowTs, Number(params.commissionPct) || 0.04)
|
|
2086
|
+
: new Map();
|
|
2087
|
+
const isQuarantinedFor = (r, side) => {
|
|
2088
|
+
const q = quarantinedMap.get(r.symbol);
|
|
2089
|
+
if (!q) return false;
|
|
2090
|
+
const reqEdge = side === 'short' ? reqShortEdgeOf(r.symbol) : reqBuyEdgeOf(r.symbol);
|
|
2091
|
+
const edgeRatio = reqEdge > 0 ? Math.abs(Number(r.expectedPct) || 0) / reqEdge : 0;
|
|
2092
|
+
return isStockQuarantined(q.cnt, q.pnlPct / 100, edgeRatio);
|
|
2093
|
+
};
|
|
2052
2094
|
const slots = dir === 'long' ? K : 2 * K;
|
|
2053
2095
|
// Фильтр мощности слота по тарифу:
|
|
2054
2096
|
// • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
|
|
@@ -2084,6 +2126,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2084
2126
|
const isRawSignalRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol));
|
|
2085
2127
|
const cooldownSkipped = fc.rows.filter((r) => isRawSignalRow(r) && isExitCooldown(r.symbol));
|
|
2086
2128
|
const entryFailSkipped = fc.rows.filter((r) => isRawSignalRow(r) && !isExitCooldown(r.symbol) && isEntryFailCooldown(r.symbol));
|
|
2129
|
+
const quarantineSkipped = fc.rows.filter((r) => {
|
|
2130
|
+
if (!isRawSignalRow(r) || isInCooldown(r.symbol)) return false;
|
|
2131
|
+
const side = r.expectedPct < 0 ? 'short' : 'buy';
|
|
2132
|
+
return !isDayBlocked(r.symbol, side) && isQuarantinedFor(r, side);
|
|
2133
|
+
});
|
|
2087
2134
|
// Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
|
|
2088
2135
|
const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
|
|
2089
2136
|
const divBlocked = [];
|
|
@@ -2093,10 +2140,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2093
2140
|
|| (isOnImpulseWatch(acc.id, r.symbol, 'buy', nowTs) && r.liquid && r.inIndex !== false && !r.buyBlockedByDip && !r.buyBlockedByOverheat && !r.buyBlockedByDiv && !r.buyBlockedByNews && r.expectedPct >= reqBuyEdgeOf(r.symbol) * IMPULSE_WATCH_HOLD_RATIO);
|
|
2094
2141
|
const canShortRow = (r) => (r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))
|
|
2095
2142
|
|| (isOnImpulseWatch(acc.id, r.symbol, 'short', nowTs) && r.liquid && r.inIndex !== false && r.shortAllowed !== false && !r.shortBlockedBySqueeze && !r.shortBlockedByDiv && !r.shortBlockedByNews && r.expectedPct <= -reqShortEdgeOf(r.symbol) * IMPULSE_WATCH_HOLD_RATIO);
|
|
2096
|
-
const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => canBuyRow(r) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'buy') && !
|
|
2143
|
+
const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => canBuyRow(r) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'buy') && !isQuarantinedFor(r, 'buy') && !nb?.get(r.symbol)?.bad)
|
|
2097
2144
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
2098
2145
|
.sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
2099
|
-
const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => canShortRow(r) && (!autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'short') && !
|
|
2146
|
+
const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => canShortRow(r) && (!autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'short') && !isQuarantinedFor(r, 'short') && !nb?.get(r.symbol)?.good)
|
|
2100
2147
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
2101
2148
|
.sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
2102
2149
|
const nonShortableSkipped = [];
|
|
@@ -2171,18 +2218,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2171
2218
|
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && (r.expectedPct > curExitThr || holdThroughMinorDip));
|
|
2172
2219
|
const dg = divGuardOf(sym, curPx);
|
|
2173
2220
|
const divExit = Boolean(dg?.exitLong);
|
|
2174
|
-
const
|
|
2221
|
+
const badNews = nb?.get(sym)?.bad;
|
|
2222
|
+
const newsExit = Boolean(autoAdapt && badNews && badNews.imp >= 0.80 && badNews.tone <= -0.60 && effEntryTs > 0 && badNews.ts > effEntryTs);
|
|
2223
|
+
const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !newsExit && !nightExit;
|
|
2175
2224
|
if (lots >= 1 && !keep) {
|
|
2176
2225
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
2226
|
+
if (newsExit && !hitHardStopLoss && !profitLockReason && !divExit) {
|
|
2227
|
+
logBrokerEvent(acc, 'news_exit', `${sym}: закрытие покупки по свежей новости (важность ${badNews.imp}, тон ${badNews.tone}) — ${badNews.gist}`);
|
|
2228
|
+
}
|
|
2177
2229
|
const exitReason = hitHardStopLoss
|
|
2178
2230
|
? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
|
|
2179
2231
|
: profitLockReason
|
|
2180
2232
|
? profitLockReason
|
|
2181
2233
|
: divExit
|
|
2182
2234
|
? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
|
|
2183
|
-
:
|
|
2184
|
-
?
|
|
2185
|
-
:
|
|
2235
|
+
: newsExit
|
|
2236
|
+
? `Выход из покупки по важной негативной новости: ${badNews.gist}`
|
|
2237
|
+
: nightExit
|
|
2238
|
+
? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
|
|
2239
|
+
: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
|
|
2186
2240
|
orders.push({
|
|
2187
2241
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
2188
2242
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
@@ -2202,18 +2256,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2202
2256
|
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && (r.expectedPct < -curExitThr || holdThroughMinorSpike));
|
|
2203
2257
|
const dg = divGuardOf(sym, curPx);
|
|
2204
2258
|
const divExit = Boolean(dg?.exitShort);
|
|
2205
|
-
const
|
|
2259
|
+
const goodNews = nb?.get(sym)?.good;
|
|
2260
|
+
const newsExit = Boolean(autoAdapt && goodNews && goodNews.imp >= 0.80 && goodNews.tone >= 0.60 && effEntryTs > 0 && goodNews.ts > effEntryTs);
|
|
2261
|
+
const keep = keepByForecast && !profitLockReason && !hitHardStopLoss && !divExit && !newsExit && !nightExit;
|
|
2206
2262
|
if (lots >= 1 && !keep) {
|
|
2207
2263
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|
|
2264
|
+
if (newsExit && !hitHardStopLoss && !profitLockReason && !divExit) {
|
|
2265
|
+
logBrokerEvent(acc, 'news_exit', `${sym}: закрытие шорта по свежей новости (важность ${goodNews.imp}, тон +${goodNews.tone}) — ${goodNews.gist}`);
|
|
2266
|
+
}
|
|
2208
2267
|
const exitReason = hitHardStopLoss
|
|
2209
2268
|
? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
|
|
2210
2269
|
: profitLockReason
|
|
2211
2270
|
? profitLockReason
|
|
2212
2271
|
: divExit
|
|
2213
2272
|
? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
|
|
2214
|
-
:
|
|
2215
|
-
?
|
|
2216
|
-
:
|
|
2273
|
+
: newsExit
|
|
2274
|
+
? `Закрытие шорта по важной позитивной новости: ${goodNews.gist}`
|
|
2275
|
+
: nightExit
|
|
2276
|
+
? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
|
|
2277
|
+
: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
|
|
2217
2278
|
orders.push({
|
|
2218
2279
|
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
2219
2280
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
@@ -2545,7 +2606,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2545
2606
|
: '';
|
|
2546
2607
|
const dayBlocked = fc.rows.filter((r) => (r.signal && isDayBlocked(r.symbol, 'buy')) || (r.shortSignal && isDayBlocked(r.symbol, 'short')));
|
|
2547
2608
|
const dayBlockNote = dayBlocked.length ? ` Сигнал ${dayBlocked.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} пропущен до конца дня: брокер отклонил вход из-за ценового коридора — бумага у планки Мосбиржи.` : '';
|
|
2548
|
-
const
|
|
2609
|
+
const qNote = quarantineSkipped.length
|
|
2610
|
+
? ` Сигнал ${quarantineSkipped.map((r) => { const q = quarantinedMap.get(r.symbol); const thr = r.expectedPct < 0 ? reqShortEdgeOf(r.symbol) : reqBuyEdgeOf(r.symbol); const pct = thr > 0 ? Math.round((Math.abs(r.expectedPct) / thr) * 100) : 0; return `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%, ${pct}% порога, карантин ${q?.pnlPct ?? ''}% до ${q?.untilText || '—'})`; }).join(', ')} пропущен: по бумаге в карантине принимаются только усиленные сигналы ≥${Math.round(QUARANTINE_OVERRIDE_RATIO * 100)}% порога.`
|
|
2611
|
+
: '';
|
|
2612
|
+
const cdNote = dayBlockNote + qNote + (cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '');
|
|
2549
2613
|
const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2550
2614
|
const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2551
2615
|
const thrRangeStr = dir !== 'long'
|
|
@@ -2556,6 +2620,22 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2556
2620
|
: `На свече ${fc.bar} ни одна доступная бумага не превысила индивидуальный порог выгоды (${thrRangeStr})${noShortNote}.`;
|
|
2557
2621
|
}
|
|
2558
2622
|
}
|
|
2623
|
+
if (entryTimeOk) {
|
|
2624
|
+
for (const r of quarantineSkipped) {
|
|
2625
|
+
const q = quarantinedMap.get(r.symbol);
|
|
2626
|
+
recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', q?.reasonText || 'карантин счёта после убыточных сделок');
|
|
2627
|
+
}
|
|
2628
|
+
for (const r of cooldownSkipped) {
|
|
2629
|
+
recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', 'пауза после недавнего выхода из позиции');
|
|
2630
|
+
}
|
|
2631
|
+
for (const r of entryFailSkipped) {
|
|
2632
|
+
recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', `пауза 5 мин после ошибки приёма у брокера (${entryFailReasonMap[r.symbol] || 'отказ шлюза'})`);
|
|
2633
|
+
}
|
|
2634
|
+
for (const r of fc.rows) {
|
|
2635
|
+
if (r.signal && isDayBlocked(r.symbol, 'buy')) recordSignalSkipOnce(acc, fc, r, 'buy', 'вход заблокирован до конца дня (граница ценового коридора 30099)');
|
|
2636
|
+
if (r.shortSignal && isDayBlocked(r.symbol, 'short')) recordSignalSkipOnce(acc, fc, r, 'short', 'вход заблокирован до конца дня (граница ценового коридора 30099)');
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2559
2639
|
if (divBlocked.length) {
|
|
2560
2640
|
diagNote = `${diagNote ? `${diagNote} ` : ''}Перед дивидендной отсечкой пропущены сигналы: ${[...new Set(divBlocked)].join(', ')}.`;
|
|
2561
2641
|
}
|
|
@@ -2634,7 +2714,8 @@ function formatTbankOrderError(status, rawText = '', ord = {}) {
|
|
|
2634
2714
|
return 'По данной бумаге брокер временно запретил короткие продажи (шорт)';
|
|
2635
2715
|
}
|
|
2636
2716
|
if (code.includes('30099') || /outside the limits/i.test(code)) {
|
|
2637
|
-
|
|
2717
|
+
const corr = (ord.limitDown > 0 || ord.limitUp > 0) ? `, заявка ${ord.limitPrice || ''} ₽ при коридоре ${ord.limitDown || '—'}..${ord.limitUp || '—'} ₽` : '';
|
|
2718
|
+
return `Цена бумаги вышла за границы дневного ценового коридора у брокера (код 30099${corr}: Мосбиржа расширила планку, шлюз брокера ещё обновляет лимиты)`;
|
|
2638
2719
|
}
|
|
2639
2720
|
return `T-Invest PostOrder HTTP ${status}${msg ? `: ${msg}` : ''}${desc ? ` (код ${desc})` : ''}`;
|
|
2640
2721
|
}
|
|
@@ -2652,6 +2733,7 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2652
2733
|
// • если спред в стакане в момент входа разъехался шире половины ожидаемого хода сигнала (или >0,35%) — отклоняем вход в тонкий стакан!
|
|
2653
2734
|
let finalLimitPx = Number(ord.limitPrice) || 0;
|
|
2654
2735
|
let lotsToOrder = Number(ord.lots) || 0;
|
|
2736
|
+
let limitUp = 0, limitDown = 0;
|
|
2655
2737
|
const sp = kvGet('tbankShortableMap')?.specMap?.[ord.symbol] || { minStep: 0.01, decimals: 2 };
|
|
2656
2738
|
const rBook = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.MarketDataService/GetOrderBook`, {
|
|
2657
2739
|
method: 'POST', headers, body: JSON.stringify({ instrumentId, depth: 10 }), timeoutMs: 3500,
|
|
@@ -2660,6 +2742,16 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2660
2742
|
const jBook = await rBook.json().catch(() => null);
|
|
2661
2743
|
const bestBid = qVal(jBook?.bids?.[0]?.price);
|
|
2662
2744
|
const bestAsk = qVal(jBook?.asks?.[0]?.price);
|
|
2745
|
+
limitUp = qVal(jBook?.limitUp);
|
|
2746
|
+
limitDown = qVal(jBook?.limitDown);
|
|
2747
|
+
if (isEntry && ord.symbol !== 'LQDT') {
|
|
2748
|
+
if (!isBuy && limitUp > 0 && bestBid >= limitUp) {
|
|
2749
|
+
throw Object.assign(new Error(`Цена бумаги ${ord.symbol} (${bestBid} ₽) у верхней границы дневного ценового коридора у брокера (limitUp ${limitUp} ₽, код 30099)`), { definitive: true });
|
|
2750
|
+
}
|
|
2751
|
+
if (isBuy && limitDown > 0 && bestAsk > 0 && bestAsk <= limitDown) {
|
|
2752
|
+
throw Object.assign(new Error(`Цена бумаги ${ord.symbol} (${bestAsk} ₽) у нижней границы дневного ценового коридора у брокера (limitDown ${limitDown} ₽, код 30099)`), { definitive: true });
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2663
2755
|
if (isEntry && ord.symbol !== 'LQDT' && (!(bestBid > 0) || !(bestAsk > 0))) {
|
|
2664
2756
|
logBrokerEvent(acc, 'spread_skip', `${ord.symbol}: пустая сторона стакана (bid ${bestBid || 0} / ask ${bestAsk || 0}, планка или аукцион)`);
|
|
2665
2757
|
throw Object.assign(new Error(`В стакане ${ord.symbol} отсутствует ${!(bestAsk > 0) ? 'предложение (верхняя планка / аукцион)' : 'спрос (нижняя планка / аукцион)'} — вход пропущен`), { definitive: true });
|
|
@@ -2723,6 +2815,22 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2723
2815
|
}
|
|
2724
2816
|
}
|
|
2725
2817
|
}
|
|
2818
|
+
// Защита от выхода лимитной цены за границы дневного коридора Т-Банка (limitDown..limitUp из GetOrderBook):
|
|
2819
|
+
// если сама рыночная цена внутри коридора, а за границу вылез только защитный запас limitOffsetPct — прижимаем цену к границе коридора;
|
|
2820
|
+
// если же при входе сама цена уже за коридором против позиции — отклоняем с кодом 30099 до вызова PostOrder.
|
|
2821
|
+
const step = sp.minStep || 0.01;
|
|
2822
|
+
if (limitUp > 0 && finalLimitPx > limitUp) {
|
|
2823
|
+
if (isEntry && !isBuy) {
|
|
2824
|
+
throw Object.assign(new Error(`Цена заявки ${ord.symbol} (${finalLimitPx} ₽) выше верхней границы ценового коридора у брокера (limitUp ${limitUp} ₽, код 30099)`), { definitive: true });
|
|
2825
|
+
}
|
|
2826
|
+
if (!(limitDown > limitUp)) finalLimitPx = roundToStep(limitUp, step, sp.decimals);
|
|
2827
|
+
}
|
|
2828
|
+
if (limitDown > 0 && finalLimitPx < limitDown) {
|
|
2829
|
+
if (isEntry && isBuy) {
|
|
2830
|
+
throw Object.assign(new Error(`Цена заявки ${ord.symbol} (${finalLimitPx} ₽) ниже нижней границы ценового коридора у брокера (limitDown ${limitDown} ₽, код 30099)`), { definitive: true });
|
|
2831
|
+
}
|
|
2832
|
+
if (!(limitUp > 0 && limitDown > limitUp)) finalLimitPx = roundToStep(limitDown, step, sp.decimals);
|
|
2833
|
+
}
|
|
2726
2834
|
}
|
|
2727
2835
|
const units = Math.trunc(finalLimitPx);
|
|
2728
2836
|
const nano = Math.round((finalLimitPx - units) * 1e9);
|
|
@@ -2798,7 +2906,7 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2798
2906
|
if (!r.ok) {
|
|
2799
2907
|
const errText = await r.text().catch(() => '');
|
|
2800
2908
|
// 5xx — исход не известен (заявка могла быть принята), 4xx — брокер точно отклонил
|
|
2801
|
-
throw Object.assign(new Error(formatTbankOrderError(r.status, errText, ord)), { definitive: r.status < 500 });
|
|
2909
|
+
throw Object.assign(new Error(formatTbankOrderError(r.status, errText, { ...ord, limitPrice: finalLimitPx, limitUp, limitDown })), { definitive: r.status < 500 });
|
|
2802
2910
|
}
|
|
2803
2911
|
const j = await r.json();
|
|
2804
2912
|
logBrokerEvent(acc, 'order_sent', `${ord.action} ${ord.symbol} ${lotsToOrder} л. по ${finalLimitPx} ₽ (сигнал ${ord.refPrice || finalLimitPx} ₽) · ${body.orderId}${ord.reason ? ` · ${ord.reason}` : ''}`);
|
|
@@ -3116,7 +3224,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
3116
3224
|
}
|
|
3117
3225
|
}
|
|
3118
3226
|
|
|
3119
|
-
export async function cancelBrokerOrder(id, orderId = null) {
|
|
3227
|
+
export async function cancelBrokerOrder(id, orderId = null, reason = null) {
|
|
3120
3228
|
const list = loadRawAccounts();
|
|
3121
3229
|
const acc = list.find((x) => x.id === id);
|
|
3122
3230
|
if (!acc) throw new Error('Счёт не найден');
|
|
@@ -3126,22 +3234,44 @@ export async function cancelBrokerOrder(id, orderId = null) {
|
|
|
3126
3234
|
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
3127
3235
|
const accountId = acc.accountId || acc.lastSync?.accountId;
|
|
3128
3236
|
if (!accountId) throw new Error('Не определён ID счёта');
|
|
3237
|
+
const activeByOrdId = new Map((acc.lastSync?.activeOrders || []).map((o) => [String(o.orderId), o]));
|
|
3129
3238
|
if (orderId && orderId !== 'all') {
|
|
3130
3239
|
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
3131
3240
|
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 10000,
|
|
3132
3241
|
});
|
|
3133
3242
|
if (!r.ok) throw new Error(`T-Invest CancelOrder: HTTP ${r.status}`);
|
|
3243
|
+
const ao = activeByOrdId.get(String(orderId));
|
|
3244
|
+
const remLots = ao ? Math.max(0, (Number(ao.lotsRequested) || 0) - (Number(ao.lotsExecuted) || 0)) : 0;
|
|
3245
|
+
logBrokerEvent(acc, 'order_expired', `${ao?.symbol || 'заявка'}: снята (${reason || 'вручную'}${remLots ? `, ${remLots} л. по ${ao.price} ₽` : ''}) · ${orderId}`);
|
|
3134
3246
|
} else {
|
|
3135
3247
|
const rOrd = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
|
|
3136
3248
|
method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
|
|
3137
3249
|
});
|
|
3138
3250
|
if (rOrd.ok) {
|
|
3139
3251
|
const jOrd = await rOrd.json();
|
|
3140
|
-
|
|
3141
|
-
|
|
3252
|
+
const rawOrders = jOrd.orders || [];
|
|
3253
|
+
const isScheduledCleanup = reason === 'не исполнена к плановому шагу';
|
|
3254
|
+
const nowMs = Date.now();
|
|
3255
|
+
const toCancel = rawOrders.filter((o) => {
|
|
3256
|
+
if (!o?.orderId) return false;
|
|
3257
|
+
// Свежую заявку (< 90 с), только что выставленную быстрым добором перед границей шага, не сбиваем на первой же минуте
|
|
3258
|
+
if (isScheduledCleanup && o.orderDate && nowMs - Date.parse(o.orderDate) < 90000) return false;
|
|
3259
|
+
return true;
|
|
3260
|
+
});
|
|
3261
|
+
const metaCache = kvGet('tbankInstMeta') || {};
|
|
3262
|
+
await Promise.all(toCancel.map((o) => (
|
|
3263
|
+
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
3142
3264
|
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(o.orderId) }), timeoutMs: 10000,
|
|
3143
|
-
}).catch(() => null)
|
|
3265
|
+
}).catch(() => null)
|
|
3144
3266
|
)));
|
|
3267
|
+
for (const o of toCancel) {
|
|
3268
|
+
const ao = activeByOrdId.get(String(o.orderId));
|
|
3269
|
+
const m = metaCache[o.ticker] || metaCache[o.figi] || metaCache[o.instrumentUid] || {};
|
|
3270
|
+
const sym = ao?.symbol || m.ticker || o.ticker || o.figi || 'заявка';
|
|
3271
|
+
const remLots = Math.max(0, (Number(o.lotsRequested) || 0) - (Number(o.lotsExecuted) || 0));
|
|
3272
|
+
const px = ao?.price || round(qVal(o.initialSecurityPrice), 4);
|
|
3273
|
+
logBrokerEvent(acc, 'order_expired', `${sym}: снята (${reason || 'вручную'}${remLots ? `, ${remLots} л.${px ? ` по ${px} ₽` : ''}` : ''}) · ${o.orderId}`);
|
|
3274
|
+
}
|
|
3145
3275
|
}
|
|
3146
3276
|
}
|
|
3147
3277
|
return syncBrokerAccount(id);
|
package/server/model/edge.js
CHANGED
|
@@ -101,9 +101,125 @@ export function computeTokenEdgeMults(tokens, sigma, fromIdx = 0, tillIdx = 0) {
|
|
|
101
101
|
return out;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
// Затухание штрафных счётчиков карантина на 30-минутном шаге (период полураспада ~15 торговых дней: 0,9985^(15×33) ≈ 0,48)
|
|
104
|
+
// Затухание штрафных счётчиков карантина на 30-минутном шаге (период полураспада ~15 торговых дней: 0,9985^(15×33) ≈ 0,48).
|
|
105
|
+
// Сильный сигнал (≥110% от индивидуального порога входа) пробивает карантин: проверка 11.11.2026 в лаборатории правил (06.2024–11.2026)
|
|
106
|
+
// показала рост CAGR на обеих половинах периода (при плече 3×: 61,9%/54,6% по половинам против 58,6%/52,1%, просадка 6,3% против 6,4%).
|
|
105
107
|
export const QUARANTINE_DECAY = 0.9985;
|
|
106
|
-
export const
|
|
108
|
+
export const QUARANTINE_OVERRIDE_RATIO = 1.10;
|
|
109
|
+
export const isStockQuarantined = (cnt, pnl, edgeRatio = 0) => {
|
|
110
|
+
if (edgeRatio >= QUARANTINE_OVERRIDE_RATIO) return false;
|
|
111
|
+
return (cnt >= 2 && pnl < -0.012) || pnl < -0.025;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Расчёт карантина по истории реальных сделок счёта (FIFO-круги с затуханием QUARANTINE_DECAY и коэффициентом 0,90 на сделку —
|
|
115
|
+
// единая формула с simulator.js, research.js и live.js, без искажения от частичных исполнений и продаж старого портфеля).
|
|
116
|
+
const NON_STOCK_TICKERS = new Set(['LQDT', 'TMON@', 'TMON', 'SBMM']);
|
|
117
|
+
export function computeAccountQuarantine(operations, heldSymbols = new Set(), nowTs = Date.now(), feeSidePct = 0.04) {
|
|
118
|
+
const out = new Map();
|
|
119
|
+
if (!Array.isArray(operations) || !operations.length) return out;
|
|
120
|
+
const cutoffTs = nowTs - 45 * DAY;
|
|
121
|
+
const recent = operations
|
|
122
|
+
.filter((o) => o && o.date >= cutoffTs && o.symbol && !NON_STOCK_TICKERS.has(o.symbol) && (o.kind === 'buy' || o.kind === 'sell') && o.qty > 0 && o.price > 0)
|
|
123
|
+
.sort((a, b) => a.date - b.date);
|
|
124
|
+
if (!recent.length) return out;
|
|
125
|
+
const roundTripFee = 2 * ((Number(feeSidePct) || 0.04) / 100);
|
|
126
|
+
const queues = new Map();
|
|
127
|
+
const rawTrips = [];
|
|
128
|
+
for (const op of recent) {
|
|
129
|
+
const sym = op.symbol;
|
|
130
|
+
const dir = op.kind === 'buy' ? 'long' : 'short';
|
|
131
|
+
let q = queues.get(sym);
|
|
132
|
+
// Несопоставленные остатки старше 18 часов — это односторонние продажи/покупки начального портфеля, а не внутридневные сделки
|
|
133
|
+
if (q && q.lots.length) {
|
|
134
|
+
q.lots = q.lots.filter((l) => op.date - l.ts <= 18 * 3600000);
|
|
135
|
+
if (!q.lots.length) q = null;
|
|
136
|
+
}
|
|
137
|
+
if (!q || !q.lots.length || q.side === dir) {
|
|
138
|
+
if (!q || !q.lots.length) q = { side: dir, lots: [] };
|
|
139
|
+
q.lots.push({ qty: op.qty, px: op.price, ts: op.date });
|
|
140
|
+
queues.set(sym, q);
|
|
141
|
+
} else {
|
|
142
|
+
let rem = op.qty;
|
|
143
|
+
while (rem > 0 && q.lots.length) {
|
|
144
|
+
const e = q.lots[0];
|
|
145
|
+
const take = Math.min(rem, e.qty);
|
|
146
|
+
const gross = q.side === 'long' ? (op.price / e.px - 1) : (1 - op.price / e.px);
|
|
147
|
+
const net = gross - roundTripFee;
|
|
148
|
+
rawTrips.push({ symbol: sym, side: q.side, alloc: take * e.px, entryTs: e.ts, exitTs: op.date, net });
|
|
149
|
+
e.qty -= take;
|
|
150
|
+
rem -= take;
|
|
151
|
+
if (e.qty <= 0) q.lots.shift();
|
|
152
|
+
}
|
|
153
|
+
if (rem > 0) {
|
|
154
|
+
q = { side: dir, lots: [{ qty: rem, px: op.price, ts: op.date }] };
|
|
155
|
+
queues.set(sym, q);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Объединяем частичные исполнения одной и той же заявки (вход или выход в пределах 5 минут) в один круг сделки
|
|
160
|
+
const mergedBySym = new Map();
|
|
161
|
+
for (const t of rawTrips) {
|
|
162
|
+
if (t.alloc < 2000) continue;
|
|
163
|
+
const list = mergedBySym.get(t.symbol) || [];
|
|
164
|
+
const prev = list.length ? list[list.length - 1] : null;
|
|
165
|
+
if (prev && prev.side === t.side && (Math.abs(t.exitTs - prev.exitTs) <= 5 * 60000 || Math.abs(t.entryTs - prev.entryTs) <= 5 * 60000)) {
|
|
166
|
+
const totAlloc = prev.alloc + t.alloc;
|
|
167
|
+
prev.net = (prev.net * prev.alloc + t.net * t.alloc) / totAlloc;
|
|
168
|
+
prev.alloc = totAlloc;
|
|
169
|
+
prev.exitTs = Math.max(prev.exitTs, t.exitTs);
|
|
170
|
+
} else {
|
|
171
|
+
list.push({ ...t });
|
|
172
|
+
mergedBySym.set(t.symbol, list);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const STEP_MS = 30 * 60000;
|
|
176
|
+
const TRADING_SHARE = 15.5 / 24;
|
|
177
|
+
for (const [sym, trips] of mergedBySym) {
|
|
178
|
+
if (heldSymbols && heldSymbols.has(sym)) continue;
|
|
179
|
+
let pnl = 0, cnt = 0, prevExitTs = 0;
|
|
180
|
+
for (const t of trips) {
|
|
181
|
+
if (prevExitTs > 0) {
|
|
182
|
+
const dtSteps = Math.max(0, (t.exitTs - prevExitTs) / STEP_MS) * TRADING_SHARE;
|
|
183
|
+
const d = Math.pow(QUARANTINE_DECAY, dtSteps);
|
|
184
|
+
pnl *= d;
|
|
185
|
+
cnt *= d;
|
|
186
|
+
}
|
|
187
|
+
pnl = pnl * 0.90 + t.net;
|
|
188
|
+
cnt += 1;
|
|
189
|
+
prevExitTs = t.exitTs;
|
|
190
|
+
}
|
|
191
|
+
if (prevExitTs > 0) {
|
|
192
|
+
const tailSteps = Math.max(0, (nowTs - prevExitTs) / STEP_MS) * TRADING_SHARE;
|
|
193
|
+
const dTail = Math.pow(QUARANTINE_DECAY, tailSteps);
|
|
194
|
+
pnl *= dTail;
|
|
195
|
+
cnt *= dTail;
|
|
196
|
+
}
|
|
197
|
+
if (isStockQuarantined(cnt, pnl, 0)) {
|
|
198
|
+
let expSteps = 0;
|
|
199
|
+
for (let s = 1; s <= 2500; s++) {
|
|
200
|
+
const d = Math.pow(QUARANTINE_DECAY, s);
|
|
201
|
+
if (!isStockQuarantined(cnt * d, pnl * d, 0)) { expSteps = s; break; }
|
|
202
|
+
}
|
|
203
|
+
const untilTs = Math.round(nowTs + expSteps * STEP_MS / TRADING_SHARE);
|
|
204
|
+
const untilIso = new Date(untilTs + 3 * 3600000).toISOString();
|
|
205
|
+
const untilText = `${untilIso.slice(8, 10)}.${untilIso.slice(5, 7)}`;
|
|
206
|
+
const pnlPct = Math.round(pnl * 10000) / 100;
|
|
207
|
+
const lastTrip = trips[trips.length - 1];
|
|
208
|
+
out.set(sym, {
|
|
209
|
+
symbol: sym,
|
|
210
|
+
cnt: Math.round(cnt * 100) / 100,
|
|
211
|
+
pnlPct,
|
|
212
|
+
tradesCount: trips.length,
|
|
213
|
+
lastExitTs: prevExitTs,
|
|
214
|
+
lastSide: lastTrip?.side || 'long',
|
|
215
|
+
untilTs,
|
|
216
|
+
untilText,
|
|
217
|
+
reasonText: `Карантин счёта после убытка ${pnlPct}% (до ${untilText} пропускаются сигналы слабее ${Math.round(QUARANTINE_OVERRIDE_RATIO * 100)}% порога)`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
107
223
|
|
|
108
224
|
// Граница вечерней сессии Мосбиржи (последняя 10-минутная свеча 23:40–23:50 МСК):
|
|
109
225
|
// не открываем новые внутридневные позиции, когда до конца торгов остаётся меньше горизонта H, и закрываем позиции перед ночью
|
package/server/model/forecast.js
CHANGED
|
@@ -4,12 +4,12 @@ import { round, DAY, MSK, fmtBarMsk } from '../util.js';
|
|
|
4
4
|
import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
|
|
5
5
|
import { buildFold, MODELS, HEADS, KINDS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
|
|
6
6
|
import { loadProfiles } from './profiles.js';
|
|
7
|
-
import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M, canEnterIntraday, isMarketDown } from './edge.js';
|
|
7
|
+
import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, REGIME_SHOCK_1D, REGIME_CRASH_30M, isStockQuarantined, canEnterIntraday, isMarketDown } from './edge.js';
|
|
8
8
|
export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults };
|
|
9
9
|
import { newsForSymbols, blockingNews } from '../news/index.js';
|
|
10
10
|
import { dayContext } from './policy.js';
|
|
11
11
|
import { liveDividendGuard } from './dividends.js';
|
|
12
|
-
import { fetchTbankRealtimePrices } from '../brokers/index.js';
|
|
12
|
+
import { fetchTbankRealtimePrices, getActiveAccountSignalFilters } from '../brokers/index.js';
|
|
13
13
|
import { kvGet, kvSet } from '../db.js';
|
|
14
14
|
import { hub } from '../hub.js';
|
|
15
15
|
|
|
@@ -504,12 +504,29 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
504
504
|
return { f, u, fitted, chosen, threshold, needL, needS, marketDown: bear, rows };
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
-
// Новости и защитные блокировки (сквиз, запрет шорта у брокера, дивидендная отсечка, новостной
|
|
507
|
+
// Новости и защитные блокировки (сквиз, запрет шорта у брокера, дивидендная отсечка, новостной фильтр, карантин/пауза счёта) — в прогнозе «сейчас»
|
|
508
|
+
function formatNearSignalsList(episodesMap) {
|
|
509
|
+
return [...episodesMap.values()]
|
|
510
|
+
.sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
|
|
511
|
+
.slice(0, 120)
|
|
512
|
+
.map((ep) => {
|
|
513
|
+
const cleanReasons = ep.status === 'signal' ? [] : (ep.reasons || []);
|
|
514
|
+
const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
|
|
515
|
+
const reasonText = ep.status === 'signal'
|
|
516
|
+
? 'Порог пройден — сигнал сформирован'
|
|
517
|
+
: ep.status === 'blocked'
|
|
518
|
+
? `Порог пройден, но вход закрыт: ${cleanReasons.join(' · ') || 'защитный фильтр'}`
|
|
519
|
+
: `Ниже порога на ${gapPct}%${cleanReasons.length ? ` (также активен фильтр: ${cleanReasons.join(' · ')})` : ''}`;
|
|
520
|
+
return { ...ep, reasons: cleanReasons, gapPct, reasonText };
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
508
524
|
function withNews(result, p) {
|
|
509
525
|
const nm = p.newsEnabled ? newsForSymbols(24) : null;
|
|
510
526
|
const nb = p.newsFilter ? blockingNews(6) : null;
|
|
511
527
|
const shortableMap = kvGet('tbankShortableMap')?.map || null;
|
|
512
528
|
const nowTs = Date.now();
|
|
529
|
+
const accFilters = getActiveAccountSignalFilters(nowTs);
|
|
513
530
|
const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.40;
|
|
514
531
|
const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.50;
|
|
515
532
|
if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
|
|
@@ -582,29 +599,124 @@ function withNews(result, p) {
|
|
|
582
599
|
const ratio = effThr > 0 ? Math.abs(Number(r.expectedPct) || 0) / effThr : 0;
|
|
583
600
|
r.thresholdRatioPct = Math.round(ratio * 100);
|
|
584
601
|
r.crossedThreshold = ratio >= 1;
|
|
602
|
+
|
|
603
|
+
// Учёт фильтров живого счёта (карантин после убыточных сделок, пауза после выхода, дневной блок 30099):
|
|
604
|
+
const qInfo = accFilters?.quarantinedMap?.get(r.symbol);
|
|
605
|
+
if (qInfo) {
|
|
606
|
+
r.quarantineInfo = qInfo;
|
|
607
|
+
if (isStockQuarantined(qInfo.cnt, qInfo.pnlPct / 100, ratio)) {
|
|
608
|
+
r.blockedByQuarantine = qInfo.reasonText;
|
|
609
|
+
r.signal = false;
|
|
610
|
+
r.shortSignal = false;
|
|
611
|
+
} else {
|
|
612
|
+
delete r.blockedByQuarantine;
|
|
613
|
+
}
|
|
614
|
+
} else {
|
|
615
|
+
delete r.quarantineInfo;
|
|
616
|
+
delete r.blockedByQuarantine;
|
|
617
|
+
}
|
|
618
|
+
const cdWhy = accFilters?.cooldownMap?.get(r.symbol);
|
|
619
|
+
if (cdWhy) {
|
|
620
|
+
r.blockedByCooldown = cdWhy;
|
|
621
|
+
r.signal = false;
|
|
622
|
+
r.shortSignal = false;
|
|
623
|
+
} else {
|
|
624
|
+
delete r.blockedByCooldown;
|
|
625
|
+
}
|
|
626
|
+
const wantSide = r.expectedPct < 0 ? 'short' : 'buy';
|
|
627
|
+
const dayBlk = accFilters?.dayBlockMap?.get(r.symbol);
|
|
628
|
+
if (dayBlk && dayBlk.side === wantSide) {
|
|
629
|
+
r.blockedByCorridor = 'Вход заблокирован до конца дня: цена у границы ценового коридора Мосбиржи (код 30099)';
|
|
630
|
+
if (wantSide === 'buy') r.signal = false;
|
|
631
|
+
else r.shortSignal = false;
|
|
632
|
+
} else {
|
|
633
|
+
delete r.blockedByCorridor;
|
|
634
|
+
}
|
|
585
635
|
}
|
|
586
|
-
// Если в сохранённом кэше список
|
|
636
|
+
// Если в сохранённом кэше список ещё не загружен или был обрезан, восстанавливаем его
|
|
587
637
|
const saved = !nearSignalsCache.episodesMap.size ? kvGet('nearSignalsLog') : null;
|
|
588
|
-
if (saved?.entries?.length &&
|
|
638
|
+
if (saved?.entries?.length && !nearSignalsCache.episodesMap.size) {
|
|
589
639
|
nearSignalsCache = { key: saved.key, scannedBarTs: saved.scannedBarTs || 0, episodesMap: new Map(saved.entries), list: [] };
|
|
590
640
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
641
|
+
// Живое пополнение журнала сегодняшними внутрисвечными сигналами и почти-сигналами (чтобы входы внутри 10-мин свечи сразу попадали в журнал)
|
|
642
|
+
if (nearSignalsCache.episodesMap.size > 0 && Array.isArray(result.rows)) {
|
|
643
|
+
const liveTs = result.liveTs || nowTs;
|
|
644
|
+
const slotBarTs = Math.floor(liveTs / BAR_MS) * BAR_MS;
|
|
645
|
+
const barMskIso = new Date(slotBarTs + MSK).toISOString();
|
|
646
|
+
const dayStr = barMskIso.slice(0, 10);
|
|
647
|
+
const timeStr = barMskIso.slice(11, 16);
|
|
648
|
+
const rankOf = (st) => (st === 'signal' ? 3 : st === 'blocked' ? 2 : 1);
|
|
649
|
+
let liveUpdated = false;
|
|
650
|
+
for (const row of result.rows) {
|
|
651
|
+
if (row.inIndex === false || (row.thresholdRatioPct || 0) < 75) continue;
|
|
652
|
+
const side = row.expectedPct < 0 ? 'short' : 'long';
|
|
653
|
+
const thr = side === 'short' ? row.shortThresholdPct : row.buyThresholdPct;
|
|
654
|
+
const crossed = Boolean(row.crossedThreshold);
|
|
655
|
+
const reasons = [];
|
|
656
|
+
if (side === 'short') {
|
|
657
|
+
if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
|
|
658
|
+
if (row.shortBlockedBySqueeze) reasons.push('защита от сквиза');
|
|
659
|
+
if (row.shortBlockedByTime) reasons.push('вне окна шорта по времени сессии');
|
|
660
|
+
if (row.shortBlockedByBroker) reasons.push('запрет шорта у брокера');
|
|
661
|
+
if (row.shortBlockedByDiv) reasons.push(`отсечка дивидендов (${row.shortBlockedByDiv})`);
|
|
662
|
+
if (row.shortBlockedByNews) reasons.push(`фильтр новостей (${row.shortBlockedByNews})`);
|
|
663
|
+
} else {
|
|
664
|
+
if (row.buyBlockedByDip) reasons.push('защита от обвала свечи');
|
|
665
|
+
if (row.buyBlockedByOverheat) reasons.push(`перегрев +${row.buyBlockedByOverheat}%`);
|
|
666
|
+
if (row.buyBlockedByDiv) reasons.push(`отсечка дивидендов (${row.buyBlockedByDiv})`);
|
|
667
|
+
if (row.buyBlockedByNews) reasons.push(`фильтр новостей (${row.buyBlockedByNews})`);
|
|
668
|
+
}
|
|
669
|
+
if (row.blockedByQuarantine) reasons.push(`карантин счёта (${row.quarantineInfo?.pnlPct ?? ''}%)`);
|
|
670
|
+
if (row.blockedByCooldown) reasons.push(row.blockedByCooldown);
|
|
671
|
+
if (row.blockedByCorridor) reasons.push('ценовой коридор 30099');
|
|
672
|
+
if (row.liquid === false) reasons.push('низкая ликвидность');
|
|
673
|
+
const isSignal = crossed && reasons.length === 0 && Boolean(side === 'long' ? row.signal : row.shortSignal);
|
|
674
|
+
const status = isSignal ? 'signal' : crossed ? 'blocked' : 'near';
|
|
675
|
+
const pctNow = row.thresholdRatioPct || 0;
|
|
676
|
+
const epKey = `${dayStr}:${row.symbol}:${side}`;
|
|
677
|
+
const prev = nearSignalsCache.episodesMap.get(epKey);
|
|
678
|
+
if (!prev) {
|
|
679
|
+
nearSignalsCache.episodesMap.set(epKey, {
|
|
680
|
+
day: dayStr, firstBar: timeStr, lastBar: timeStr, firstBarTs: slotBarTs, lastBarTs: slotBarTs,
|
|
681
|
+
peakBar: timeStr, barsCount: 1, symbol: row.symbol, name: row.name, side, price: row.price,
|
|
682
|
+
peakExpectedPct: row.expectedPct, thresholdPct: thr, peakRatioPct: pctNow,
|
|
683
|
+
status, reasons: status === 'signal' ? [] : reasons,
|
|
604
684
|
});
|
|
685
|
+
liveUpdated = true;
|
|
686
|
+
} else {
|
|
687
|
+
if (slotBarTs > prev.lastBarTs) {
|
|
688
|
+
prev.lastBar = timeStr;
|
|
689
|
+
prev.lastBarTs = slotBarTs;
|
|
690
|
+
prev.barsCount += 1;
|
|
691
|
+
liveUpdated = true;
|
|
692
|
+
}
|
|
693
|
+
if (rankOf(status) > rankOf(prev.status) || (rankOf(status) === rankOf(prev.status) && pctNow > prev.peakRatioPct)) {
|
|
694
|
+
prev.peakExpectedPct = row.expectedPct;
|
|
695
|
+
prev.thresholdPct = thr;
|
|
696
|
+
prev.peakRatioPct = Math.max(prev.peakRatioPct, pctNow);
|
|
697
|
+
prev.peakBar = timeStr;
|
|
698
|
+
prev.price = row.price;
|
|
699
|
+
prev.status = status;
|
|
700
|
+
prev.reasons = status === 'signal' ? [] : (reasons.length ? reasons : prev.reasons);
|
|
701
|
+
liveUpdated = true;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
605
704
|
}
|
|
606
|
-
|
|
607
|
-
|
|
705
|
+
if (liveUpdated) {
|
|
706
|
+
nearSignalsCache.list = formatNearSignalsList(nearSignalsCache.episodesMap);
|
|
707
|
+
kvSet('nearSignalsLog', {
|
|
708
|
+
key: nearSignalsCache.key,
|
|
709
|
+
scannedBarTs: nearSignalsCache.scannedBarTs,
|
|
710
|
+
entries: [...nearSignalsCache.episodesMap.entries()],
|
|
711
|
+
list: nearSignalsCache.list,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (nearSignalsCache.episodesMap.size && !nearSignalsCache.list.length) {
|
|
716
|
+
nearSignalsCache.list = formatNearSignalsList(nearSignalsCache.episodesMap);
|
|
717
|
+
}
|
|
718
|
+
if (nearSignalsCache.list.length || !Array.isArray(result.nearSignals) || !result.nearSignals.length) {
|
|
719
|
+
result.nearSignals = nearSignalsCache.list.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
|
|
608
720
|
}
|
|
609
721
|
return result;
|
|
610
722
|
}
|
|
@@ -624,7 +736,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
|
|
|
624
736
|
key,
|
|
625
737
|
scannedBarTs: saved.scannedBarTs,
|
|
626
738
|
episodesMap: new Map(saved.entries),
|
|
627
|
-
list:
|
|
739
|
+
list: [],
|
|
628
740
|
};
|
|
629
741
|
} else {
|
|
630
742
|
nearSignalsCache = { key, scannedBarTs: 0, episodesMap: new Map(), list: [] };
|
|
@@ -695,7 +807,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
|
|
|
695
807
|
thresholdPct: thr,
|
|
696
808
|
peakRatioPct: Math.round(ratio * 100),
|
|
697
809
|
status,
|
|
698
|
-
reasons,
|
|
810
|
+
reasons: status === 'signal' ? [] : reasons,
|
|
699
811
|
});
|
|
700
812
|
} else {
|
|
701
813
|
prev.lastBar = timeStr;
|
|
@@ -709,14 +821,14 @@ async function updateNearSignalsJournal(ds, p, last, key) {
|
|
|
709
821
|
prev.peakBar = timeStr;
|
|
710
822
|
prev.price = row.price;
|
|
711
823
|
prev.status = status;
|
|
712
|
-
|
|
824
|
+
prev.reasons = status === 'signal' ? [] : (reasons.length ? reasons : prev.reasons);
|
|
713
825
|
} else if (pctNow > prev.peakRatioPct) {
|
|
714
826
|
prev.peakExpectedPct = row.expectedPct;
|
|
715
827
|
prev.thresholdPct = thr;
|
|
716
828
|
prev.peakRatioPct = pctNow;
|
|
717
829
|
prev.peakBar = timeStr;
|
|
718
830
|
}
|
|
719
|
-
if (reasons.length && !prev.reasons?.length) prev.reasons = reasons;
|
|
831
|
+
if (prev.status !== 'signal' && reasons.length && !prev.reasons?.length) prev.reasons = reasons;
|
|
720
832
|
}
|
|
721
833
|
}
|
|
722
834
|
nearSignalsCache.scannedBarTs = barTs;
|
|
@@ -724,18 +836,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
|
|
|
724
836
|
}
|
|
725
837
|
|
|
726
838
|
if (updated || !nearSignalsCache.list.length) {
|
|
727
|
-
const list =
|
|
728
|
-
.sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
|
|
729
|
-
.slice(0, 120)
|
|
730
|
-
.map((ep) => {
|
|
731
|
-
const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
|
|
732
|
-
const reasonText = ep.status === 'signal'
|
|
733
|
-
? 'Порог пройден — сигнал сформирован'
|
|
734
|
-
: ep.status === 'blocked'
|
|
735
|
-
? `Порог пройден, но вход закрыт: ${(ep.reasons || []).join(' · ') || 'защитный фильтр'}`
|
|
736
|
-
: `Ниже порога на ${gapPct}%${ep.reasons?.length ? ` (также активен фильтр: ${ep.reasons.join(' · ')})` : ''}`;
|
|
737
|
-
return { ...ep, gapPct, reasonText };
|
|
738
|
-
});
|
|
839
|
+
const list = formatNearSignalsList(nearSignalsCache.episodesMap);
|
|
739
840
|
nearSignalsCache.list = list;
|
|
740
841
|
kvSet('nearSignalsLog', {
|
|
741
842
|
key,
|
package/server/model/live.js
CHANGED
|
@@ -415,9 +415,8 @@ class LiveEngine {
|
|
|
415
415
|
};
|
|
416
416
|
const exitRatio = autoAdapt ? 0.95 : Number(p.modelExitRatio ?? 0.5);
|
|
417
417
|
const mktDown = p.modelMarketFilter === 'ma' && r.marketDown;
|
|
418
|
-
const
|
|
418
|
+
const stMap = new Map();
|
|
419
419
|
if (autoAdapt && s.trades.length >= 1) {
|
|
420
|
-
const stMap = new Map();
|
|
421
420
|
for (const t of s.trades) {
|
|
422
421
|
// затухание прошлых штрафов с возрастом сделки (~15 торговых дней полураспада)
|
|
423
422
|
const ageSteps = Math.max(0, (ts - (t.exitTs || ts)) / (stepBars * BAR_MS));
|
|
@@ -427,7 +426,6 @@ class LiveEngine {
|
|
|
427
426
|
cur.cnt += wDecay;
|
|
428
427
|
stMap.set(t.symbol, cur);
|
|
429
428
|
}
|
|
430
|
-
for (const [sym, st] of stMap) if (isStockQuarantined(st.cnt, st.pnl)) quarantined.add(sym);
|
|
431
429
|
}
|
|
432
430
|
for (const [sym, h] of s.held) {
|
|
433
431
|
if (autoAdapt && shouldExitBeforeNight(ts, h.entryTs, stepBars, BAR_INTERVAL)) {
|
|
@@ -452,7 +450,10 @@ class LiveEngine {
|
|
|
452
450
|
const out = [];
|
|
453
451
|
for (const x of list) {
|
|
454
452
|
if (out.length >= lim) break;
|
|
455
|
-
|
|
453
|
+
const qSt = stMap.get(x.symbol);
|
|
454
|
+
const thrPct = side === 'buy' ? (x.buyThresholdPct || r.buyThresholdPct || r.thresholdPct) : (x.shortThresholdPct || r.shortThresholdPct || r.thresholdPct);
|
|
455
|
+
const edgeRatio = thrPct > 0 ? Math.abs(x.expectedPct || 0) / thrPct : 0;
|
|
456
|
+
if (qSt && isStockQuarantined(qSt.cnt, qSt.pnl, edgeRatio)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
|
|
456
457
|
const b = nb?.get(x.symbol)?.[side === 'buy' ? 'bad' : 'good'];
|
|
457
458
|
if (b) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: `Пропущена: ${side === 'buy' ? 'плохая' : 'хорошая'} новость — ${b.gist}` }); continue; }
|
|
458
459
|
out.push(x);
|
package/server/model/research.js
CHANGED
|
@@ -393,7 +393,7 @@ async function run(runId, p, state) {
|
|
|
393
393
|
const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
|
|
394
394
|
// Самообучение правил по прошлым закрытым сделкам (временный карантин «акций-убийц» с затуханием и авто-калибровка порога выхода)
|
|
395
395
|
const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
|
|
396
|
-
const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
|
|
396
|
+
const stockAllowed = (i, edgeRatio = 0) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i], edgeRatio);
|
|
397
397
|
let curExitRatio = autoAdapt ? 0.95 : exitRatio;
|
|
398
398
|
const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
|
|
399
399
|
const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
|
|
@@ -459,7 +459,7 @@ async function run(runId, p, state) {
|
|
|
459
459
|
}
|
|
460
460
|
}
|
|
461
461
|
const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
|
|
462
|
-
const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
|
|
462
|
+
const canOpenAt = (i, edgeRatio = 0) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i, edgeRatio) && canEnter(i, t);
|
|
463
463
|
const okLong = (i) => !divRule(i, t)?.blockLong && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
|
|
464
464
|
const okShort = (i) => !divRule(i, t)?.blockShort && (!shortable || shortable.has(tokens[i].symbol)) && (!needTrade || (tokens[i].has[tIn] && tokens[i].open[tIn] > 0)) && (!autoAdapt || (!isRegimeShocked(ds, i, t, 'short') && canEnterIntraday(barTs, H, BAR_INTERVAL, 'short', tokens[i].symbol)));
|
|
465
465
|
let order, entries;
|
|
@@ -480,8 +480,8 @@ async function run(runId, p, state) {
|
|
|
480
480
|
}
|
|
481
481
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
482
482
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
483
|
-
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
484
|
-
const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
|
|
483
|
+
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i, edge(i) / (needL * symMult[i])) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
484
|
+
const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i, (-edge(i)) / (needS * symMult[i])) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
|
|
485
485
|
// Вытеснение слабой/остывшей позиции новым мощным лидером (Opportunity Rotation):
|
|
486
486
|
// если капитал заперт в позиции с ослабевшим ожиданием (< 0.55 * exitNeed), а на текущей свече появился лидер в 2.4 раза сильнее порога,
|
|
487
487
|
// освобождаем слот под лидера, чтобы ни одна фаза не застревала в слабых бумагах
|
|
@@ -522,7 +522,7 @@ async function run(runId, p, state) {
|
|
|
522
522
|
if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn, false, shocked ? 'Защита от экстремального обвала' : '');
|
|
523
523
|
}
|
|
524
524
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
525
|
-
const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
525
|
+
const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i, edge(i) / (needL * symMult[i])) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
526
526
|
const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
527
527
|
if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
|
|
528
528
|
const bestNewEdge = edge(rawCand[0]);
|
|
@@ -1118,7 +1118,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1118
1118
|
const borrowFee = (h, t) => h.alloc * (p.modelShortFeePct / 100) * ((tsOf(t) - tsOf(h.entryT)) / 3600000 / 24 / 365);
|
|
1119
1119
|
const countSide = (side) => [...held.values()].filter((h) => h.side === side).length;
|
|
1120
1120
|
const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
|
|
1121
|
-
const stockAllowed = (i) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i]);
|
|
1121
|
+
const stockAllowed = (i, edgeRatio = 0) => !autoAdapt || !isStockQuarantined(stockCnt[i], stockPnl[i], edgeRatio);
|
|
1122
1122
|
const curExitRatio = autoAdapt ? 0.95 : exitRatio;
|
|
1123
1123
|
const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
|
|
1124
1124
|
const maxSlotWeight = dir === 'long' ? Math.min(0.58, Math.max(1 / K, 1.8 / K)) : Math.min(0.75, 3.0 / (2 * K));
|
|
@@ -1159,7 +1159,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1159
1159
|
}
|
|
1160
1160
|
}
|
|
1161
1161
|
const timeOk = !autoAdapt || canEnterIntraday(barTs, H, BAR_INTERVAL);
|
|
1162
|
-
const canOpenAt = (i) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i) && canEnter(i, t);
|
|
1162
|
+
const canOpenAt = (i, edgeRatio = 0) => timeOk && !held.has(i) && !closedThisBar.has(i) && stockAllowed(i, edgeRatio) && canEnter(i, t);
|
|
1163
1163
|
const okLong = (i) => !divRule(i, t)?.blockLong && (!autoAdapt || !isRegimeShocked(ds, i, t, 'long'));
|
|
1164
1164
|
const okShort = (i) => !divRule(i, t)?.blockShort && (!autoAdapt || (!isRegimeShocked(ds, i, t, 'short') && canEnterIntraday(barTs, H, BAR_INTERVAL, 'short', tokens[i].symbol)));
|
|
1165
1165
|
const edge = (i) => pr[i] * sg[i] * Math.sqrt(H);
|
|
@@ -1174,8 +1174,8 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1174
1174
|
}
|
|
1175
1175
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
1176
1176
|
const needS = autoAdapt ? (bear ? need * 0.90 : need * 1.25) : need;
|
|
1177
|
-
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
1178
|
-
const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
|
|
1177
|
+
const candL = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i, edge(i) / (needL * symMult[i])) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
1178
|
+
const candS = !timeOk ? [] : [...pr.keys()].filter((i) => edge(i) < -needS * symMult[i] && canOpenAt(i, (-edge(i)) / (needS * symMult[i])) && okShort(i)).sort((a, b) => pr[a] - pr[b]);
|
|
1179
1179
|
const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
1180
1180
|
if (autoAdapt && usedNow > equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
|
|
1181
1181
|
const bestNewEdge = Math.max(candL.length ? edge(candL[0]) : 0, candS.length ? Math.abs(edge(candS[0])) : 0);
|
|
@@ -1201,7 +1201,7 @@ async function runStabilityLoop(targetId, runRow, st) {
|
|
|
1201
1201
|
if (shocked || !(e > req) || !liquid(i, t)) closePos(i, h, tIn);
|
|
1202
1202
|
}
|
|
1203
1203
|
const needL = autoAdapt && bear ? need * 1.25 : need;
|
|
1204
|
-
const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
1204
|
+
const rawCand = (mktDown || !timeOk) ? [] : [...pr.keys()].filter((i) => edge(i) > needL * symMult[i] && canOpenAt(i, edge(i) / (needL * symMult[i])) && okLong(i)).sort((a, b) => pr[b] - pr[a]);
|
|
1205
1205
|
const usedNow = [...held.values()].reduce((s, h) => s + h.alloc, 0);
|
|
1206
1206
|
if (autoAdapt && usedNow > equity * 0.65 && rawCand.length > 0) {
|
|
1207
1207
|
const bestNewEdge = edge(rawCand[0]);
|
|
@@ -277,18 +277,20 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
277
277
|
}
|
|
278
278
|
const timeOk = !dayHalted && canEnterIntraday(decTs, H, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
|
|
279
279
|
if (timeOk) {
|
|
280
|
-
const baseOk = (i) => !held.has(i) && cooldownUntil[i] <= t && liquid(i) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1) && !isStockQuarantined(stockCnt[i], stockPnl[i]) && (policy.tier2 !== false || isT1(i));
|
|
280
|
+
const baseOk = (i, edgeRatio = 0) => !held.has(i) && cooldownUntil[i] <= t && liquid(i) && (!tokens[i].inIndex || tokens[i].inIndex[t] === 1) && !isStockQuarantined(stockCnt[i], stockPnl[i], edgeRatio) && (policy.tier2 !== false || isT1(i));
|
|
281
281
|
const candL = [];
|
|
282
282
|
const candS = [];
|
|
283
283
|
for (let i = 0; i < N; i++) {
|
|
284
284
|
const e = edge(i);
|
|
285
|
-
|
|
286
|
-
|
|
285
|
+
const thrL = Math.max(needL * mult[i] * tierScale(i), minEdgeL);
|
|
286
|
+
const thrS = Math.max(needS * mult[i] * tierScale(i), minEdgeS);
|
|
287
|
+
if (e > thrL) {
|
|
288
|
+
if (!baseOk(i, e / thrL)) continue;
|
|
287
289
|
const fl = flags(i);
|
|
288
290
|
if (fl.dipBlock || fl.overheat || divRule(i, t)?.blockLong) continue;
|
|
289
291
|
candL.push(i);
|
|
290
|
-
} else if (dir !== 'long' && e < -
|
|
291
|
-
if (!baseOk(i)) continue;
|
|
292
|
+
} else if (dir !== 'long' && e < -thrS) {
|
|
293
|
+
if (!baseOk(i, (-e) / thrS)) continue;
|
|
292
294
|
if (shortable && !shortable.has(tokens[i].symbol)) continue;
|
|
293
295
|
if (!canEnterIntraday(decTs, H, BAR_INTERVAL, 'short', tokens[i].symbol)) continue;
|
|
294
296
|
const fl = flags(i);
|
|
@@ -23,9 +23,11 @@ const MAX_STEPS = 20;
|
|
|
23
23
|
const MSK = 3 * 3600000;
|
|
24
24
|
const fmtTs = (ts) => new Date(ts + MSK).toISOString().slice(0, 16).replace('T', ' ');
|
|
25
25
|
const fmtClockMsk = (ts) => new Date(ts + MSK).toISOString().slice(11, 16);
|
|
26
|
-
// Общие вопросы о состоянии торгов («как дела», «что происходит») требуют
|
|
26
|
+
// Общие вопросы о состоянии торгов («как дела», «что происходит») и вопросы по акциям/сигналам требуют вызова свежих инструментов, а не чтения старых реплик диалога
|
|
27
27
|
const STATUS_QUESTION_RE = /^(?:ну\s+)?(?:как\s+(?:дела|делишки|торговля|идут\s+торги|сч[её]т|успехи)|что\s+(?:происходит|нового))(?:[\s!?.,]|$)/i;
|
|
28
|
+
const STOCK_OR_SIGNAL_QUESTION_RE = /(?:почему\s+не\s+(?:покупа|бер|взял|вошл|шорт|прода|откры)|почему\s+пропустил|что\s+(?:по|с)\s+\S+|сигнал\s+по|прогноз\s+по|как\s+вчера|вчерашн)/i;
|
|
28
29
|
const isStatusQuestion = (text = '') => STATUS_QUESTION_RE.test(String(text || '').trim());
|
|
30
|
+
const isStockOrSignalQuestion = (text = '') => STOCK_OR_SIGNAL_QUESTION_RE.test(String(text || '').trim());
|
|
29
31
|
const fmtPct = (v) => (v === null || v === undefined ? '—' : `${v > 0 ? '+' : ''}${v}%`);
|
|
30
32
|
|
|
31
33
|
// Человекочитаемое имя сессии по первому вопросу пользователя или теме запроса
|
|
@@ -208,6 +210,15 @@ function systemPrompt() {
|
|
|
208
210
|
if (!noTools && isStatusQuestion(lastUser?.content)) {
|
|
209
211
|
statusQuestionHint = `\n## Указание для текущего запроса
|
|
210
212
|
Пользователь задал общий вопрос о состоянии. Прежде чем отвечать, **ОБЯЗАТЕЛЬНО** вызови \`get_brokers_status\` и \`get_broker_operations\` с \`{"period": "today"}\` (если счета подключены). Ответь по фактическим данным: идут ли торги, какие позиции открыты, какие сделки уже были сегодня и с каким результатом. Не говори «торги ещё не начались», если в данных есть сделки.
|
|
213
|
+
`;
|
|
214
|
+
} else if (!noTools && isStockOrSignalQuestion(lastUser?.content)) {
|
|
215
|
+
statusQuestionHint = `\n## Указание для текущего запроса
|
|
216
|
+
Пользователь спрашивает о конкретной бумаге, сигнале, пропуске входа или итогах торгов. **КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО** отвечать по прошлым сообщениям или старым вызовам инструментов в истории чата!
|
|
217
|
+
Прежде чем отвечать, **ОБЯЗАТЕЛЬНО** вызови свежие инструменты прямо сейчас:
|
|
218
|
+
- \`model_forecast\` (передай \`symbol\` с тикером или названием компании, если речь о конкретной акции, например \`{"symbol": "SMLT"}\` или \`{"symbol": "Самолет"}\`),
|
|
219
|
+
- \`get_brokers_status\` (чтобы проверить активные фильтры счёта: \`карантин_бумаг_на_счёте\`, \`последние_события_исполнения\`, \`подготовленный_пакет_заявок\`),
|
|
220
|
+
- и при вопросе о вчерашних/сегодняшних сделках — \`get_broker_operations\`.
|
|
221
|
+
В ответе называй точный индивидуальный порог акции из поля \`порог_покупки_проц\` / \`порог_шорта_проц\` свежего вызова \`model_forecast\` и проверяй поля \`заблокирована_карантином_счёта\`, \`заблокирована_паузой_после_выхода\`, \`заблокирована_ценовым_коридором\` и \`эпизоды_выбранной_акции_в_журнале\`.
|
|
211
222
|
`;
|
|
212
223
|
}
|
|
213
224
|
} catch { /* ignore */ }
|
|
@@ -355,7 +366,14 @@ function buildContext(convId) {
|
|
|
355
366
|
}
|
|
356
367
|
if (m.role === 'tool') {
|
|
357
368
|
let content = m.content || '';
|
|
358
|
-
|
|
369
|
+
const ageMs = m.ts ? (Date.now() - m.ts) : 0;
|
|
370
|
+
const isStaleLiveTool = ageMs > 15 * 60000;
|
|
371
|
+
if (isStaleLiveTool) {
|
|
372
|
+
const stamp = m.ts ? fmtTs(m.ts) : 'ранее';
|
|
373
|
+
content = `[УСТАРЕВШИЙ СНИМОК ИНСТРУМЕНТА от ${stamp} МСК — НЕ ИСПОЛЬЗУЙ КАК ТЕКУЩИЕ ДАННЫЕ, вызови инструмент заново!] ${content.slice(0, 380)}…`;
|
|
374
|
+
} else if (!lastToolIdx.includes(idx) && content.length > 1500) {
|
|
375
|
+
content = `${content.slice(0, 1500)}… (сокращено)`;
|
|
376
|
+
}
|
|
359
377
|
out.push({ role: 'tool', tool_call_id: m.tool_call_id, content });
|
|
360
378
|
openIds.delete(m.tool_call_id);
|
|
361
379
|
if (!openIds.size) { out.push(...deferred); deferred.length = 0; }
|
|
@@ -5,7 +5,7 @@ import { getRun, listRuns } from '../db.js';
|
|
|
5
5
|
import { analyzeRun, compareRuns, recentRuns } from './analytics.js';
|
|
6
6
|
import { forecastNow } from '../model/forecast.js';
|
|
7
7
|
import { listNews } from '../news/index.js';
|
|
8
|
-
import { BROKER_META, brokersState, discoverBrokerAccounts, saveBrokerAccount, syncBrokerAccount, prepareBrokerOrders, fetchBrokerHistory } from '../brokers/index.js';
|
|
8
|
+
import { BROKER_META, brokersState, listBrokerEvents, discoverBrokerAccounts, saveBrokerAccount, syncBrokerAccount, prepareBrokerOrders, fetchBrokerHistory } from '../brokers/index.js';
|
|
9
9
|
import { fmtBarMsk, fmtDayMsk, MSK, DAY, round } from '../util.js';
|
|
10
10
|
import { tradingSessionStatus } from '../session.js';
|
|
11
11
|
|
|
@@ -68,6 +68,17 @@ export const TOOLS = {
|
|
|
68
68
|
...(a.entryTsBySymbol?.[p.symbol] ? { время_входа_робота_мск: fmtBarMsk(a.entryTsBySymbol[p.symbol]) } : {}),
|
|
69
69
|
})),
|
|
70
70
|
активные_заявки_в_стакане: a.lastSync?.activeOrders || [],
|
|
71
|
+
карантин_бумаг_на_счёте: (a.quarantinedStocks || []).map((q) => ({
|
|
72
|
+
тикер: q.symbol,
|
|
73
|
+
накопленный_результат_проц: q.pnlPct,
|
|
74
|
+
до_даты: q.untilText,
|
|
75
|
+
правило: q.reasonText,
|
|
76
|
+
})),
|
|
77
|
+
последние_события_исполнения: listBrokerEvents(a.id, 15).map((ev) => ({
|
|
78
|
+
время_мск: fmtBarMsk(ev.ts),
|
|
79
|
+
тип: ev.kind,
|
|
80
|
+
описание: ev.text,
|
|
81
|
+
})),
|
|
71
82
|
подготовленный_пакет_заявок: a.pendingBatch ? {
|
|
72
83
|
свеча: a.pendingBatch.bar,
|
|
73
84
|
статус: a.pendingBatch.status,
|
|
@@ -239,6 +250,13 @@ export const TOOLS = {
|
|
|
239
250
|
цена: op.price || 0,
|
|
240
251
|
сумма_руб: op.paymentRub,
|
|
241
252
|
})),
|
|
253
|
+
события_заявок_и_пропусков_за_период: listBrokerEvents(a.id, 120)
|
|
254
|
+
.filter((ev) => {
|
|
255
|
+
const dIso = new Date((ev.ts || 0) + MSK).toISOString().slice(0, 10);
|
|
256
|
+
return dIso >= startIso && dIso <= endIso;
|
|
257
|
+
})
|
|
258
|
+
.reverse()
|
|
259
|
+
.map((ev) => ({ время_мск: fmtBarMsk(ev.ts), тип: ev.kind, описание: ev.text })),
|
|
242
260
|
...(prevActiveDay ? { подсказка: `За период ${periodLabel} операций не было; ближайший предыдущий день со сделками: ${prevActiveDay}` } : {}),
|
|
243
261
|
};
|
|
244
262
|
});
|
|
@@ -299,12 +317,17 @@ export const TOOLS = {
|
|
|
299
317
|
...(x.shortBlockedByBroker ? { шорт_запрещён_брокером: true } : {}),
|
|
300
318
|
...(x.shortBlockedByDiv ? { шорт_заблокирован_дивидендами: x.shortBlockedByDiv } : {}),
|
|
301
319
|
...(x.shortBlockedByNews ? { шорт_заблокирован_новостями: x.shortBlockedByNews } : {}),
|
|
320
|
+
...(x.blockedByQuarantine ? { заблокирована_карантином_счёта: x.blockedByQuarantine } : (x.quarantineInfo ? { карантин_пробит_сильным_сигналом: `${x.quarantineInfo.reasonText} — сигнал ≥110% порога допущен` } : {})),
|
|
321
|
+
...(x.blockedByCooldown ? { заблокирована_паузой_после_выхода: x.blockedByCooldown } : {}),
|
|
322
|
+
...(x.blockedByCorridor ? { заблокирована_ценовым_коридором: x.blockedByCorridor } : {}),
|
|
302
323
|
ликвидна: x.liquid,
|
|
303
324
|
влияют_соседи: (x.influences || []).map((n) => `${n.symbol} (${n.contribPct > 0 ? '+' : ''}${n.contribPct}%, близость ${n.closeness})`),
|
|
304
325
|
};
|
|
305
326
|
};
|
|
306
327
|
const symUp = symbol ? String(symbol).toUpperCase().trim() : '';
|
|
307
|
-
const targetRow = symUp
|
|
328
|
+
const targetRow = symUp
|
|
329
|
+
? (r.rows.find((x) => x.symbol === symUp) || r.rows.find((x) => String(x.name || '').toUpperCase().includes(symUp)))
|
|
330
|
+
: null;
|
|
308
331
|
return {
|
|
309
332
|
result: {
|
|
310
333
|
текущее_время_мск: fmtBarMsk(Date.now()),
|
|
@@ -315,7 +338,10 @@ export const TOOLS = {
|
|
|
315
338
|
порог_шорта_1_эшелон_проц: -shortThr,
|
|
316
339
|
порог_шорта_2_эшелон_проц: -shortThrT2,
|
|
317
340
|
тренд_индекса: r.marketDown ? 'падающий (облегчён шорт, ужесточён лонг)' : 'растущий (стандартный лонг, ужесточён шорт)',
|
|
318
|
-
...(targetRow ? {
|
|
341
|
+
...(targetRow ? {
|
|
342
|
+
выбранная_акция: pick(targetRow),
|
|
343
|
+
эпизоды_выбранной_акции_в_журнале: (r.nearSignals || []).filter((e) => e.symbol === targetRow.symbol),
|
|
344
|
+
} : {}),
|
|
319
345
|
сильнее_рынка: r.rows.slice(0, top).map(pick),
|
|
320
346
|
слабее_рынка: r.rows.slice(-Math.min(6, top)).reverse().map(pick),
|
|
321
347
|
журнал_почти_сигналов_за_неделю: (r.nearSignals || []).slice(0, 12),
|