neuroalpha 0.6.7 → 0.6.9
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 +134 -44
- package/server/index.js +16 -3
- package/server/model/dataset.js +4 -7
- package/server/model/edge.js +118 -2
- package/server/model/forecast.js +147 -40
- package/server/model/live.js +10 -7
- package/server/model/policy.js +6 -0
- package/server/model/research.js +10 -10
- package/server/model/simulator.js +13 -10
- 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.9",
|
|
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)) {
|
|
@@ -2000,8 +2055,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2000
2055
|
const nowTs = Date.now();
|
|
2001
2056
|
const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
|
|
2002
2057
|
const H = barsFromHours(effHorizon);
|
|
2003
|
-
const
|
|
2004
|
-
const
|
|
2058
|
+
const holdBars = autoAdapt ? (STRATEGY_POLICY.holdBars ?? H) : H;
|
|
2059
|
+
const stepBars = autoAdapt ? Math.max(3, Math.floor(holdBars / 5)) : holdBars;
|
|
2060
|
+
const timeOk = !autoAdapt || canEnterIntraday(nowTs, holdBars, BAR_INTERVAL);
|
|
2005
2061
|
const entryTsMap = acc.entryTsBySymbol || {};
|
|
2006
2062
|
const exitCooldownMap = acc.exitCooldownBySymbol || {};
|
|
2007
2063
|
const entryFailMap = acc.entryFailCooldownBySymbol || {};
|
|
@@ -2022,33 +2078,20 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2022
2078
|
const todayMsk = new Date(nowTs + MSK).toISOString().slice(0, 10);
|
|
2023
2079
|
const entryDayBlockMap = acc.entryDayBlockBySymbol || {};
|
|
2024
2080
|
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
|
-
}
|
|
2081
|
+
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с simulator.js / research.js):
|
|
2082
|
+
// если по закрытым FIFO-кругам накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%, с затуханием QUARANTINE_DECAY),
|
|
2083
|
+
// бумага пропускает обычные сигналы, но пропускает усиленные сигналы >= 110% индивидуального порога (QUARANTINE_OVERRIDE_RATIO).
|
|
2084
|
+
const heldNowSet = new Set((acc.lastSync?.positions || []).filter((p) => p.qty !== 0 && algoQtyFor(acc, p.symbol, p.qty) !== 0).map((p) => p.symbol));
|
|
2085
|
+
const quarantinedMap = autoAdapt
|
|
2086
|
+
? computeAccountQuarantine(acc.lastHistory?.operations, heldNowSet, nowTs, Number(params.commissionPct) || 0.04)
|
|
2087
|
+
: new Map();
|
|
2088
|
+
const isQuarantinedFor = (r, side) => {
|
|
2089
|
+
const q = quarantinedMap.get(r.symbol);
|
|
2090
|
+
if (!q) return false;
|
|
2091
|
+
const reqEdge = side === 'short' ? reqShortEdgeOf(r.symbol) : reqBuyEdgeOf(r.symbol);
|
|
2092
|
+
const edgeRatio = reqEdge > 0 ? Math.abs(Number(r.expectedPct) || 0) / reqEdge : 0;
|
|
2093
|
+
return isStockQuarantined(q.cnt, q.pnlPct / 100, edgeRatio);
|
|
2094
|
+
};
|
|
2052
2095
|
const slots = dir === 'long' ? K : 2 * K;
|
|
2053
2096
|
// Фильтр мощности слота по тарифу:
|
|
2054
2097
|
// • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов), консервативный слот до 24% капитала
|
|
@@ -2084,6 +2127,11 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2084
2127
|
const isRawSignalRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol)) || (rawDir !== 'long' && r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol));
|
|
2085
2128
|
const cooldownSkipped = fc.rows.filter((r) => isRawSignalRow(r) && isExitCooldown(r.symbol));
|
|
2086
2129
|
const entryFailSkipped = fc.rows.filter((r) => isRawSignalRow(r) && !isExitCooldown(r.symbol) && isEntryFailCooldown(r.symbol));
|
|
2130
|
+
const quarantineSkipped = fc.rows.filter((r) => {
|
|
2131
|
+
if (!isRawSignalRow(r) || isInCooldown(r.symbol)) return false;
|
|
2132
|
+
const side = r.expectedPct < 0 ? 'short' : 'buy';
|
|
2133
|
+
return !isDayBlocked(r.symbol, side) && isQuarantinedFor(r, side);
|
|
2134
|
+
});
|
|
2087
2135
|
// Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
|
|
2088
2136
|
const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
|
|
2089
2137
|
const divBlocked = [];
|
|
@@ -2093,10 +2141,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2093
2141
|
|| (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
2142
|
const canShortRow = (r) => (r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))
|
|
2095
2143
|
|| (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') && !
|
|
2144
|
+
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
2145
|
.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
2146
|
.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,
|
|
2147
|
+
const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) => canShortRow(r) && (!autoAdapt || canEnterIntraday(nowTs, holdBars, BAR_INTERVAL, 'short', r.symbol)) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'short') && !isQuarantinedFor(r, 'short') && !nb?.get(r.symbol)?.good)
|
|
2100
2148
|
.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
2149
|
.sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
2102
2150
|
const nonShortableSkipped = [];
|
|
@@ -2146,8 +2194,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2146
2194
|
const effEntryTs = Math.max(entryTsMap[sym] || 0, lastHistEntryTs);
|
|
2147
2195
|
const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
|
|
2148
2196
|
const heldMin = heldMs / 60000;
|
|
2149
|
-
const horizonMin = Math.max(90,
|
|
2150
|
-
// Плавный переход порога удержания от мягкого (0.35 до 45 мин) к динамическому плотному (0.95 × clamp(realizedBarPct / sigmaBarPct, 0.75, 1.25) к концу
|
|
2197
|
+
const horizonMin = Math.max(90, holdBars * BAR_INTERVAL);
|
|
2198
|
+
// Плавный переход порога удержания от мягкого (0.35 до 45 мин) к динамическому плотному (0.95 × clamp(realizedBarPct / sigmaBarPct, 0.75, 1.25) к концу глубины сопровождения)
|
|
2151
2199
|
const symMult = r.edgeMult || symbolEdgeMult(sym);
|
|
2152
2200
|
const holdRatio = exitHoldRatio(heldMin, horizonMin, {
|
|
2153
2201
|
sigmaBarPct: Number(r.day?.sigmaBarPct || r.sigmaPct) || 0,
|
|
@@ -2537,7 +2585,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2537
2585
|
} else if (portfolioStale) {
|
|
2538
2586
|
diagNote = `Сервис портфеля брокера временно отвечает с задержкой (${acc.lastSync?.staleReason || 'таймаут'}) — открытие новых позиций отложено до подтверждения остатков, текущие позиции и заявки под контролем.`;
|
|
2539
2587
|
} else if (!timeOk) {
|
|
2540
|
-
diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше
|
|
2588
|
+
diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше глубины сопровождения (${round((holdBars * BAR_INTERVAL) / 60, 2)} ч) — внутридневные сделки не переносятся через ночь.`;
|
|
2541
2589
|
} else if (edgeWarn) {
|
|
2542
2590
|
diagNote = `На свече ${fc.bar} новые входы временно отложены: ${edgeWarn.toLowerCase()}.`;
|
|
2543
2591
|
} else if ((newBuys.length || newShorts.length) && availRub < 1500) {
|
|
@@ -2559,7 +2607,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2559
2607
|
: '';
|
|
2560
2608
|
const dayBlocked = fc.rows.filter((r) => (r.signal && isDayBlocked(r.symbol, 'buy')) || (r.shortSignal && isDayBlocked(r.symbol, 'short')));
|
|
2561
2609
|
const dayBlockNote = dayBlocked.length ? ` Сигнал ${dayBlocked.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} пропущен до конца дня: брокер отклонил вход из-за ценового коридора — бумага у планки Мосбиржи.` : '';
|
|
2562
|
-
const
|
|
2610
|
+
const qNote = quarantineSkipped.length
|
|
2611
|
+
? ` Сигнал ${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)}% порога.`
|
|
2612
|
+
: '';
|
|
2613
|
+
const cdNote = dayBlockNote + qNote + (cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует пауза (30–60 мин) после недавнего выхода из позиции, чтобы не перезаходить в отыгранный импульс и не платить повторную комиссию.` : '');
|
|
2563
2614
|
const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2564
2615
|
const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2565
2616
|
const thrRangeStr = dir !== 'long'
|
|
@@ -2570,6 +2621,22 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2570
2621
|
: `На свече ${fc.bar} ни одна доступная бумага не превысила индивидуальный порог выгоды (${thrRangeStr})${noShortNote}.`;
|
|
2571
2622
|
}
|
|
2572
2623
|
}
|
|
2624
|
+
if (entryTimeOk) {
|
|
2625
|
+
for (const r of quarantineSkipped) {
|
|
2626
|
+
const q = quarantinedMap.get(r.symbol);
|
|
2627
|
+
recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', q?.reasonText || 'карантин счёта после убыточных сделок');
|
|
2628
|
+
}
|
|
2629
|
+
for (const r of cooldownSkipped) {
|
|
2630
|
+
recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', 'пауза после недавнего выхода из позиции');
|
|
2631
|
+
}
|
|
2632
|
+
for (const r of entryFailSkipped) {
|
|
2633
|
+
recordSignalSkipOnce(acc, fc, r, r.expectedPct < 0 ? 'short' : 'buy', `пауза 5 мин после ошибки приёма у брокера (${entryFailReasonMap[r.symbol] || 'отказ шлюза'})`);
|
|
2634
|
+
}
|
|
2635
|
+
for (const r of fc.rows) {
|
|
2636
|
+
if (r.signal && isDayBlocked(r.symbol, 'buy')) recordSignalSkipOnce(acc, fc, r, 'buy', 'вход заблокирован до конца дня (граница ценового коридора 30099)');
|
|
2637
|
+
if (r.shortSignal && isDayBlocked(r.symbol, 'short')) recordSignalSkipOnce(acc, fc, r, 'short', 'вход заблокирован до конца дня (граница ценового коридора 30099)');
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2573
2640
|
if (divBlocked.length) {
|
|
2574
2641
|
diagNote = `${diagNote ? `${diagNote} ` : ''}Перед дивидендной отсечкой пропущены сигналы: ${[...new Set(divBlocked)].join(', ')}.`;
|
|
2575
2642
|
}
|
|
@@ -3158,7 +3225,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
3158
3225
|
}
|
|
3159
3226
|
}
|
|
3160
3227
|
|
|
3161
|
-
export async function cancelBrokerOrder(id, orderId = null) {
|
|
3228
|
+
export async function cancelBrokerOrder(id, orderId = null, reason = null) {
|
|
3162
3229
|
const list = loadRawAccounts();
|
|
3163
3230
|
const acc = list.find((x) => x.id === id);
|
|
3164
3231
|
if (!acc) throw new Error('Счёт не найден');
|
|
@@ -3168,22 +3235,44 @@ export async function cancelBrokerOrder(id, orderId = null) {
|
|
|
3168
3235
|
const headers = { Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' };
|
|
3169
3236
|
const accountId = acc.accountId || acc.lastSync?.accountId;
|
|
3170
3237
|
if (!accountId) throw new Error('Не определён ID счёта');
|
|
3238
|
+
const activeByOrdId = new Map((acc.lastSync?.activeOrders || []).map((o) => [String(o.orderId), o]));
|
|
3171
3239
|
if (orderId && orderId !== 'all') {
|
|
3172
3240
|
const r = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
3173
3241
|
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(orderId) }), timeoutMs: 10000,
|
|
3174
3242
|
});
|
|
3175
3243
|
if (!r.ok) throw new Error(`T-Invest CancelOrder: HTTP ${r.status}`);
|
|
3244
|
+
const ao = activeByOrdId.get(String(orderId));
|
|
3245
|
+
const remLots = ao ? Math.max(0, (Number(ao.lotsRequested) || 0) - (Number(ao.lotsExecuted) || 0)) : 0;
|
|
3246
|
+
logBrokerEvent(acc, 'order_expired', `${ao?.symbol || 'заявка'}: снята (${reason || 'вручную'}${remLots ? `, ${remLots} л. по ${ao.price} ₽` : ''}) · ${orderId}`);
|
|
3176
3247
|
} else {
|
|
3177
3248
|
const rOrd = await brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/GetOrders`, {
|
|
3178
3249
|
method: 'POST', headers, body: JSON.stringify({ accountId }), timeoutMs: 10000,
|
|
3179
3250
|
});
|
|
3180
3251
|
if (rOrd.ok) {
|
|
3181
3252
|
const jOrd = await rOrd.json();
|
|
3182
|
-
|
|
3183
|
-
|
|
3253
|
+
const rawOrders = jOrd.orders || [];
|
|
3254
|
+
const isScheduledCleanup = reason === 'не исполнена к плановому шагу';
|
|
3255
|
+
const nowMs = Date.now();
|
|
3256
|
+
const toCancel = rawOrders.filter((o) => {
|
|
3257
|
+
if (!o?.orderId) return false;
|
|
3258
|
+
// Свежую заявку (< 90 с), только что выставленную быстрым добором перед границей шага, не сбиваем на первой же минуте
|
|
3259
|
+
if (isScheduledCleanup && o.orderDate && nowMs - Date.parse(o.orderDate) < 90000) return false;
|
|
3260
|
+
return true;
|
|
3261
|
+
});
|
|
3262
|
+
const metaCache = kvGet('tbankInstMeta') || {};
|
|
3263
|
+
await Promise.all(toCancel.map((o) => (
|
|
3264
|
+
brokerFetch(`${base}/tinkoff.public.invest.api.contract.v1.OrdersService/CancelOrder`, {
|
|
3184
3265
|
method: 'POST', headers, body: JSON.stringify({ accountId, orderId: String(o.orderId) }), timeoutMs: 10000,
|
|
3185
|
-
}).catch(() => null)
|
|
3266
|
+
}).catch(() => null)
|
|
3186
3267
|
)));
|
|
3268
|
+
for (const o of toCancel) {
|
|
3269
|
+
const ao = activeByOrdId.get(String(o.orderId));
|
|
3270
|
+
const m = metaCache[o.ticker] || metaCache[o.figi] || metaCache[o.instrumentUid] || {};
|
|
3271
|
+
const sym = ao?.symbol || m.ticker || o.ticker || o.figi || 'заявка';
|
|
3272
|
+
const remLots = Math.max(0, (Number(o.lotsRequested) || 0) - (Number(o.lotsExecuted) || 0));
|
|
3273
|
+
const px = ao?.price || round(qVal(o.initialSecurityPrice), 4);
|
|
3274
|
+
logBrokerEvent(acc, 'order_expired', `${sym}: снята (${reason || 'вручную'}${remLots ? `, ${remLots} л.${px ? ` по ${px} ₽` : ''}` : ''}) · ${o.orderId}`);
|
|
3275
|
+
}
|
|
3187
3276
|
}
|
|
3188
3277
|
}
|
|
3189
3278
|
return syncBrokerAccount(id);
|
|
@@ -3395,8 +3484,9 @@ async function tickBrokerSchedule() {
|
|
|
3395
3484
|
const autoAdapt = params.modelAutoAdapt !== false;
|
|
3396
3485
|
const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
|
|
3397
3486
|
const H = barsFromHours(effHorizon);
|
|
3398
|
-
|
|
3399
|
-
|
|
3487
|
+
const holdBars = autoAdapt ? (STRATEGY_POLICY.holdBars ?? H) : H;
|
|
3488
|
+
// Внутричасовой перехват holdBars/5 (каждые 30 мин) активен в режимах VIP и Хозяин; на тарифах Инвестор и Профи шаг равен полному окну holdBars
|
|
3489
|
+
const stepBars = ((plan.allowVipBoost || plan.isOwner) && autoAdapt) ? Math.max(3, Math.floor(holdBars / 5)) : holdBars;
|
|
3400
3490
|
const minStepMs = stepBars * BAR_MS;
|
|
3401
3491
|
const rawLastFullWallTs = acc.lastFullStepWallTs || 0;
|
|
3402
3492
|
const batchCreatedTs = acc.pendingBatch?.createdAt || 0;
|
package/server/index.js
CHANGED
|
@@ -146,10 +146,23 @@ function lanUrls() {
|
|
|
146
146
|
.map((a) => `http://${a.address}:${PORT}`);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
const shouldOpenBrowser = process.env.OPEN_BROWSER === '1'
|
|
150
|
+
|| (process.env.OPEN_BROWSER !== '0'
|
|
151
|
+
&& process.env.NO_BROWSER !== '1'
|
|
152
|
+
&& !process.argv.includes('--no-browser')
|
|
153
|
+
&& !process.execArgv.includes('--watch')
|
|
154
|
+
&& !IS_CENTRAL
|
|
155
|
+
&& (!IS_INSTANCE || IS_DESKTOP)
|
|
156
|
+
&& (process.platform === 'win32' || process.platform === 'darwin' || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY)));
|
|
157
|
+
|
|
149
158
|
wss.on('error', () => {});
|
|
150
159
|
server.on('error', (e) => {
|
|
151
|
-
if (e.code === 'EADDRINUSE')
|
|
152
|
-
|
|
160
|
+
if (e.code === 'EADDRINUSE') {
|
|
161
|
+
console.error(`Порт ${PORT} занят: сервер уже запущен? Клиент: http://localhost:${PORT}`);
|
|
162
|
+
if (shouldOpenBrowser) openBrowser(`http://localhost:${PORT}`);
|
|
163
|
+
} else {
|
|
164
|
+
console.error(e);
|
|
165
|
+
}
|
|
153
166
|
process.exit(1);
|
|
154
167
|
});
|
|
155
168
|
|
|
@@ -160,7 +173,7 @@ server.listen(PORT, LISTEN_HOST, () => {
|
|
|
160
173
|
console.log(`ИИ-стратег: ${llm.enabled ? `${llm.provider} · ${llm.model}` : 'выключен (нет модели или ключа: Настройки → Модели ИИ)'}`);
|
|
161
174
|
if (!LISTEN_HOST || LISTEN_HOST === '0.0.0.0') for (const url of lanUrls()) console.log(` В локальной сети: ${url}`);
|
|
162
175
|
console.log(`Клиент: http://localhost:${PORT}`);
|
|
163
|
-
if (
|
|
176
|
+
if (shouldOpenBrowser) openBrowser(`http://localhost:${PORT}`);
|
|
164
177
|
});
|
|
165
178
|
|
|
166
179
|
function openBrowser(url) {
|
package/server/model/dataset.js
CHANGED
|
@@ -239,13 +239,14 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
239
239
|
}
|
|
240
240
|
return lastClosedBar >= latestClosedSlotTs;
|
|
241
241
|
};
|
|
242
|
+
const cacheTtlMs = isTradingNow ? 4 * 60000 : 30 * 60000;
|
|
242
243
|
const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
|
|
243
244
|
const hit = datasetCache.get(key);
|
|
244
|
-
if (hit && now - hit.at <
|
|
245
|
+
if (hit && now - hit.at < cacheTtlMs && isCacheFreshForSlot(hit)) return hit.ds;
|
|
245
246
|
// Если в памяти уже есть более широкий датасет (не localOnly), который полностью покрывает запрошенный диапазон [fromTs … tillTs], возвращаем его
|
|
246
247
|
const modeKey = JSON.stringify([interval, useMacro ? 1 : 0, useDividends ? (process.env.DIVIDENDS_MODE || 1) : 0]);
|
|
247
248
|
for (const v of datasetCache.values()) {
|
|
248
|
-
if (v.modeKey === modeKey && (!v.localOnly || localOnly) && now - v.at <
|
|
249
|
+
if (v.modeKey === modeKey && (!v.localOnly || localOnly) && now - v.at < cacheTtlMs && Math.abs(v.fromTs - fromTs) < 2 * DAY && Math.abs(v.tillTs - tillTs) < 10 * 60000 && isCacheFreshForSlot(v)) {
|
|
249
250
|
return v.ds;
|
|
250
251
|
}
|
|
251
252
|
}
|
|
@@ -263,7 +264,6 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
263
264
|
const symbols = vocab.map((v) => v.symbol);
|
|
264
265
|
const raw = {};
|
|
265
266
|
let done = 0;
|
|
266
|
-
const divStat = { applied: 0, skipped: 0 };
|
|
267
267
|
const macroIds = useMacro ? MACRO.filter((m) => m.id !== 'BR').map((m) => m.id) : [];
|
|
268
268
|
const all = [...symbols, 'IMOEX', ...macroIds];
|
|
269
269
|
// акция дольше 2 минут — пропускаем и останавливаем её загрузку (скачанное останется в кэше)
|
|
@@ -284,9 +284,7 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
284
284
|
raw[s] = compact(rows);
|
|
285
285
|
if (divs?.length) {
|
|
286
286
|
// DIVIDENDS_MODE=raw — прежняя корректировка без сверки с ценой (только для сравнения результатов)
|
|
287
|
-
|
|
288
|
-
divStat.applied += st.applied;
|
|
289
|
-
divStat.skipped += st.skipped;
|
|
287
|
+
adjustDividends(raw[s], divs, { verify: process.env.DIVIDENDS_MODE !== 'raw' });
|
|
290
288
|
}
|
|
291
289
|
})(),
|
|
292
290
|
new Promise((_, rej) => {
|
|
@@ -306,7 +304,6 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
306
304
|
onProgress({ symbol: s, done: ++done, total: all.length });
|
|
307
305
|
}
|
|
308
306
|
});
|
|
309
|
-
if (useDividends) console.log(`[dataset] дивиденды: учтено ${divStat.applied}, пропущено (нет подтверждающего падения цены) ${divStat.skipped}`);
|
|
310
307
|
// нефть Brent: склейка фьючерсов (много контрактов — отдельно от общего пула)
|
|
311
308
|
if (useMacro) {
|
|
312
309
|
try {
|
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
|
-
import { dayContext } from './policy.js';
|
|
10
|
+
import { dayContext, STRATEGY_POLICY } 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
|
|
|
@@ -447,7 +447,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
447
447
|
const isLimitUpLocked = isFlatLimitBar && excess1d > 0.05;
|
|
448
448
|
const isLimitDownLocked = isFlatLimitBar && excess1d < -0.05;
|
|
449
449
|
const barDecisionTs = ds.timeline[idx] || Date.now();
|
|
450
|
-
const
|
|
450
|
+
const holdBars = autoAdapt ? (STRATEGY_POLICY.holdBars ?? H) : H;
|
|
451
|
+
const isMorningOpenShortBlocked = autoAdapt && !canEnterIntraday(barDecisionTs, holdBars, 10, 'short', tk.symbol);
|
|
451
452
|
const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M || isLimitDownLocked;
|
|
452
453
|
const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D || isLimitUpLocked;
|
|
453
454
|
const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
|
|
@@ -504,12 +505,29 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
504
505
|
return { f, u, fitted, chosen, threshold, needL, needS, marketDown: bear, rows };
|
|
505
506
|
}
|
|
506
507
|
|
|
507
|
-
// Новости и защитные блокировки (сквиз, запрет шорта у брокера, дивидендная отсечка, новостной
|
|
508
|
+
// Новости и защитные блокировки (сквиз, запрет шорта у брокера, дивидендная отсечка, новостной фильтр, карантин/пауза счёта) — в прогнозе «сейчас»
|
|
509
|
+
function formatNearSignalsList(episodesMap) {
|
|
510
|
+
return [...episodesMap.values()]
|
|
511
|
+
.sort((a, b) => (b.lastBarTs - a.lastBarTs) || (b.peakRatioPct - a.peakRatioPct))
|
|
512
|
+
.slice(0, 120)
|
|
513
|
+
.map((ep) => {
|
|
514
|
+
const cleanReasons = ep.status === 'signal' ? [] : (ep.reasons || []);
|
|
515
|
+
const gapPct = round(Math.max(0, ep.thresholdPct - Math.abs(ep.peakExpectedPct)), 2);
|
|
516
|
+
const reasonText = ep.status === 'signal'
|
|
517
|
+
? 'Порог пройден — сигнал сформирован'
|
|
518
|
+
: ep.status === 'blocked'
|
|
519
|
+
? `Порог пройден, но вход закрыт: ${cleanReasons.join(' · ') || 'защитный фильтр'}`
|
|
520
|
+
: `Ниже порога на ${gapPct}%${cleanReasons.length ? ` (также активен фильтр: ${cleanReasons.join(' · ')})` : ''}`;
|
|
521
|
+
return { ...ep, reasons: cleanReasons, gapPct, reasonText };
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
508
525
|
function withNews(result, p) {
|
|
509
526
|
const nm = p.newsEnabled ? newsForSymbols(24) : null;
|
|
510
527
|
const nb = p.newsFilter ? blockingNews(6) : null;
|
|
511
528
|
const shortableMap = kvGet('tbankShortableMap')?.map || null;
|
|
512
529
|
const nowTs = Date.now();
|
|
530
|
+
const accFilters = getActiveAccountSignalFilters(nowTs);
|
|
513
531
|
const baseBuyThr = result.buyThresholdPct ?? result.thresholdPct ?? 0.40;
|
|
514
532
|
const baseShortThr = result.shortThresholdPct ?? result.thresholdPct ?? 0.50;
|
|
515
533
|
if (result.buyThresholdTier2Pct === undefined) result.buyThresholdTier2Pct = round(Math.max(baseBuyThr * TIER2_EDGE_MULT, 0.30), 3);
|
|
@@ -582,29 +600,124 @@ function withNews(result, p) {
|
|
|
582
600
|
const ratio = effThr > 0 ? Math.abs(Number(r.expectedPct) || 0) / effThr : 0;
|
|
583
601
|
r.thresholdRatioPct = Math.round(ratio * 100);
|
|
584
602
|
r.crossedThreshold = ratio >= 1;
|
|
603
|
+
|
|
604
|
+
// Учёт фильтров живого счёта (карантин после убыточных сделок, пауза после выхода, дневной блок 30099):
|
|
605
|
+
const qInfo = accFilters?.quarantinedMap?.get(r.symbol);
|
|
606
|
+
if (qInfo) {
|
|
607
|
+
r.quarantineInfo = qInfo;
|
|
608
|
+
if (isStockQuarantined(qInfo.cnt, qInfo.pnlPct / 100, ratio)) {
|
|
609
|
+
r.blockedByQuarantine = qInfo.reasonText;
|
|
610
|
+
r.signal = false;
|
|
611
|
+
r.shortSignal = false;
|
|
612
|
+
} else {
|
|
613
|
+
delete r.blockedByQuarantine;
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
delete r.quarantineInfo;
|
|
617
|
+
delete r.blockedByQuarantine;
|
|
618
|
+
}
|
|
619
|
+
const cdWhy = accFilters?.cooldownMap?.get(r.symbol);
|
|
620
|
+
if (cdWhy) {
|
|
621
|
+
r.blockedByCooldown = cdWhy;
|
|
622
|
+
r.signal = false;
|
|
623
|
+
r.shortSignal = false;
|
|
624
|
+
} else {
|
|
625
|
+
delete r.blockedByCooldown;
|
|
626
|
+
}
|
|
627
|
+
const wantSide = r.expectedPct < 0 ? 'short' : 'buy';
|
|
628
|
+
const dayBlk = accFilters?.dayBlockMap?.get(r.symbol);
|
|
629
|
+
if (dayBlk && dayBlk.side === wantSide) {
|
|
630
|
+
r.blockedByCorridor = 'Вход заблокирован до конца дня: цена у границы ценового коридора Мосбиржи (код 30099)';
|
|
631
|
+
if (wantSide === 'buy') r.signal = false;
|
|
632
|
+
else r.shortSignal = false;
|
|
633
|
+
} else {
|
|
634
|
+
delete r.blockedByCorridor;
|
|
635
|
+
}
|
|
585
636
|
}
|
|
586
|
-
// Если в сохранённом кэше список
|
|
637
|
+
// Если в сохранённом кэше список ещё не загружен или был обрезан, восстанавливаем его
|
|
587
638
|
const saved = !nearSignalsCache.episodesMap.size ? kvGet('nearSignalsLog') : null;
|
|
588
|
-
if (saved?.entries?.length &&
|
|
639
|
+
if (saved?.entries?.length && !nearSignalsCache.episodesMap.size) {
|
|
589
640
|
nearSignalsCache = { key: saved.key, scannedBarTs: saved.scannedBarTs || 0, episodesMap: new Map(saved.entries), list: [] };
|
|
590
641
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
642
|
+
// Живое пополнение журнала сегодняшними внутрисвечными сигналами и почти-сигналами (чтобы входы внутри 10-мин свечи сразу попадали в журнал)
|
|
643
|
+
if (nearSignalsCache.episodesMap.size > 0 && Array.isArray(result.rows)) {
|
|
644
|
+
const liveTs = result.liveTs || nowTs;
|
|
645
|
+
const slotBarTs = Math.floor(liveTs / BAR_MS) * BAR_MS;
|
|
646
|
+
const barMskIso = new Date(slotBarTs + MSK).toISOString();
|
|
647
|
+
const dayStr = barMskIso.slice(0, 10);
|
|
648
|
+
const timeStr = barMskIso.slice(11, 16);
|
|
649
|
+
const rankOf = (st) => (st === 'signal' ? 3 : st === 'blocked' ? 2 : 1);
|
|
650
|
+
let liveUpdated = false;
|
|
651
|
+
for (const row of result.rows) {
|
|
652
|
+
if (row.inIndex === false || (row.thresholdRatioPct || 0) < 75) continue;
|
|
653
|
+
const side = row.expectedPct < 0 ? 'short' : 'long';
|
|
654
|
+
const thr = side === 'short' ? row.shortThresholdPct : row.buyThresholdPct;
|
|
655
|
+
const crossed = Boolean(row.crossedThreshold);
|
|
656
|
+
const reasons = [];
|
|
657
|
+
if (side === 'short') {
|
|
658
|
+
if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
|
|
659
|
+
if (row.shortBlockedBySqueeze) reasons.push('защита от сквиза');
|
|
660
|
+
if (row.shortBlockedByTime) reasons.push('вне окна шорта по времени сессии');
|
|
661
|
+
if (row.shortBlockedByBroker) reasons.push('запрет шорта у брокера');
|
|
662
|
+
if (row.shortBlockedByDiv) reasons.push(`отсечка дивидендов (${row.shortBlockedByDiv})`);
|
|
663
|
+
if (row.shortBlockedByNews) reasons.push(`фильтр новостей (${row.shortBlockedByNews})`);
|
|
664
|
+
} else {
|
|
665
|
+
if (row.buyBlockedByDip) reasons.push('защита от обвала свечи');
|
|
666
|
+
if (row.buyBlockedByOverheat) reasons.push(`перегрев +${row.buyBlockedByOverheat}%`);
|
|
667
|
+
if (row.buyBlockedByDiv) reasons.push(`отсечка дивидендов (${row.buyBlockedByDiv})`);
|
|
668
|
+
if (row.buyBlockedByNews) reasons.push(`фильтр новостей (${row.buyBlockedByNews})`);
|
|
669
|
+
}
|
|
670
|
+
if (row.blockedByQuarantine) reasons.push(`карантин счёта (${row.quarantineInfo?.pnlPct ?? ''}%)`);
|
|
671
|
+
if (row.blockedByCooldown) reasons.push(row.blockedByCooldown);
|
|
672
|
+
if (row.blockedByCorridor) reasons.push('ценовой коридор 30099');
|
|
673
|
+
if (row.liquid === false) reasons.push('низкая ликвидность');
|
|
674
|
+
const isSignal = crossed && reasons.length === 0 && Boolean(side === 'long' ? row.signal : row.shortSignal);
|
|
675
|
+
const status = isSignal ? 'signal' : crossed ? 'blocked' : 'near';
|
|
676
|
+
const pctNow = row.thresholdRatioPct || 0;
|
|
677
|
+
const epKey = `${dayStr}:${row.symbol}:${side}`;
|
|
678
|
+
const prev = nearSignalsCache.episodesMap.get(epKey);
|
|
679
|
+
if (!prev) {
|
|
680
|
+
nearSignalsCache.episodesMap.set(epKey, {
|
|
681
|
+
day: dayStr, firstBar: timeStr, lastBar: timeStr, firstBarTs: slotBarTs, lastBarTs: slotBarTs,
|
|
682
|
+
peakBar: timeStr, barsCount: 1, symbol: row.symbol, name: row.name, side, price: row.price,
|
|
683
|
+
peakExpectedPct: row.expectedPct, thresholdPct: thr, peakRatioPct: pctNow,
|
|
684
|
+
status, reasons: status === 'signal' ? [] : reasons,
|
|
604
685
|
});
|
|
686
|
+
liveUpdated = true;
|
|
687
|
+
} else {
|
|
688
|
+
if (slotBarTs > prev.lastBarTs) {
|
|
689
|
+
prev.lastBar = timeStr;
|
|
690
|
+
prev.lastBarTs = slotBarTs;
|
|
691
|
+
prev.barsCount += 1;
|
|
692
|
+
liveUpdated = true;
|
|
693
|
+
}
|
|
694
|
+
if (rankOf(status) > rankOf(prev.status) || (rankOf(status) === rankOf(prev.status) && pctNow > prev.peakRatioPct)) {
|
|
695
|
+
prev.peakExpectedPct = row.expectedPct;
|
|
696
|
+
prev.thresholdPct = thr;
|
|
697
|
+
prev.peakRatioPct = Math.max(prev.peakRatioPct, pctNow);
|
|
698
|
+
prev.peakBar = timeStr;
|
|
699
|
+
prev.price = row.price;
|
|
700
|
+
prev.status = status;
|
|
701
|
+
prev.reasons = status === 'signal' ? [] : (reasons.length ? reasons : prev.reasons);
|
|
702
|
+
liveUpdated = true;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
605
705
|
}
|
|
606
|
-
|
|
607
|
-
|
|
706
|
+
if (liveUpdated) {
|
|
707
|
+
nearSignalsCache.list = formatNearSignalsList(nearSignalsCache.episodesMap);
|
|
708
|
+
kvSet('nearSignalsLog', {
|
|
709
|
+
key: nearSignalsCache.key,
|
|
710
|
+
scannedBarTs: nearSignalsCache.scannedBarTs,
|
|
711
|
+
entries: [...nearSignalsCache.episodesMap.entries()],
|
|
712
|
+
list: nearSignalsCache.list,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (nearSignalsCache.episodesMap.size && !nearSignalsCache.list.length) {
|
|
717
|
+
nearSignalsCache.list = formatNearSignalsList(nearSignalsCache.episodesMap);
|
|
718
|
+
}
|
|
719
|
+
if (nearSignalsCache.list.length || !Array.isArray(result.nearSignals) || !result.nearSignals.length) {
|
|
720
|
+
result.nearSignals = nearSignalsCache.list.length ? nearSignalsCache.list : (kvGet('nearSignalsLog')?.list || []);
|
|
608
721
|
}
|
|
609
722
|
return result;
|
|
610
723
|
}
|
|
@@ -624,7 +737,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
|
|
|
624
737
|
key,
|
|
625
738
|
scannedBarTs: saved.scannedBarTs,
|
|
626
739
|
episodesMap: new Map(saved.entries),
|
|
627
|
-
list:
|
|
740
|
+
list: [],
|
|
628
741
|
};
|
|
629
742
|
} else {
|
|
630
743
|
nearSignalsCache = { key, scannedBarTs: 0, episodesMap: new Map(), list: [] };
|
|
@@ -695,7 +808,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
|
|
|
695
808
|
thresholdPct: thr,
|
|
696
809
|
peakRatioPct: Math.round(ratio * 100),
|
|
697
810
|
status,
|
|
698
|
-
reasons,
|
|
811
|
+
reasons: status === 'signal' ? [] : reasons,
|
|
699
812
|
});
|
|
700
813
|
} else {
|
|
701
814
|
prev.lastBar = timeStr;
|
|
@@ -709,14 +822,14 @@ async function updateNearSignalsJournal(ds, p, last, key) {
|
|
|
709
822
|
prev.peakBar = timeStr;
|
|
710
823
|
prev.price = row.price;
|
|
711
824
|
prev.status = status;
|
|
712
|
-
|
|
825
|
+
prev.reasons = status === 'signal' ? [] : (reasons.length ? reasons : prev.reasons);
|
|
713
826
|
} else if (pctNow > prev.peakRatioPct) {
|
|
714
827
|
prev.peakExpectedPct = row.expectedPct;
|
|
715
828
|
prev.thresholdPct = thr;
|
|
716
829
|
prev.peakRatioPct = pctNow;
|
|
717
830
|
prev.peakBar = timeStr;
|
|
718
831
|
}
|
|
719
|
-
if (reasons.length && !prev.reasons?.length) prev.reasons = reasons;
|
|
832
|
+
if (prev.status !== 'signal' && reasons.length && !prev.reasons?.length) prev.reasons = reasons;
|
|
720
833
|
}
|
|
721
834
|
}
|
|
722
835
|
nearSignalsCache.scannedBarTs = barTs;
|
|
@@ -724,18 +837,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
|
|
|
724
837
|
}
|
|
725
838
|
|
|
726
839
|
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
|
-
});
|
|
840
|
+
const list = formatNearSignalsList(nearSignalsCache.episodesMap);
|
|
739
841
|
nearSignalsCache.list = list;
|
|
740
842
|
kvSet('nearSignalsLog', {
|
|
741
843
|
key,
|
|
@@ -838,10 +940,15 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
|
|
|
838
940
|
inflight.delete(key);
|
|
839
941
|
inflightStartedAt.delete(key);
|
|
840
942
|
}
|
|
943
|
+
const mskDate = new Date(now + MSK);
|
|
944
|
+
const mskDow = mskDate.getUTCDay();
|
|
945
|
+
const mskMins = mskDate.getUTCHours() * 60 + mskDate.getUTCMinutes();
|
|
946
|
+
const isTradingWindow = mskDow >= 1 && mskDow <= 5 && mskMins >= 7 * 60 && mskMins <= 23 * 60 + 55;
|
|
841
947
|
const latestClosedSlotTs = Math.floor((now - BAR_MS) / BAR_MS) * BAR_MS;
|
|
842
948
|
const curBarTs = baseState.ds?.timeline?.[baseState.last] || 0;
|
|
843
|
-
const barStale = curBarTs > 0 && curBarTs < latestClosedSlotTs && (now - baseState.dsLoadedAt >= 60000);
|
|
844
|
-
|
|
949
|
+
const barStale = isTradingWindow && curBarTs > 0 && curBarTs < latestClosedSlotTs && (now - baseState.dsLoadedAt >= 60000);
|
|
950
|
+
const dsTtlMs = isTradingWindow ? 4 * 60000 : 30 * 60000;
|
|
951
|
+
if ((now - baseState.dsLoadedAt >= dsTtlMs || barStale) && !inflight.has(key)) {
|
|
845
952
|
setImmediate(() => {
|
|
846
953
|
refreshDatasetStateInBackground(p, key).catch(() => {});
|
|
847
954
|
});
|
package/server/model/live.js
CHANGED
|
@@ -7,6 +7,7 @@ import { latestBarTs } from '../data/moex.js';
|
|
|
7
7
|
import { loadDataset, barsFromHours, BAR_MS, BAR_INTERVAL } from './dataset.js';
|
|
8
8
|
import { predictAt, lastClosedIndex } from './forecast.js';
|
|
9
9
|
import { ENTRY_EDGE_MULT, symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isRegimeShocked } from './edge.js';
|
|
10
|
+
import { STRATEGY_POLICY } from './policy.js';
|
|
10
11
|
import { computeReport, downsample } from './metrics.js';
|
|
11
12
|
import { MODELS } from './models.js';
|
|
12
13
|
import { blockingNews } from '../news/index.js';
|
|
@@ -395,7 +396,8 @@ class LiveEngine {
|
|
|
395
396
|
const autoAdapt = p.modelAutoAdapt !== false;
|
|
396
397
|
const effHorizon = autoAdapt ? 2.5 : p.modelHorizon;
|
|
397
398
|
const H = barsFromHours(effHorizon);
|
|
398
|
-
const
|
|
399
|
+
const holdBars = autoAdapt ? (STRATEGY_POLICY.holdBars ?? H) : H;
|
|
400
|
+
const stepBars = autoAdapt ? Math.max(3, Math.floor(holdBars / 5)) : holdBars;
|
|
399
401
|
if (s.barsSinceRebalance >= stepBars) {
|
|
400
402
|
await new Promise((r) => setImmediate(r));
|
|
401
403
|
const r = await predictAt(ds, p, idx);
|
|
@@ -415,9 +417,8 @@ class LiveEngine {
|
|
|
415
417
|
};
|
|
416
418
|
const exitRatio = autoAdapt ? 0.95 : Number(p.modelExitRatio ?? 0.5);
|
|
417
419
|
const mktDown = p.modelMarketFilter === 'ma' && r.marketDown;
|
|
418
|
-
const
|
|
420
|
+
const stMap = new Map();
|
|
419
421
|
if (autoAdapt && s.trades.length >= 1) {
|
|
420
|
-
const stMap = new Map();
|
|
421
422
|
for (const t of s.trades) {
|
|
422
423
|
// затухание прошлых штрафов с возрастом сделки (~15 торговых дней полураспада)
|
|
423
424
|
const ageSteps = Math.max(0, (ts - (t.exitTs || ts)) / (stepBars * BAR_MS));
|
|
@@ -427,7 +428,6 @@ class LiveEngine {
|
|
|
427
428
|
cur.cnt += wDecay;
|
|
428
429
|
stMap.set(t.symbol, cur);
|
|
429
430
|
}
|
|
430
|
-
for (const [sym, st] of stMap) if (isStockQuarantined(st.cnt, st.pnl)) quarantined.add(sym);
|
|
431
431
|
}
|
|
432
432
|
for (const [sym, h] of s.held) {
|
|
433
433
|
if (autoAdapt && shouldExitBeforeNight(ts, h.entryTs, stepBars, BAR_INTERVAL)) {
|
|
@@ -444,7 +444,7 @@ class LiveEngine {
|
|
|
444
444
|
const req = r.threshold * (autoAdapt && heldBars <= 2 * stepBars ? 0.35 : exitRatio) * (x?.edgeMult || symbolEdgeMult(sym));
|
|
445
445
|
if (!(x && x.liquid && (h.side === 'short' ? x.expected < -req : x.expected > req))) closePos(sym);
|
|
446
446
|
}
|
|
447
|
-
const timeOk = !autoAdapt || canEnterIntraday(ts,
|
|
447
|
+
const timeOk = !autoAdapt || canEnterIntraday(ts, holdBars, BAR_INTERVAL);
|
|
448
448
|
const sideHeld = (side) => [...s.held].filter(([sym, h]) => h.side === side && !closing.has(sym));
|
|
449
449
|
const nb = p.newsFilter ? blockingNews(6) : null;
|
|
450
450
|
const take = (list, side, n) => {
|
|
@@ -452,7 +452,10 @@ class LiveEngine {
|
|
|
452
452
|
const out = [];
|
|
453
453
|
for (const x of list) {
|
|
454
454
|
if (out.length >= lim) break;
|
|
455
|
-
|
|
455
|
+
const qSt = stMap.get(x.symbol);
|
|
456
|
+
const thrPct = side === 'buy' ? (x.buyThresholdPct || r.buyThresholdPct || r.thresholdPct) : (x.shortThresholdPct || r.shortThresholdPct || r.thresholdPct);
|
|
457
|
+
const edgeRatio = thrPct > 0 ? Math.abs(x.expectedPct || 0) / thrPct : 0;
|
|
458
|
+
if (qSt && isStockQuarantined(qSt.cnt, qSt.pnl, edgeRatio)) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: 'Пропущена: в карантине после серии убыточных сделок' }); continue; }
|
|
456
459
|
const b = nb?.get(x.symbol)?.[side === 'buy' ? 'bad' : 'good'];
|
|
457
460
|
if (b) { step.decisions.push({ action: 'skip', symbol: x.symbol, text: `Пропущена: ${side === 'buy' ? 'плохая' : 'хорошая'} новость — ${b.gist}` }); continue; }
|
|
458
461
|
out.push(x);
|
|
@@ -461,7 +464,7 @@ class LiveEngine {
|
|
|
461
464
|
};
|
|
462
465
|
const open = (x) => !s.held.has(x.symbol) && !closing.has(x.symbol);
|
|
463
466
|
const candL = (mktDown || !timeOk) ? [] : r.rows.filter((x) => x.signal && open(x)).sort((a, b) => b.expected - a.expected);
|
|
464
|
-
const candS = (dir === 'long' || !timeOk) ? [] : r.rows.filter((x) => x.shortSignal && open(x) && (!autoAdapt || canEnterIntraday(ts,
|
|
467
|
+
const candS = (dir === 'long' || !timeOk) ? [] : r.rows.filter((x) => x.shortSignal && open(x) && (!autoAdapt || canEnterIntraday(ts, holdBars, BAR_INTERVAL, 'short', x.symbol))).sort((a, b) => a.expected - b.expected);
|
|
465
468
|
const usedNow = [...s.held.entries()].filter(([sym]) => !closing.has(sym)).reduce((sum, [, h]) => sum + h.alloc, 0);
|
|
466
469
|
if (autoAdapt && usedNow > s.equity * 0.65 && (candL.length > 0 || candS.length > 0)) {
|
|
467
470
|
const bestNewEdge = Math.max(candL.length ? candL[0].expected : 0, candS.length ? Math.abs(candS[0].expected) : 0);
|
package/server/model/policy.js
CHANGED
|
@@ -38,6 +38,12 @@ export const STRATEGY_POLICY = Object.freeze({
|
|
|
38
38
|
// в спокойный день (множитель до 0.75×) не вытряхивает позицию раньше времени, в турбулентный день (до 1.25×) быстрее фиксирует выход при затухании прогноза.
|
|
39
39
|
// Проверка 09.11.2026 (06.2024–11.2026, плечо 3×): CAGR 58,6%/52,1% по половинам (весь 55,1%, PF 1,97, DD 6,4%) против 57,5%/48,1% (весь 52,6%, PF 1,89, DD 6,8%).
|
|
40
40
|
exitVolClamp: Object.freeze([0.75, 1.25]),
|
|
41
|
+
// глубина сопровождения позиции и вечерней отсечки входов в 10-минутных свечах (13 свечей = 130 минут = 2 ч 10 м),
|
|
42
|
+
// при сохранении базового мульти-горизонта обучения модели (H = 15 свечей = 2,5 ч) и калибровки порога входа √H.
|
|
43
|
+
// Проверка 12.11.2026 (06.2024–11.2026, 59 051 свеча): улучшает итог и снижает риск на обеих половинах истории при любом плече
|
|
44
|
+
// (при плече 2×: CAGR 54,5%/40,6% по половинам, весь 47,2%, PF 1,87, DD 3,4%, худшая сделка −3,54% против 51,9%/40,3%, весь 45,8%, PF 1,82, DD 3,6%, худшая −4,39%;
|
|
45
|
+
// при плече 3×: CAGR 65,9%/55,2% по половинам, весь 60,3%, PF 2,03, DD 6,0% против 61,9%/54,6%, весь 58,0%, PF 1,97, DD 6,3%).
|
|
46
|
+
holdBars: 13,
|
|
41
47
|
});
|
|
42
48
|
|
|
43
49
|
// ---------- окна начала и конца сессий (широкий спред, гэпы, аукционы) ----------
|
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]);
|
|
@@ -36,8 +36,9 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
36
36
|
const N = tokens.length;
|
|
37
37
|
const H = wf.H;
|
|
38
38
|
const sqrtH = Math.sqrt(H);
|
|
39
|
+
const holdBars = policy.holdBars ?? H;
|
|
39
40
|
const bpd = Math.max(1, Math.round(ds.barsPerDay || 94));
|
|
40
|
-
const stepBars = plan === 'owner' || plan === 'vip' ? Math.max(3, Math.floor(
|
|
41
|
+
const stepBars = plan === 'owner' || plan === 'vip' ? Math.max(3, Math.floor(holdBars / 5)) : holdBars;
|
|
41
42
|
const K = policy.K ?? 4;
|
|
42
43
|
const dir = p.modelDirection === 'long' ? 'long' : 'longshort';
|
|
43
44
|
const realCost = (p.commissionPct + p.slippagePct) / 100;
|
|
@@ -48,7 +49,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
48
49
|
const commission = p.commissionPct / 100;
|
|
49
50
|
const cooldownBars = Math.round((policy.cooldownMin ?? 30) / BAR_INTERVAL);
|
|
50
51
|
const stopCooldownBars = Math.round((policy.stopCooldownMin ?? 60) / BAR_INTERVAL);
|
|
51
|
-
const horizonMin = Math.max(90,
|
|
52
|
+
const horizonMin = Math.max(90, holdBars * BAR_INTERVAL);
|
|
52
53
|
const slotW = policy.slotPct ?? (plan === 'owner' ? 0.45 : plan === 'vip' ? 0.40 : plan === 'pro' ? Math.min(0.28, 1.15 / 8) : Math.min(0.24, 1.05 / 5));
|
|
53
54
|
const convMax = plan === 'owner' ? 1.65 : plan === 'vip' ? 1.30 : 1;
|
|
54
55
|
const convK = plan === 'owner' ? 0.30 : 0.25;
|
|
@@ -275,22 +276,24 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
275
276
|
dayDdBreachCount = 0;
|
|
276
277
|
}
|
|
277
278
|
}
|
|
278
|
-
const timeOk = !dayHalted && canEnterIntraday(decTs,
|
|
279
|
+
const timeOk = !dayHalted && canEnterIntraday(decTs, holdBars, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
|
|
279
280
|
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));
|
|
281
|
+
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
282
|
const candL = [];
|
|
282
283
|
const candS = [];
|
|
283
284
|
for (let i = 0; i < N; i++) {
|
|
284
285
|
const e = edge(i);
|
|
285
|
-
|
|
286
|
-
|
|
286
|
+
const thrL = Math.max(needL * mult[i] * tierScale(i), minEdgeL);
|
|
287
|
+
const thrS = Math.max(needS * mult[i] * tierScale(i), minEdgeS);
|
|
288
|
+
if (e > thrL) {
|
|
289
|
+
if (!baseOk(i, e / thrL)) continue;
|
|
287
290
|
const fl = flags(i);
|
|
288
291
|
if (fl.dipBlock || fl.overheat || divRule(i, t)?.blockLong) continue;
|
|
289
292
|
candL.push(i);
|
|
290
|
-
} else if (dir !== 'long' && e < -
|
|
291
|
-
if (!baseOk(i)) continue;
|
|
293
|
+
} else if (dir !== 'long' && e < -thrS) {
|
|
294
|
+
if (!baseOk(i, (-e) / thrS)) continue;
|
|
292
295
|
if (shortable && !shortable.has(tokens[i].symbol)) continue;
|
|
293
|
-
if (!canEnterIntraday(decTs,
|
|
296
|
+
if (!canEnterIntraday(decTs, holdBars, BAR_INTERVAL, 'short', tokens[i].symbol)) continue;
|
|
294
297
|
const fl = flags(i);
|
|
295
298
|
if (fl.spikeBlock || divRule(i, t)?.blockShort) continue;
|
|
296
299
|
candS.push(i);
|
|
@@ -333,7 +336,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
333
336
|
const sigmaBarPct = sg[i] * 100;
|
|
334
337
|
const ctx = dayContext(ds, i, t, sigmaBarPct);
|
|
335
338
|
if (counterTrendBlocked(side, ctx, policy)) { stats.blockedCT++; continue; }
|
|
336
|
-
const stopPct = protectiveStopPct(side, { sigmaBarPct, realizedBarPct: ctx.realizedBarPct, horizonBars:
|
|
339
|
+
const stopPct = protectiveStopPct(side, { sigmaBarPct, realizedBarPct: ctx.realizedBarPct, horizonBars: holdBars }, policy);
|
|
337
340
|
const conv = Math.min(convMax, Math.max(0.85, 0.80 + convK * (Math.abs(e) / need)));
|
|
338
341
|
const tm = isT1(i) ? (policy.sizeMultT1 ?? lev) : (policy.sizeMultT2 ?? 1);
|
|
339
342
|
if (!(tm > 0)) continue;
|
|
@@ -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),
|