neuroalpha 0.6.2 → 0.6.4
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 +6 -0
- package/server/brokers/index.js +63 -24
- package/server/model/dataset.js +8 -2
- package/server/model/forecast.js +106 -60
- package/server/model/policy.js +2 -2
- package/server/model/simulator.js +40 -16
- package/server/model/walkforward.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -1076,6 +1076,10 @@ async function loadForecast(force, silent = false) {
|
|
|
1076
1076
|
reasons.push(`Фильтр новостей (сильный позитив): ${r.shortBlockedByNews}`);
|
|
1077
1077
|
if (!label) label = '⛔ новости';
|
|
1078
1078
|
}
|
|
1079
|
+
if (r.shortBlockedByTime) {
|
|
1080
|
+
reasons.push('Вне внутридневного окна открытия новых шортов (утренний старт или конец сессии)');
|
|
1081
|
+
if (!label) label = '⛔ вне окна';
|
|
1082
|
+
}
|
|
1079
1083
|
} else {
|
|
1080
1084
|
const dip = Boolean(r.buyBlockedByDip || (r.expectedPct > 0 && (r.lastMovePct ?? 0) < -4.5));
|
|
1081
1085
|
if (dip) {
|
|
@@ -4226,6 +4230,8 @@ function buildStockModalHeaderHtml(d) {
|
|
|
4226
4230
|
? '<span class="tag blocked" title="Аварийная защита от сквиза: бумага взлетела на текущей свече (> +4,5%)">⛔ защита от сквиза</span>'
|
|
4227
4231
|
: fc?.shortBlockedByBroker
|
|
4228
4232
|
? '<span class="tag blocked" title="Брокер запрещает короткие продажи по этой бумаге">⛔ шорт запрещён брокером</span>'
|
|
4233
|
+
: fc?.shortBlockedByTime
|
|
4234
|
+
? '<span class="tag blocked" title="Вне внутридневного окна открытия новых шортов">⛔ вне окна шорта</span>'
|
|
4229
4235
|
: fc?.buyBlockedByDip
|
|
4230
4236
|
? '<span class="tag blocked" title="Аварийная защита от обвала: бумага падает на текущей свече (< −4,5%)">⛔ защита от провала свечи</span>'
|
|
4231
4237
|
: fc?.buyBlockedByOverheat
|
package/server/brokers/index.js
CHANGED
|
@@ -1706,11 +1706,20 @@ const entryImpulseWatch = new Map(); // `${accId}:${symbol}:${side}` -> { firstT
|
|
|
1706
1706
|
// Фильтр затухания минутного импульса перед входом (Impulse Exhaustion):
|
|
1707
1707
|
// не даёт продавать в шорт посередине вертикально растущей минутной свечи (или покупать в полёт падающего ножа),
|
|
1708
1708
|
// дожидаясь остановки разгона и подтверждённого отката от пика/дна (с шагом проверки 20 секунд; без принудительного входа по таймауту)
|
|
1709
|
+
// Порог удержания уже взятого на прицел сигнала в entryImpulseWatch: пока ждём минутного отката,
|
|
1710
|
+
// само ожидание возврата к среднему неизбежно немного снижается — не теряем сигнал, пока он держится >= 90% порога (до 6 минут)
|
|
1711
|
+
const IMPULSE_WATCH_HOLD_RATIO = 0.90;
|
|
1712
|
+
const IMPULSE_WATCH_TTL_MS = 6 * 60000;
|
|
1713
|
+
const isOnImpulseWatch = (accId, sym, side, nowTs = Date.now()) => {
|
|
1714
|
+
const w = entryImpulseWatch.get(`${accId}:${sym}:${side}`);
|
|
1715
|
+
return Boolean(w && nowTs - w.firstTs <= IMPULSE_WATCH_TTL_MS && nowTs - w.lastTs <= IMPULSE_WATCH_TTL_MS);
|
|
1716
|
+
};
|
|
1717
|
+
|
|
1709
1718
|
async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
|
|
1710
|
-
if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
|
|
1711
1719
|
for (const [k, v] of entryImpulseWatch) {
|
|
1712
|
-
if (nowTs - v.lastTs >
|
|
1720
|
+
if (nowTs - v.lastTs > IMPULSE_WATCH_TTL_MS || nowTs - v.firstTs > IMPULSE_WATCH_TTL_MS) entryImpulseWatch.delete(k);
|
|
1713
1721
|
}
|
|
1722
|
+
if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
|
|
1714
1723
|
const ready = [];
|
|
1715
1724
|
const deferred = [];
|
|
1716
1725
|
for (const r of cands) {
|
|
@@ -1750,9 +1759,12 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
|
|
|
1750
1759
|
const pullbackPct = recentHigh > 0 ? ((recentHigh - curPx) / recentHigh) * 100 : 0;
|
|
1751
1760
|
const pullbackTicks = recentHigh > 0 ? (recentHigh - curPx) / step : 0;
|
|
1752
1761
|
const barTurnedDown = Boolean(last1m && prev1m && last1m.close <= last1m.open && last1m.high <= prev1m.high + step * 0.5);
|
|
1762
|
+
// Если внутри самой минутной свечи цена уже взлетела до recentHigh и откатилась от пика на >= 1,4× порога отката (и >= 0,55%),
|
|
1763
|
+
// разворот с вершины уже состоялся — не ждём следующей минутной свечи!
|
|
1764
|
+
const intrabarReversalDown = Boolean(last1m && pullbackPct >= Math.max(0.55, minPullbackPct * 1.4) && pullbackTicks >= 4 && curPx <= last1m.high - step * 3);
|
|
1753
1765
|
const peakedAndPulledBack = pullbackPct >= minPullbackPct && pullbackTicks >= 2 && (!last1m || last1m.close <= last1m.open + step * 0.5);
|
|
1754
1766
|
const tickedDownFromPrev = Boolean(!isStrongSurge && prevW && curPx <= prevW.lastPx - step && pullbackTicks >= 2 && pullbackPct >= 0.12);
|
|
1755
|
-
const readyShort = isStrongSurge ? (peakedAndPulledBack && barTurnedDown) : (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev);
|
|
1767
|
+
const readyShort = isStrongSurge ? (peakedAndPulledBack && (barTurnedDown || intrabarReversalDown)) : (peakedAndPulledBack || barTurnedDown || tickedDownFromPrev);
|
|
1756
1768
|
if (readyShort) {
|
|
1757
1769
|
entryImpulseWatch.delete(wKey);
|
|
1758
1770
|
r.price = curPx;
|
|
@@ -1765,10 +1777,11 @@ async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.n
|
|
|
1765
1777
|
const recentLow = Math.min(prevW?.extremePx || Infinity, min1m);
|
|
1766
1778
|
const bouncePct = Number.isFinite(recentLow) && recentLow > 0 ? ((curPx - recentLow) / recentLow) * 100 : 0;
|
|
1767
1779
|
const bounceTicks = Number.isFinite(recentLow) && recentLow > 0 ? (curPx - recentLow) / step : 0;
|
|
1768
|
-
const barTurnedUp = Boolean(last1m && prev1m && last1m.
|
|
1780
|
+
const barTurnedUp = Boolean(last1m && prev1m && last1m.low >= prev1m.low - step * 0.5 && last1m.close >= last1m.open);
|
|
1781
|
+
const intrabarReversalUp = Boolean(last1m && bouncePct >= Math.max(0.55, minPullbackPct * 1.4) && bounceTicks >= 4 && curPx >= last1m.low + step * 3);
|
|
1769
1782
|
const bottomedAndBounced = bouncePct >= minPullbackPct && bounceTicks >= 2 && (!last1m || last1m.close >= last1m.open - step * 0.5);
|
|
1770
1783
|
const tickedUpFromPrev = Boolean(!isStrongSurge && prevW && curPx >= prevW.lastPx + step && bounceTicks >= 2 && bouncePct >= 0.12);
|
|
1771
|
-
const readyLong = isStrongSurge ? (bottomedAndBounced && barTurnedUp) : (bottomedAndBounced || barTurnedUp || tickedUpFromPrev);
|
|
1784
|
+
const readyLong = isStrongSurge ? (bottomedAndBounced && (barTurnedUp || intrabarReversalUp)) : (bottomedAndBounced || barTurnedUp || tickedUpFromPrev);
|
|
1772
1785
|
if (readyLong) {
|
|
1773
1786
|
entryImpulseWatch.delete(wKey);
|
|
1774
1787
|
r.price = curPx;
|
|
@@ -1813,7 +1826,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1813
1826
|
let list = loadRawAccounts();
|
|
1814
1827
|
let acc = list.find((x) => x.id === id);
|
|
1815
1828
|
if (!acc) throw new Error('Счёт не найден');
|
|
1816
|
-
|
|
1829
|
+
let exitsOnly = isDailyStopHalt(acc);
|
|
1817
1830
|
if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
|
|
1818
1831
|
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
1819
1832
|
if (onlyEntries && (edgeWarn || exitsOnly)) {
|
|
@@ -1835,6 +1848,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1835
1848
|
list = loadRawAccounts();
|
|
1836
1849
|
acc = list.find((x) => x.id === id) || acc;
|
|
1837
1850
|
}
|
|
1851
|
+
exitsOnly = isDailyStopHalt(acc);
|
|
1852
|
+
if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
|
|
1853
|
+
if (onlyEntries && exitsOnly) return brokersState();
|
|
1838
1854
|
const params = normalizeParams(getSettings());
|
|
1839
1855
|
const [fc, rawSpecs, shortableMap] = await withWallTimeout(Promise.all([
|
|
1840
1856
|
forecastNow(params),
|
|
@@ -1972,10 +1988,14 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1972
1988
|
const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
|
|
1973
1989
|
const divBlocked = [];
|
|
1974
1990
|
const entryTimeOk = timeOk && !edgeWarn && !exitsOnly;
|
|
1975
|
-
const
|
|
1991
|
+
const canBuyRow = (r) => (r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol))
|
|
1992
|
+
|| (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);
|
|
1993
|
+
const canShortRow = (r) => (r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol))
|
|
1994
|
+
|| (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);
|
|
1995
|
+
const buyCands = !entryTimeOk ? [] : fc.rows.filter((r) => canBuyRow(r) && !isInCooldown(r.symbol) && !isDayBlocked(r.symbol, 'buy') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
|
|
1976
1996
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
1977
1997
|
.sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
1978
|
-
const allShortSignals = (rawDir !== 'long' && entryTimeOk) ? fc.rows.filter((r) =>
|
|
1998
|
+
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') && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
|
|
1979
1999
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
1980
2000
|
.sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
1981
2001
|
const nonShortableSkipped = [];
|
|
@@ -2849,23 +2869,25 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2849
2869
|
if (!acc) throw new Error('Счёт не найден');
|
|
2850
2870
|
if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
|
|
2851
2871
|
const gate = tradingGate();
|
|
2852
|
-
if (!gate.allowNew) throw new Error(gate.reason);
|
|
2872
|
+
if (!gate.allowNew && !gate.allowProtective) throw new Error(gate.reason);
|
|
2853
2873
|
const batch = acc.pendingBatch;
|
|
2854
2874
|
if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
|
|
2855
2875
|
if (batch.status === 'executed' || batch.status === 'executing') return brokersState();
|
|
2856
|
-
if (acc.haltedReason && !
|
|
2857
|
-
// Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна
|
|
2876
|
+
if (acc.haltedReason && !isDailyStopHalt(acc)) throw new Error(acc.haltedReason);
|
|
2877
|
+
// Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна входов, при дневном стопе или в окне начала сессии не отправляем (выходы сохраняются!)
|
|
2858
2878
|
const execCheckTs = Date.now();
|
|
2859
2879
|
const staleWhy = (() => {
|
|
2880
|
+
if (!gate.allowNew) return gate.reason;
|
|
2881
|
+
if (isDailyStopHalt(acc)) return acc.haltedReason;
|
|
2860
2882
|
if (execCheckTs - (batch.createdAt || 0) > 10 * 60000) return `сигнал устарел (пакет сформирован ${Math.round((execCheckTs - (batch.createdAt || 0)) / 60000)} мин назад)`;
|
|
2861
2883
|
if (!canEnterIntraday(execCheckTs, barsFromHours(2.5), BAR_INTERVAL)) return 'окно новых входов уже закрыто';
|
|
2862
2884
|
if (acc.skipEdgeMinutes && sessionEdgeWarning(execCheckTs)) return 'начало/конец сессии';
|
|
2863
|
-
if (isDailyStopHalt(acc)) return acc.haltedReason;
|
|
2864
2885
|
return null;
|
|
2865
2886
|
})();
|
|
2866
2887
|
if (staleWhy && batch.orders.some((o) => !isExitOrder(o) && o.symbol !== 'LQDT')) {
|
|
2867
|
-
const
|
|
2868
|
-
|
|
2888
|
+
const keepLqdt = !isDailyStopHalt(acc) && gate.allowNew;
|
|
2889
|
+
const dropped = batch.orders.filter((o) => !isExitOrder(o) && !(keepLqdt && o.symbol === 'LQDT'));
|
|
2890
|
+
batch.orders = batch.orders.filter((o) => isExitOrder(o) || (keepLqdt && o.symbol === 'LQDT'));
|
|
2869
2891
|
logBrokerEvent(acc, 'order_skip', `входы не отправлены (${staleWhy}): ${dropped.map((o) => `${o.action} ${o.symbol}`).join(', ')}`);
|
|
2870
2892
|
if (!batch.orders.length) {
|
|
2871
2893
|
batch.status = 'expired';
|
|
@@ -2875,6 +2897,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2875
2897
|
throw new Error(`Входы не отправлены: ${staleWhy}`);
|
|
2876
2898
|
}
|
|
2877
2899
|
}
|
|
2900
|
+
if (acc.haltedReason && !(isDailyStopHalt(acc) && batch.orders.every(isExitOrder))) throw new Error(acc.haltedReason);
|
|
2878
2901
|
// Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
|
|
2879
2902
|
// ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
|
|
2880
2903
|
for (const ord of batch.orders) {
|
|
@@ -2927,8 +2950,12 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2927
2950
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
|
|
2928
2951
|
newlySentOrders.push(ord);
|
|
2929
2952
|
if (ord.action === 'buy' || ord.action === 'short') {
|
|
2930
|
-
acc.entryTsBySymbol[ord.symbol] = execNowTs;
|
|
2931
|
-
|
|
2953
|
+
if (!acc.entryTsBySymbol[ord.symbol]) acc.entryTsBySymbol[ord.symbol] = execNowTs;
|
|
2954
|
+
const ordPx = Number(ord.limitPrice || ord.refPrice) || 0;
|
|
2955
|
+
const prevPeak = Number(acc.peakPriceBySymbol[ord.symbol]) || 0;
|
|
2956
|
+
acc.peakPriceBySymbol[ord.symbol] = prevPeak > 0 && ordPx > 0
|
|
2957
|
+
? (ord.action === 'buy' ? Math.max(prevPeak, ordPx) : Math.min(prevPeak, ordPx))
|
|
2958
|
+
: (ordPx || prevPeak);
|
|
2932
2959
|
delete acc.exitCooldownBySymbol[ord.symbol];
|
|
2933
2960
|
delete acc.entryFailCooldownBySymbol[ord.symbol];
|
|
2934
2961
|
delete acc.entryFailReasonBySymbol[ord.symbol];
|
|
@@ -3231,7 +3258,8 @@ async function tickBrokerSchedule() {
|
|
|
3231
3258
|
const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
|
|
3232
3259
|
const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
|
|
3233
3260
|
const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
|
|
3234
|
-
|
|
3261
|
+
const marketActiveNow = Boolean(latest && nowTs - latest <= 25 * 60000);
|
|
3262
|
+
if (marketActiveNow && acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && freeCashRub >= 15000 && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
3235
3263
|
await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
|
|
3236
3264
|
}
|
|
3237
3265
|
}
|
|
@@ -3355,6 +3383,7 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3355
3383
|
const headers = tbankHeaders(acc);
|
|
3356
3384
|
const want = new Map();
|
|
3357
3385
|
const activeOrders = acc.lastSync?.activeOrders || [];
|
|
3386
|
+
const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
|
|
3358
3387
|
for (const pos of acc.lastSync.positions || []) {
|
|
3359
3388
|
const sym = pos.symbol;
|
|
3360
3389
|
if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
|
|
@@ -3369,7 +3398,8 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3369
3398
|
const ordAgeMs = activeOpenOrd ? (Date.now() - (Date.parse(activeOpenOrd.orderDate || 0) || Number(acc.entryTsBySymbol?.[sym]) || 0)) : Infinity;
|
|
3370
3399
|
const isFillingNow = Boolean(activeOpenOrd && ordAgeMs < 60000);
|
|
3371
3400
|
const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
|
|
3372
|
-
const
|
|
3401
|
+
const lastHistEntryOp = histOps.find((op) => op.symbol === sym && op.kind === wantOpenDir && op.date > 0);
|
|
3402
|
+
const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || Number(pos.price) || 0);
|
|
3373
3403
|
if (lots < 1 || avgPx <= 0) continue;
|
|
3374
3404
|
const side = aQty > 0 ? 'long' : 'short';
|
|
3375
3405
|
const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
|
|
@@ -3435,16 +3465,19 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3435
3465
|
}
|
|
3436
3466
|
}
|
|
3437
3467
|
|
|
3438
|
-
// Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые
|
|
3468
|
+
// Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые 25 секунд между шагами расписания
|
|
3439
3469
|
async function checkIntradayProfitLock(accId) {
|
|
3440
|
-
const list = loadRawAccounts();
|
|
3441
|
-
const acc = list.find((x) => x.id === accId);
|
|
3442
|
-
if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
|
|
3443
3470
|
if (!tradingGate().allowProtective) return;
|
|
3444
3471
|
const nowMsk = new Date(Date.now() + MSK);
|
|
3445
3472
|
const dow = nowMsk.getUTCDay();
|
|
3446
3473
|
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
3447
3474
|
if (dow === 0 || dow === 6 || mins < 6 * 60 + 55 || mins > 23 * 60 + 48) return;
|
|
3475
|
+
const lockToken = acquireAccountLock(accId);
|
|
3476
|
+
if (!lockToken) return;
|
|
3477
|
+
try {
|
|
3478
|
+
const list = loadRawAccounts();
|
|
3479
|
+
const acc = list.find((x) => x.id === accId);
|
|
3480
|
+
if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
|
|
3448
3481
|
// В окнах начала/конца сессии (широкий спред) фиксация прибыли не срабатывает на шуме стакана, а стопы работают всегда
|
|
3449
3482
|
const edgeNow = Boolean(acc.skipEdgeMinutes && sessionEdgeWarning());
|
|
3450
3483
|
|
|
@@ -3561,7 +3594,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3561
3594
|
logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
|
|
3562
3595
|
}
|
|
3563
3596
|
}
|
|
3564
|
-
|
|
3597
|
+
const stopBatchObj = {
|
|
3565
3598
|
...(acc.pendingBatch || {}),
|
|
3566
3599
|
id: `batch_stop_${execNowTs}`,
|
|
3567
3600
|
createdAt: execNowTs,
|
|
@@ -3571,13 +3604,19 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3571
3604
|
status: results.every((r) => r.ok) ? 'executed' : 'partial',
|
|
3572
3605
|
results,
|
|
3573
3606
|
};
|
|
3607
|
+
if (acc.pendingBatch?.status !== 'pending' && acc.pendingBatch?.status !== 'executing') {
|
|
3608
|
+
acc.pendingBatch = stopBatchObj;
|
|
3609
|
+
}
|
|
3574
3610
|
saveRawAccounts(list);
|
|
3575
3611
|
if (sentLockOrders.length > 0) {
|
|
3576
|
-
notifyBrokerBatch(currentUserId(), acc, { ...
|
|
3612
|
+
notifyBrokerBatch(currentUserId(), acc, { ...stopBatchObj, orders: sentLockOrders }).catch(() => {});
|
|
3577
3613
|
}
|
|
3578
3614
|
await syncBrokerAccount(accId).catch(() => {});
|
|
3579
3615
|
const st = brokersState();
|
|
3580
3616
|
hub.emit({ type: 'brokers:update', state: st });
|
|
3617
|
+
} finally {
|
|
3618
|
+
releaseAccountLock(accId, lockToken);
|
|
3619
|
+
}
|
|
3581
3620
|
}
|
|
3582
3621
|
|
|
3583
3622
|
// Автоматическое периодическое фоновое обновление портфеля и контроль фиксации прибыли (раз в 25 секунд; история операций — раз в 2 минуты)
|
package/server/model/dataset.js
CHANGED
|
@@ -230,8 +230,14 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
|
|
|
230
230
|
const latestClosedSlotTs = Math.floor((Math.min(tillTs, now) - bMs) / bMs) * bMs;
|
|
231
231
|
const isCacheFreshForSlot = (entry) => {
|
|
232
232
|
if (!isTradingNow || now - entry.at < 50000) return true;
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
if (entry.at < latestClosedSlotTs + bMs) return false;
|
|
234
|
+
const tl = entry.ds?.timeline;
|
|
235
|
+
if (!tl?.length) return false;
|
|
236
|
+
let lastClosedBar = 0;
|
|
237
|
+
for (let i = tl.length - 1; i >= 0; i--) {
|
|
238
|
+
if (tl[i] + bMs <= now) { lastClosedBar = tl[i]; break; }
|
|
239
|
+
}
|
|
240
|
+
return lastClosedBar >= latestClosedSlotTs;
|
|
235
241
|
};
|
|
236
242
|
const key = cacheKey(interval, fromTs, tillTs, useMacro, useDividends, localOnly);
|
|
237
243
|
const hit = datasetCache.get(key);
|
package/server/model/forecast.js
CHANGED
|
@@ -19,17 +19,29 @@ const inflight = new Map();
|
|
|
19
19
|
const inflightStartedAt = new Map();
|
|
20
20
|
let nearSignalsCache = { key: null, scannedBarTs: 0, episodesMap: new Map(), list: [] };
|
|
21
21
|
|
|
22
|
-
// Синхронная живая доходность индекса относительно
|
|
23
|
-
// если
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// Синхронная живая доходность индекса относительно базовой закрытой свечи baseIdx:
|
|
23
|
+
// если последняя закрытая 10-минутная свеча idx уже закончилась (liveSnap.ts >= barTs + BAR_MS), живая цена относится к НОВОЙ
|
|
24
|
+
// формирующейся свече после idx — поэтому базой служит tk.close[idx], а не tk.close[idx - 1]. Иначе два подряд растущих бара
|
|
25
|
+
// складывались в одну точку и упирались в потолок 8σ одной свечи!
|
|
26
|
+
const liveBaseIndexOf = (ds, idx, liveSnap) => {
|
|
27
|
+
const barTs = ds.timeline?.[idx] || 0;
|
|
28
|
+
return (liveSnap?.ts && barTs > 0 && liveSnap.ts >= barTs + BAR_MS) ? idx : Math.max(0, idx - 1);
|
|
29
|
+
};
|
|
30
|
+
const liveElapsedBarsOf = (ds, idx, liveSnap) => {
|
|
31
|
+
const barTs = ds.timeline?.[idx] || 0;
|
|
32
|
+
if (!(liveSnap?.ts && barTs > 0)) return 1;
|
|
33
|
+
const startTs = liveSnap.ts >= barTs + BAR_MS ? barTs + BAR_MS : barTs;
|
|
34
|
+
return Math.max(1, (liveSnap.ts - startTs) / BAR_MS);
|
|
35
|
+
};
|
|
36
|
+
|
|
26
37
|
function computeLiveMarketReturn(ds, idx, liveSnap) {
|
|
27
38
|
if (idx < 1 || idx >= ds.T) return 0;
|
|
28
39
|
if (liveSnap?.prices?.size > 0) {
|
|
40
|
+
const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
|
|
29
41
|
let wSum = 0, rSum = 0, cnt = 0;
|
|
30
42
|
for (const tk of ds.tokens) {
|
|
31
43
|
if (tk.inIndex && !tk.inIndex[idx]) continue;
|
|
32
|
-
const prevPx = tk.close[
|
|
44
|
+
const prevPx = tk.close[baseIdx];
|
|
33
45
|
const livePx = liveSnap.prices.get(tk.symbol);
|
|
34
46
|
if (livePx > 0 && prevPx > 0) {
|
|
35
47
|
const w = tk.weight > 0 ? tk.weight : 1;
|
|
@@ -39,44 +51,52 @@ function computeLiveMarketReturn(ds, idx, liveSnap) {
|
|
|
39
51
|
}
|
|
40
52
|
}
|
|
41
53
|
if (cnt >= 15 && wSum > 0) return rSum / wSum;
|
|
42
|
-
const mPrev = ds.mclose?.[
|
|
54
|
+
const mPrev = ds.mclose?.[baseIdx] || 0;
|
|
43
55
|
if (liveSnap.imoexPrice > 0 && mPrev > 0) return Math.log(liveSnap.imoexPrice / mPrev);
|
|
44
56
|
}
|
|
45
57
|
return ds.mret[idx] || 0;
|
|
46
58
|
}
|
|
47
59
|
|
|
48
|
-
// Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс)
|
|
60
|
+
// Быстрый расчёт признаков контекста в одной точке idx без переобучения матрицы внимания и весов (за ~1-2 мс).
|
|
61
|
+
// Выделяется дополнительный живой слот u+1, чтобы при формировании новой свечи (после закрытия idx) не затирать закрытую свечу idx!
|
|
49
62
|
function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
50
63
|
const { tokens, mret } = ds;
|
|
51
64
|
const N = fTrain.N;
|
|
52
65
|
const { beta, sigma, cos } = fTrain;
|
|
53
66
|
const lo = Math.max(1, idx - FEATURE_WARMUP - 2);
|
|
54
|
-
const
|
|
55
|
-
const
|
|
67
|
+
const uClosed = idx - lo;
|
|
68
|
+
const barTs = ds.timeline[idx] || 0;
|
|
69
|
+
const hasNewLiveBar = Boolean(liveSnap?.prices?.size > 0 && liveSnap.ts && barTs > 0 && liveSnap.ts >= barTs + BAR_MS);
|
|
70
|
+
const u = hasNewLiveBar ? uClosed + 1 : uClosed;
|
|
71
|
+
const W = uClosed + 2;
|
|
56
72
|
|
|
57
73
|
const z = new Float64Array(W * N);
|
|
58
74
|
const vr = new Float64Array(W * N);
|
|
59
|
-
|
|
60
|
-
const elapsedBars = liveSnap?.ts && barTs > 0 ? Math.max(1, (liveSnap.ts - barTs) / BAR_MS) : 1;
|
|
61
|
-
const liveScale = 1 / Math.sqrt(elapsedBars);
|
|
62
|
-
const mLive = liveSnap?.prices?.size > 0 ? computeLiveMarketReturn(ds, idx, liveSnap) : (mret[idx] || 0);
|
|
63
|
-
for (let uk = 0; uk < W; uk++) {
|
|
75
|
+
for (let uk = 0; uk <= uClosed; uk++) {
|
|
64
76
|
const t = lo + uk;
|
|
65
|
-
const
|
|
66
|
-
const mt = isLast ? mLive : mret[t];
|
|
77
|
+
const mt = mret[t];
|
|
67
78
|
for (let i = 0; i < N; i++) {
|
|
68
79
|
const tk = tokens[i];
|
|
69
|
-
|
|
70
|
-
if (isLast) {
|
|
71
|
-
const prevPx = tk.close[t - 1];
|
|
72
|
-
const livePx = liveSnap.prices.get(tk.symbol);
|
|
73
|
-
if (livePx > 0 && prevPx > 0) rt = Math.log(livePx / prevPx);
|
|
74
|
-
}
|
|
75
|
-
const e = (rt - beta[i] * mt) * (isLast ? liveScale : 1);
|
|
80
|
+
const e = tk.ret[t] - beta[i] * mt;
|
|
76
81
|
z[uk * N + i] = Math.max(-8, Math.min(8, e / sigma[i]));
|
|
77
82
|
vr[uk * N + i] = Math.min(4, tk.valRatio[t]);
|
|
78
83
|
}
|
|
79
84
|
}
|
|
85
|
+
for (let i = 0; i < N; i++) vr[(uClosed + 1) * N + i] = Math.min(4, tokens[i].valRatio[idx]);
|
|
86
|
+
|
|
87
|
+
if (liveSnap?.prices?.size > 0) {
|
|
88
|
+
const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
|
|
89
|
+
const liveScale = 1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap));
|
|
90
|
+
const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
|
|
91
|
+
for (let i = 0; i < N; i++) {
|
|
92
|
+
const tk = tokens[i];
|
|
93
|
+
const prevPx = tk.close[baseIdx];
|
|
94
|
+
const livePx = liveSnap.prices.get(tk.symbol);
|
|
95
|
+
const rt = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (hasNewLiveBar ? 0 : tk.ret[idx]);
|
|
96
|
+
const e = (rt - beta[i] * mLive) * liveScale;
|
|
97
|
+
z[u * N + i] = Math.max(-8, Math.min(8, e / sigma[i]));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
80
100
|
|
|
81
101
|
const X = KINDS.map(() => SCALES.map(() => new Float32Array(W * N)));
|
|
82
102
|
SCALES.forEach((sc, si) => {
|
|
@@ -101,17 +121,20 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
|
101
121
|
const list = fTrain.macro.list.map((mTrain) => {
|
|
102
122
|
const dm = ds.macro.find((x) => x.id === mTrain.id);
|
|
103
123
|
const zs = new Float32Array(SCALES.length * W);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
124
|
+
if (dm) {
|
|
125
|
+
const msig = mTrain.msig || 1e-3;
|
|
126
|
+
for (const uk of [uClosed, uClosed + 1]) {
|
|
127
|
+
const baseT = Math.min(idx, lo + uk);
|
|
128
|
+
SCALES.forEach((sc, si) => {
|
|
129
|
+
let sum = 0;
|
|
130
|
+
for (let k = 0; k < sc.len; k++) {
|
|
131
|
+
const t = baseT - sc.from - k;
|
|
132
|
+
if (t >= 0) sum += Math.max(-8, Math.min(8, dm.ret[t] / msig));
|
|
133
|
+
}
|
|
134
|
+
zs[si * W + uk] = sum / Math.sqrt(sc.len);
|
|
135
|
+
});
|
|
111
136
|
}
|
|
112
|
-
|
|
113
|
-
});
|
|
114
|
-
}
|
|
137
|
+
}
|
|
115
138
|
return { id: mTrain.id, label: mTrain.label, b: mTrain.b, zs };
|
|
116
139
|
});
|
|
117
140
|
macro = { list };
|
|
@@ -213,27 +236,33 @@ async function fetchLiveMoexSnapshot() {
|
|
|
213
236
|
}
|
|
214
237
|
}
|
|
215
238
|
|
|
216
|
-
// Мгновенное обновление
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
if (idx < 1 || idx >= ds.T) return;
|
|
239
|
+
// Мгновенное обновление живой точки контекста в уже обученной модели по живым котировкам (за ~2 мс).
|
|
240
|
+
// Возвращает индекс активной точки u (uClosed или uClosed+1, если после закрытия свечи idx уже идёт новая свеча).
|
|
241
|
+
function applyLivePricesToFold(ds, f, idx, fitted, liveSnap) {
|
|
242
|
+
const uClosed = idx - f.lo;
|
|
243
|
+
if (!liveSnap?.prices?.size || idx < 1 || idx >= ds.T) return uClosed;
|
|
221
244
|
const N = f.N;
|
|
222
|
-
const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
|
|
223
245
|
const barTs = ds.timeline[idx] || 0;
|
|
224
|
-
const
|
|
225
|
-
const
|
|
246
|
+
const hasNewLiveBar = Boolean(liveSnap.ts && barTs > 0 && liveSnap.ts >= barTs + BAR_MS && uClosed + 1 < f.W);
|
|
247
|
+
const u = hasNewLiveBar ? uClosed + 1 : uClosed;
|
|
248
|
+
// восстанавливаем чистую закрытую свечу idx на случай, если на предыдущем тике она обновлялась внутри свечи
|
|
249
|
+
for (let i = 0; i < N; i++) {
|
|
250
|
+
const tk = ds.tokens[i];
|
|
251
|
+
const e0 = tk.ret[idx] - f.beta[i] * ds.mret[idx];
|
|
252
|
+
f.z[uClosed * N + i] = Math.max(-8, Math.min(8, e0 / f.sigma[i]));
|
|
253
|
+
}
|
|
254
|
+
const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
|
|
255
|
+
const mLive = computeLiveMarketReturn(ds, idx, liveSnap);
|
|
256
|
+
const liveScale = 1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap));
|
|
226
257
|
|
|
227
|
-
// 1) пересчитываем z[u*N + i] по живой цене акции
|
|
258
|
+
// 1) пересчитываем z[u*N + i] по живой цене акции относительно закрытия базовой свечи
|
|
228
259
|
for (let i = 0; i < N; i++) {
|
|
229
260
|
const tk = ds.tokens[i];
|
|
230
|
-
const prevPx = tk.close[
|
|
261
|
+
const prevPx = tk.close[baseIdx];
|
|
231
262
|
const livePx = liveSnap.prices.get(tk.symbol);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
f.z[u * N + i] = Math.max(-8, Math.min(8, e / f.sigma[i]));
|
|
236
|
-
}
|
|
263
|
+
const ret = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (hasNewLiveBar ? 0 : tk.ret[idx]);
|
|
264
|
+
const e = (ret - f.beta[i] * mLive) * liveScale;
|
|
265
|
+
f.z[u * N + i] = Math.max(-8, Math.min(8, e / f.sigma[i]));
|
|
237
266
|
}
|
|
238
267
|
|
|
239
268
|
// 2) пересчитываем все масштабы SCALES в точке u (как в buildInferenceFoldAt)
|
|
@@ -246,8 +275,9 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
|
|
|
246
275
|
const uk = u - sc.from - k;
|
|
247
276
|
if (uk < 0) break;
|
|
248
277
|
const zk = f.z[uk * N + i];
|
|
278
|
+
const t = Math.min(idx, f.lo + uk);
|
|
249
279
|
sp += zk;
|
|
250
|
-
sf += zk * Math.min(4, ds.tokens[i].valRatio[
|
|
280
|
+
sf += zk * Math.min(4, ds.tokens[i].valRatio[t]);
|
|
251
281
|
}
|
|
252
282
|
Xp[u * N + i] = sp * invP;
|
|
253
283
|
Xf[u * N + i] = sf * invF;
|
|
@@ -272,6 +302,7 @@ function applyLivePricesToFold(ds, f, u, fitted, liveSnap) {
|
|
|
272
302
|
}
|
|
273
303
|
}
|
|
274
304
|
}
|
|
305
|
+
return u;
|
|
275
306
|
}
|
|
276
307
|
|
|
277
308
|
// Обучение ядра модели (карта рынка + веса внимания) выполняется не чаще 1 раза в торговый день для каждого набора параметров.
|
|
@@ -285,8 +316,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
285
316
|
const chosen = p.modelType in MODELS ? p.modelType : 'attention';
|
|
286
317
|
const fitKey = JSON.stringify([autoAdapt, chosen, effHorizon, p.modelTrainDays, p.modelEmbedDim, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight, ds.tokens.length]);
|
|
287
318
|
const barDayMsk = new Date((ds.timeline[idx] || Date.now()) + MSK).toISOString().slice(0, 10);
|
|
319
|
+
const dayFitKey = `${fitKey}:${barDayMsk}`;
|
|
288
320
|
|
|
289
|
-
const cached = trainedModelCache.get(
|
|
321
|
+
const cached = trainedModelCache.get(dayFitKey);
|
|
290
322
|
if (!forceTrain && cached && cached.dayMsk === barDayMsk && cached.fTrain.N === ds.tokens.length) {
|
|
291
323
|
const inf = buildInferenceFoldAt(ds, idx, cached.fTrain, cached.fitted, liveSnap);
|
|
292
324
|
return {
|
|
@@ -300,7 +332,6 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
300
332
|
const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, idx - trainLen);
|
|
301
333
|
const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
|
|
302
334
|
const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
|
|
303
|
-
const u = idx - f.lo;
|
|
304
335
|
// Обучаем только выбранную модель (и attention для объяснения соседей и весов голов),
|
|
305
336
|
// не тратя 15 секунд на холостое обучение матрицы leadlag, которая в прогнозе и живой торговле не используется!
|
|
306
337
|
const list = [...new Set(['attention', chosen])];
|
|
@@ -325,7 +356,11 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
325
356
|
...(fitted.attention ? { attention: { ...fitted.attention, F: null } } : {}),
|
|
326
357
|
};
|
|
327
358
|
const trainedAt = Date.now();
|
|
328
|
-
trainedModelCache.
|
|
359
|
+
if (trainedModelCache.size >= 16) {
|
|
360
|
+
const firstKey = trainedModelCache.keys().next().value;
|
|
361
|
+
if (firstKey) trainedModelCache.delete(firstKey);
|
|
362
|
+
}
|
|
363
|
+
trainedModelCache.set(dayFitKey, { dayMsk: barDayMsk, trainedAt, fTrain, fitted: lightFitted, snap });
|
|
329
364
|
|
|
330
365
|
const inf = buildInferenceFoldAt(ds, idx, fTrain, lightFitted, liveSnap);
|
|
331
366
|
return {
|
|
@@ -357,7 +392,9 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
357
392
|
const idx30m = Math.max(0, idx - 3);
|
|
358
393
|
const idx1d = Math.max(0, idx - bpd);
|
|
359
394
|
const idx3d = Math.max(0, idx - 3 * bpd);
|
|
360
|
-
const
|
|
395
|
+
const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
|
|
396
|
+
const liveScale = liveSnap?.prices?.size > 0 ? (1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap))) : 1;
|
|
397
|
+
const mPrevBar = idx > 0 ? (ds.mclose?.[baseIdx] || 0) : 0;
|
|
361
398
|
const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
|
|
362
399
|
const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
|
|
363
400
|
? mPrevBar * Math.exp(mLiveRet)
|
|
@@ -368,11 +405,11 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
368
405
|
const rows = ds.tokens.map((tk, i) => {
|
|
369
406
|
let exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
|
|
370
407
|
const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
|
|
371
|
-
const prevPx = idx > 0 ? (tk.close[
|
|
408
|
+
const prevPx = idx > 0 ? (tk.close[baseIdx] || 0) : 0;
|
|
372
409
|
const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
|
|
373
410
|
const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
|
|
374
|
-
const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
|
|
375
|
-
const rawPriceMovePct = (livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100);
|
|
411
|
+
const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * liveScale * 100;
|
|
412
|
+
const rawPriceMovePct = ((livePx > 0 && prevPx > 0) ? ((livePx / prevPx - 1) * 100) : (rawBarRet * 100)) * liveScale;
|
|
376
413
|
const lastMovePct = round(rawResPct, 3);
|
|
377
414
|
// Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
|
|
378
415
|
const p30m = tk.close[idx30m] || 0;
|
|
@@ -414,15 +451,16 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
414
451
|
const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M || isLimitDownLocked;
|
|
415
452
|
const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D || isLimitUpLocked;
|
|
416
453
|
const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
|
|
417
|
-
const isExtremeSpike = Boolean(isRegimeSqueeze ||
|
|
454
|
+
const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
|
|
418
455
|
const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
|
|
419
|
-
const liveShortOk = !isExtremeSpike;
|
|
456
|
+
const liveShortOk = !isExtremeSpike && !isMorningOpenShortBlocked;
|
|
420
457
|
// сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
|
|
421
458
|
const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
|
|
422
459
|
const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
|
|
423
460
|
const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
|
|
424
461
|
const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
|
|
425
462
|
const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
|
|
463
|
+
const shortBlockedByTime = Boolean(exp < 0 && !isExtremeSpike && isMorningOpenShortBlocked);
|
|
426
464
|
const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
|
|
427
465
|
const row = {
|
|
428
466
|
i, symbol: tk.symbol, name: tk.name,
|
|
@@ -449,6 +487,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
449
487
|
row.shortSignal = rawShortHit && !shortBlockedByBroker;
|
|
450
488
|
if (shortBlockedByBroker) row.shortBlockedByBroker = true;
|
|
451
489
|
if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
|
|
490
|
+
if (shortBlockedByTime) row.shortBlockedByTime = true;
|
|
452
491
|
}
|
|
453
492
|
// контекст торгового дня (экстремумы, движение к закрытию прошлого дня) — для фильтра входа против события, стопа и журнала решений
|
|
454
493
|
const dc = dayContext(ds, i, idx, f.sigma[i] * 100, livePx);
|
|
@@ -499,6 +538,11 @@ function withNews(result, p) {
|
|
|
499
538
|
} else {
|
|
500
539
|
delete r.shortBlockedBySqueeze;
|
|
501
540
|
}
|
|
541
|
+
if (r.expectedPct < 0 && r.shortBlockedByTime) {
|
|
542
|
+
r.shortSignal = false;
|
|
543
|
+
} else if (r.expectedPct >= 0) {
|
|
544
|
+
delete r.shortBlockedByTime;
|
|
545
|
+
}
|
|
502
546
|
if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
|
|
503
547
|
r.buyBlockedByDip = true;
|
|
504
548
|
r.signal = false;
|
|
@@ -616,6 +660,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
|
|
|
616
660
|
if (side === 'short') {
|
|
617
661
|
if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
|
|
618
662
|
if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
|
|
663
|
+
if (row.shortBlockedByTime) reasons.push('вне окна шорта по времени сессии');
|
|
619
664
|
if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
|
|
620
665
|
if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
|
|
621
666
|
if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
|
|
@@ -742,11 +787,12 @@ const fmtLiveBarLabel = (barTs, liveSnapTs) => {
|
|
|
742
787
|
// Мгновенный пересчёт последнего среза прогноза по живым секундным котировкам (без обращения к истории свечей)
|
|
743
788
|
async function tickLiveForecastState(p, now = Date.now()) {
|
|
744
789
|
const liveSnap = await fetchLiveMoexSnapshot();
|
|
745
|
-
|
|
790
|
+
const uActive = liveSnap ? applyLivePricesToFold(baseState.ds, baseState.f, baseState.last, baseState.fitted, liveSnap) : baseState.u;
|
|
791
|
+
baseState.u = uActive;
|
|
746
792
|
const effHorizon = p.modelAutoAdapt !== false ? 2.5 : p.modelHorizon;
|
|
747
793
|
const effMinEdge = p.modelAutoAdapt !== false ? 2.5 : p.modelMinEdge;
|
|
748
794
|
const H = barsFromHours(effHorizon);
|
|
749
|
-
const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f,
|
|
795
|
+
const r = buildPredictionRows(baseState.ds, p, baseState.last, baseState.f, uActive, baseState.fitted, baseState.chosen, H, effMinEdge, p.modelAutoAdapt !== false, true, liveSnap);
|
|
750
796
|
const sortedRows = r.rows.map(({ i, expected, ...x }) => ({ ...x, cluster: baseState.snap.clusters[i] })).sort((a, b) => b.expectedPct - a.expectedPct);
|
|
751
797
|
attachDeltas(sortedRows, baseState);
|
|
752
798
|
const liveTs = liveSnap?.ts || now;
|
package/server/model/policy.js
CHANGED
|
@@ -89,10 +89,10 @@ export function protectiveStopPct(side, { sigmaBarPct = 0, realizedBarPct = 0, h
|
|
|
89
89
|
if (st.mode === 'vol' && sigmaBarPct > 0) {
|
|
90
90
|
const vol = Math.max(sigmaBarPct, st.useRealized ? realizedBarPct : 0);
|
|
91
91
|
const raw = st.k * vol * Math.sqrt(horizonBars);
|
|
92
|
-
const maxPct = side === 'long' ? (st.maxLongPct ??
|
|
92
|
+
const maxPct = side === 'long' ? (st.maxLongPct ?? 7.0) : (st.maxShortPct ?? 7.0);
|
|
93
93
|
return Math.max(st.minPct ?? 1.0, Math.min(maxPct, raw));
|
|
94
94
|
}
|
|
95
|
-
return side === 'long' ? (st.longPct ??
|
|
95
|
+
return side === 'long' ? (st.longPct ?? 7.0) : (st.shortPct ?? 7.0);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
// ---------- плечо и множители размера по эшелонам ----------
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isMarketDown,
|
|
18
18
|
} from './edge.js';
|
|
19
19
|
import { nextDividendAt, dividendRules } from './dividends.js';
|
|
20
|
-
import { STRATEGY_POLICY, sessionEdgeWarning, profitLockLevels, protectiveStopPct, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
|
|
20
|
+
import { STRATEGY_POLICY, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
|
|
21
21
|
|
|
22
22
|
// Выравнивание внешнего ряда (свечи IMOEXF, дневные цены LQDT) по шкале датасета с переносом последней цены
|
|
23
23
|
export function alignSeries(timeline, rows) {
|
|
@@ -52,7 +52,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
52
52
|
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
53
|
const convMax = plan === 'owner' ? 1.65 : plan === 'vip' ? 1.30 : 1;
|
|
54
54
|
const convK = plan === 'owner' ? 0.30 : 0.25;
|
|
55
|
-
const lev = Math.max(1, Number(leverage) || 1);
|
|
55
|
+
const lev = Math.max(1, Math.min(MAX_LEVERAGE, Number(leverage) || 1));
|
|
56
56
|
const maxGross = policy.maxGross ?? 0.99 * lev;
|
|
57
57
|
const capScale = policy.capScale ?? 1;
|
|
58
58
|
const tierScale = (i) => (isT1(i) ? (policy.edgeScaleT1 ?? 1) : (policy.edgeScaleT2 ?? 1));
|
|
@@ -106,7 +106,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
106
106
|
}
|
|
107
107
|
held.delete(i);
|
|
108
108
|
cooldownUntil[i] = exitT + (kind === 'stop' || kind === 'thesis' ? stopCooldownBars : cooldownBars);
|
|
109
|
-
stockPnl[i] = stockPnl[i] * 0.90 + (g - 2 *
|
|
109
|
+
stockPnl[i] = stockPnl[i] * 0.90 + (g - 2 * c);
|
|
110
110
|
stockCnt[i] += 1;
|
|
111
111
|
};
|
|
112
112
|
const mtmUnreal = (t) => {
|
|
@@ -124,13 +124,26 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
124
124
|
if (firstK < 0) firstK = wf.testTs.length;
|
|
125
125
|
let lastT = -1;
|
|
126
126
|
let prevT = -1;
|
|
127
|
+
const maxDailyDdPct = policy.maxDailyDrawdownPct ?? 3.0;
|
|
128
|
+
let curDayMsk = -1;
|
|
129
|
+
let dayStartTotalRub = capital;
|
|
130
|
+
let dayDdBreachCount = 0;
|
|
131
|
+
let dayHalted = false;
|
|
127
132
|
for (let k = firstK; k < wf.testTs.length; k++) {
|
|
128
133
|
const t = wf.testTs[k];
|
|
129
134
|
if (timeline[t] > tillTs || t + 1 >= T) break;
|
|
130
135
|
lastT = t;
|
|
131
136
|
const fo = wf.foldOf[k];
|
|
132
137
|
const pr = wf.pred[k], sg = wf.foldSigma[fo], mult = wf.foldMult[fo], beta = wf.foldBeta[fo];
|
|
138
|
+
if (!pr || !sg || !mult || !beta) continue;
|
|
133
139
|
const decTs = timeline[t] + BAR_MS;
|
|
140
|
+
const barDayMsk = Math.floor((timeline[t] + 3 * 3600000) / 86400000);
|
|
141
|
+
if (barDayMsk !== curDayMsk) {
|
|
142
|
+
curDayMsk = barDayMsk;
|
|
143
|
+
dayStartTotalRub = Math.max(1, equity + mtmUnreal(t));
|
|
144
|
+
dayDdBreachCount = 0;
|
|
145
|
+
dayHalted = false;
|
|
146
|
+
}
|
|
134
147
|
const isStep = (k - firstK) % stepBars === 0;
|
|
135
148
|
if (isStep) {
|
|
136
149
|
for (let i = 0; i < N; i++) { stockPnl[i] *= QUARANTINE_DECAY; stockCnt[i] *= QUARANTINE_DECAY; }
|
|
@@ -149,7 +162,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
149
162
|
}
|
|
150
163
|
prevT = t;
|
|
151
164
|
|
|
152
|
-
// 1) внутри свечи t:
|
|
165
|
+
// 1) внутри свечи t: сначала проверяем защитный стоп и стоп отмены идеи (консервативный приоритет неблагоприятного движения), затем фиксацию прибыли
|
|
153
166
|
for (const [i, pos] of [...held]) {
|
|
154
167
|
if (pos.entryT > t) continue;
|
|
155
168
|
const tk = tokens[i];
|
|
@@ -159,19 +172,11 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
159
172
|
const favorable = pos.side === 'long' ? h : l;
|
|
160
173
|
const heldMinStart = (t - pos.entryT) * BAR_INTERVAL;
|
|
161
174
|
let done = false;
|
|
162
|
-
if (policy.profitLock) {
|
|
163
|
-
const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart, roundTripFeePct);
|
|
164
|
-
if (lv) {
|
|
165
|
-
const gO = gainOf(pos, o) * 100;
|
|
166
|
-
if (gO <= lv.floorPct && gO >= lv.minExitPct) { closePos(i, pos, t, o, 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
|
|
167
|
-
else if (gO > lv.floorPct && gainOf(pos, adverse) * 100 <= lv.floorPct) { closePos(i, pos, t, pxOfGain(pos, lv.floorPct / 100), 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (done) continue;
|
|
171
175
|
const stopPx = pxOfGain(pos, -pos.stopPct / 100);
|
|
172
176
|
const levels = [[stopPx, 'stop']];
|
|
173
177
|
if (pos.thesisPx > 0) levels.push([pos.thesisPx, 'thesis']);
|
|
174
|
-
|
|
178
|
+
// При одновременном пробое нескольких стоп-уровней внутри свечи исполняем по более невыгодному (консервативно)
|
|
179
|
+
levels.sort((a, b) => (pos.side === 'long' ? a[0] - b[0] : b[0] - a[0]));
|
|
175
180
|
for (const [lvPx, kind] of levels) {
|
|
176
181
|
const hit = pos.side === 'long' ? adverse <= lvPx : adverse >= lvPx;
|
|
177
182
|
if (!hit) continue;
|
|
@@ -182,6 +187,15 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
182
187
|
break;
|
|
183
188
|
}
|
|
184
189
|
if (done) continue;
|
|
190
|
+
if (policy.profitLock) {
|
|
191
|
+
const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart, roundTripFeePct);
|
|
192
|
+
if (lv) {
|
|
193
|
+
const gO = gainOf(pos, o) * 100;
|
|
194
|
+
if (gO <= lv.floorPct && gO >= lv.minExitPct) { closePos(i, pos, t, o, 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
|
|
195
|
+
else if (gO > lv.floorPct && gainOf(pos, adverse) * 100 <= lv.floorPct) { closePos(i, pos, t, pxOfGain(pos, lv.floorPct / 100), 'Фиксация прибыли', 'lock'); stats.locks++; done = true; }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (done) continue;
|
|
185
199
|
pos.peakGain = Math.max(pos.peakGain, gainOf(pos, favorable));
|
|
186
200
|
if (policy.profitLock) {
|
|
187
201
|
const lv = profitLockLevels(tk.symbol, pos.peakGain * 100, heldMinStart + BAR_INTERVAL, roundTripFeePct);
|
|
@@ -248,8 +262,18 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
|
|
|
248
262
|
if (kind === 'night') stats.night++; else if (kind === 'signal') stats.signalExits++; else if (kind === 'time') stats.timeExits++; else if (kind === 'regime') stats.regime++; else stats.div++;
|
|
249
263
|
}
|
|
250
264
|
|
|
251
|
-
// 3) входы
|
|
252
|
-
|
|
265
|
+
// 3) входы (блокируются до конца дня при срабатывании дневного стопа просадки, как в живом контуре)
|
|
266
|
+
if (maxDailyDdPct > 0 && !dayHalted && isStep) {
|
|
267
|
+
const curTotalRub = equity + mtmUnreal(t);
|
|
268
|
+
const dayDdPct = ((dayStartTotalRub - curTotalRub) / dayStartTotalRub) * 100;
|
|
269
|
+
if (dayDdPct >= maxDailyDdPct) {
|
|
270
|
+
dayDdBreachCount += 1;
|
|
271
|
+
if (dayDdBreachCount >= 2) dayHalted = true;
|
|
272
|
+
} else {
|
|
273
|
+
dayDdBreachCount = 0;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const timeOk = !dayHalted && canEnterIntraday(decTs, H, BAR_INTERVAL) && !sessionEdgeWarning(decTs) && !nightNow && (policy.entryEveryBar !== false || isStep);
|
|
253
277
|
if (timeOk) {
|
|
254
278
|
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));
|
|
255
279
|
const candL = [];
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { buildFold, MODELS, FEATURE_WARMUP } from './models.js';
|
|
6
6
|
import { computeTokenEdgeMults } from './edge.js';
|
|
7
7
|
import { barsFromHours } from './dataset.js';
|
|
8
|
+
import { loadProfiles } from './profiles.js';
|
|
8
9
|
|
|
9
10
|
const DAY = 86400000;
|
|
10
11
|
const MSK = 3 * 3600000;
|
|
@@ -33,6 +34,7 @@ export async function computeWalkForward(ds, p, { model = 'attention', tillTs =
|
|
|
33
34
|
const foldSigma = [];
|
|
34
35
|
const foldBeta = [];
|
|
35
36
|
const foldMult = [];
|
|
37
|
+
const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
|
|
36
38
|
let fi = 0;
|
|
37
39
|
while (testStart < T - maxH - 2) {
|
|
38
40
|
if (signal?.aborted) throw new Error('Отменено');
|
|
@@ -40,7 +42,7 @@ export async function computeWalkForward(ds, p, { model = 'attention', tillTs =
|
|
|
40
42
|
const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, testStart - trainLen);
|
|
41
43
|
onProgress({ fold: fi + 1, folds, from: timeline[testStart], till: timeline[testEnd - 1] });
|
|
42
44
|
await tick();
|
|
43
|
-
const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: 0, profiles
|
|
45
|
+
const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight || 0, profiles, multiHorizon: autoAdapt });
|
|
44
46
|
foldSigma.push(Float64Array.from(f.sigma));
|
|
45
47
|
foldBeta.push(Float64Array.from(f.beta));
|
|
46
48
|
foldMult.push(computeTokenEdgeMults(tokens, f.sigma, trainStart, testStart));
|