neuroalpha 0.6.3 → 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 +40 -18
- package/server/model/dataset.js +8 -2
- package/server/model/forecast.js +22 -9
- 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
|
@@ -1716,10 +1716,10 @@ const isOnImpulseWatch = (accId, sym, side, nowTs = Date.now()) => {
|
|
|
1716
1716
|
};
|
|
1717
1717
|
|
|
1718
1718
|
async function filterByImpulseExhaustion(acc, cands, side, specs, nowTs = Date.now()) {
|
|
1719
|
-
if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
|
|
1720
1719
|
for (const [k, v] of entryImpulseWatch) {
|
|
1721
1720
|
if (nowTs - v.lastTs > IMPULSE_WATCH_TTL_MS || nowTs - v.firstTs > IMPULSE_WATCH_TTL_MS) entryImpulseWatch.delete(k);
|
|
1722
1721
|
}
|
|
1722
|
+
if (!cands.length || acc.broker !== 'tbank' || !acc.token) return { ready: cands, deferred: [] };
|
|
1723
1723
|
const ready = [];
|
|
1724
1724
|
const deferred = [];
|
|
1725
1725
|
for (const r of cands) {
|
|
@@ -1826,7 +1826,7 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1826
1826
|
let list = loadRawAccounts();
|
|
1827
1827
|
let acc = list.find((x) => x.id === id);
|
|
1828
1828
|
if (!acc) throw new Error('Счёт не найден');
|
|
1829
|
-
|
|
1829
|
+
let exitsOnly = isDailyStopHalt(acc);
|
|
1830
1830
|
if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
|
|
1831
1831
|
const edgeWarn = acc.skipEdgeMinutes ? sessionEdgeWarning() : null;
|
|
1832
1832
|
if (onlyEntries && (edgeWarn || exitsOnly)) {
|
|
@@ -1848,6 +1848,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
1848
1848
|
list = loadRawAccounts();
|
|
1849
1849
|
acc = list.find((x) => x.id === id) || acc;
|
|
1850
1850
|
}
|
|
1851
|
+
exitsOnly = isDailyStopHalt(acc);
|
|
1852
|
+
if (acc.haltedReason && !exitsOnly) throw new Error(acc.haltedReason);
|
|
1853
|
+
if (onlyEntries && exitsOnly) return brokersState();
|
|
1851
1854
|
const params = normalizeParams(getSettings());
|
|
1852
1855
|
const [fc, rawSpecs, shortableMap] = await withWallTimeout(Promise.all([
|
|
1853
1856
|
forecastNow(params),
|
|
@@ -2866,23 +2869,25 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2866
2869
|
if (!acc) throw new Error('Счёт не найден');
|
|
2867
2870
|
if (acc.mode === 'readonly') throw new Error('Счёт в режиме «Только чтение» — отправка заявок отключена');
|
|
2868
2871
|
const gate = tradingGate();
|
|
2869
|
-
if (!gate.allowNew) throw new Error(gate.reason);
|
|
2872
|
+
if (!gate.allowNew && !gate.allowProtective) throw new Error(gate.reason);
|
|
2870
2873
|
const batch = acc.pendingBatch;
|
|
2871
2874
|
if (!batch || !batch.orders?.length) throw new Error('Нет подготовленных заявок для отправки');
|
|
2872
2875
|
if (batch.status === 'executed' || batch.status === 'executing') return brokersState();
|
|
2873
|
-
if (acc.haltedReason && !
|
|
2874
|
-
// Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна
|
|
2876
|
+
if (acc.haltedReason && !isDailyStopHalt(acc)) throw new Error(acc.haltedReason);
|
|
2877
|
+
// Пакет мог ждать подтверждения в Telegram/MAX: входы по устаревшему сигналу, вне окна входов, при дневном стопе или в окне начала сессии не отправляем (выходы сохраняются!)
|
|
2875
2878
|
const execCheckTs = Date.now();
|
|
2876
2879
|
const staleWhy = (() => {
|
|
2880
|
+
if (!gate.allowNew) return gate.reason;
|
|
2881
|
+
if (isDailyStopHalt(acc)) return acc.haltedReason;
|
|
2877
2882
|
if (execCheckTs - (batch.createdAt || 0) > 10 * 60000) return `сигнал устарел (пакет сформирован ${Math.round((execCheckTs - (batch.createdAt || 0)) / 60000)} мин назад)`;
|
|
2878
2883
|
if (!canEnterIntraday(execCheckTs, barsFromHours(2.5), BAR_INTERVAL)) return 'окно новых входов уже закрыто';
|
|
2879
2884
|
if (acc.skipEdgeMinutes && sessionEdgeWarning(execCheckTs)) return 'начало/конец сессии';
|
|
2880
|
-
if (isDailyStopHalt(acc)) return acc.haltedReason;
|
|
2881
2885
|
return null;
|
|
2882
2886
|
})();
|
|
2883
2887
|
if (staleWhy && batch.orders.some((o) => !isExitOrder(o) && o.symbol !== 'LQDT')) {
|
|
2884
|
-
const
|
|
2885
|
-
|
|
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'));
|
|
2886
2891
|
logBrokerEvent(acc, 'order_skip', `входы не отправлены (${staleWhy}): ${dropped.map((o) => `${o.action} ${o.symbol}`).join(', ')}`);
|
|
2887
2892
|
if (!batch.orders.length) {
|
|
2888
2893
|
batch.status = 'expired';
|
|
@@ -2892,6 +2897,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2892
2897
|
throw new Error(`Входы не отправлены: ${staleWhy}`);
|
|
2893
2898
|
}
|
|
2894
2899
|
}
|
|
2900
|
+
if (acc.haltedReason && !(isDailyStopHalt(acc) && batch.orders.every(isExitOrder))) throw new Error(acc.haltedReason);
|
|
2895
2901
|
// Идемпотентность: у каждой заявки постоянный clientOrderId (для Т-Банка — orderId в PostOrder, брокер не исполнит его дважды).
|
|
2896
2902
|
// ID закрепляется и сохраняется ДО отправки; при повторе пакета «partial» уже принятые брокером заявки не отправляются снова.
|
|
2897
2903
|
for (const ord of batch.orders) {
|
|
@@ -2944,8 +2950,12 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2944
2950
|
results.push({ symbol: ord.symbol, ok: true, orderId: r.orderId, clientOrderId: ord.clientOrderId });
|
|
2945
2951
|
newlySentOrders.push(ord);
|
|
2946
2952
|
if (ord.action === 'buy' || ord.action === 'short') {
|
|
2947
|
-
acc.entryTsBySymbol[ord.symbol] = execNowTs;
|
|
2948
|
-
|
|
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);
|
|
2949
2959
|
delete acc.exitCooldownBySymbol[ord.symbol];
|
|
2950
2960
|
delete acc.entryFailCooldownBySymbol[ord.symbol];
|
|
2951
2961
|
delete acc.entryFailReasonBySymbol[ord.symbol];
|
|
@@ -3248,7 +3258,8 @@ async function tickBrokerSchedule() {
|
|
|
3248
3258
|
const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
|
|
3249
3259
|
const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
|
|
3250
3260
|
const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
|
|
3251
|
-
|
|
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) {
|
|
3252
3263
|
await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
|
|
3253
3264
|
}
|
|
3254
3265
|
}
|
|
@@ -3372,6 +3383,7 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3372
3383
|
const headers = tbankHeaders(acc);
|
|
3373
3384
|
const want = new Map();
|
|
3374
3385
|
const activeOrders = acc.lastSync?.activeOrders || [];
|
|
3386
|
+
const histOps = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
|
|
3375
3387
|
for (const pos of acc.lastSync.positions || []) {
|
|
3376
3388
|
const sym = pos.symbol;
|
|
3377
3389
|
if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
|
|
@@ -3386,7 +3398,8 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3386
3398
|
const ordAgeMs = activeOpenOrd ? (Date.now() - (Date.parse(activeOpenOrd.orderDate || 0) || Number(acc.entryTsBySymbol?.[sym]) || 0)) : Infinity;
|
|
3387
3399
|
const isFillingNow = Boolean(activeOpenOrd && ordAgeMs < 60000);
|
|
3388
3400
|
const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
|
|
3389
|
-
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);
|
|
3390
3403
|
if (lots < 1 || avgPx <= 0) continue;
|
|
3391
3404
|
const side = aQty > 0 ? 'long' : 'short';
|
|
3392
3405
|
const stopPx = roundToStep(side === 'long' ? avgPx * (1 - BROKER_STOP_LONG_PCT / 100) : avgPx * (1 + BROKER_STOP_SHORT_PCT / 100), sp.minStep, sp.decimals);
|
|
@@ -3452,16 +3465,19 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
3452
3465
|
}
|
|
3453
3466
|
}
|
|
3454
3467
|
|
|
3455
|
-
// Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые
|
|
3468
|
+
// Быстрая проверка и автоматическая фиксация прибыли (Take-Profit / защита безубытка от отката) каждые 25 секунд между шагами расписания
|
|
3456
3469
|
async function checkIntradayProfitLock(accId) {
|
|
3457
|
-
const list = loadRawAccounts();
|
|
3458
|
-
const acc = list.find((x) => x.id === accId);
|
|
3459
|
-
if (!acc || acc.mode !== 'auto' || !acc.token || (acc.haltedReason && !isDailyStopHalt(acc))) return;
|
|
3460
3470
|
if (!tradingGate().allowProtective) return;
|
|
3461
3471
|
const nowMsk = new Date(Date.now() + MSK);
|
|
3462
3472
|
const dow = nowMsk.getUTCDay();
|
|
3463
3473
|
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
3464
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;
|
|
3465
3481
|
// В окнах начала/конца сессии (широкий спред) фиксация прибыли не срабатывает на шуме стакана, а стопы работают всегда
|
|
3466
3482
|
const edgeNow = Boolean(acc.skipEdgeMinutes && sessionEdgeWarning());
|
|
3467
3483
|
|
|
@@ -3578,7 +3594,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3578
3594
|
logBrokerEvent(acc, 'protective_stop_error', `${ord.symbol}: ${e.message}`);
|
|
3579
3595
|
}
|
|
3580
3596
|
}
|
|
3581
|
-
|
|
3597
|
+
const stopBatchObj = {
|
|
3582
3598
|
...(acc.pendingBatch || {}),
|
|
3583
3599
|
id: `batch_stop_${execNowTs}`,
|
|
3584
3600
|
createdAt: execNowTs,
|
|
@@ -3588,13 +3604,19 @@ async function checkIntradayProfitLock(accId) {
|
|
|
3588
3604
|
status: results.every((r) => r.ok) ? 'executed' : 'partial',
|
|
3589
3605
|
results,
|
|
3590
3606
|
};
|
|
3607
|
+
if (acc.pendingBatch?.status !== 'pending' && acc.pendingBatch?.status !== 'executing') {
|
|
3608
|
+
acc.pendingBatch = stopBatchObj;
|
|
3609
|
+
}
|
|
3591
3610
|
saveRawAccounts(list);
|
|
3592
3611
|
if (sentLockOrders.length > 0) {
|
|
3593
|
-
notifyBrokerBatch(currentUserId(), acc, { ...
|
|
3612
|
+
notifyBrokerBatch(currentUserId(), acc, { ...stopBatchObj, orders: sentLockOrders }).catch(() => {});
|
|
3594
3613
|
}
|
|
3595
3614
|
await syncBrokerAccount(accId).catch(() => {});
|
|
3596
3615
|
const st = brokersState();
|
|
3597
3616
|
hub.emit({ type: 'brokers:update', state: st });
|
|
3617
|
+
} finally {
|
|
3618
|
+
releaseAccountLock(accId, lockToken);
|
|
3619
|
+
}
|
|
3598
3620
|
}
|
|
3599
3621
|
|
|
3600
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
|
@@ -82,7 +82,6 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
|
82
82
|
vr[uk * N + i] = Math.min(4, tk.valRatio[t]);
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
vr[(uClosed + 1) * N] = 1;
|
|
86
85
|
for (let i = 0; i < N; i++) vr[(uClosed + 1) * N + i] = Math.min(4, tokens[i].valRatio[idx]);
|
|
87
86
|
|
|
88
87
|
if (liveSnap?.prices?.size > 0) {
|
|
@@ -125,10 +124,11 @@ function buildInferenceFoldAt(ds, idx, fTrain, fitted, liveSnap = null) {
|
|
|
125
124
|
if (dm) {
|
|
126
125
|
const msig = mTrain.msig || 1e-3;
|
|
127
126
|
for (const uk of [uClosed, uClosed + 1]) {
|
|
127
|
+
const baseT = Math.min(idx, lo + uk);
|
|
128
128
|
SCALES.forEach((sc, si) => {
|
|
129
129
|
let sum = 0;
|
|
130
130
|
for (let k = 0; k < sc.len; k++) {
|
|
131
|
-
const t =
|
|
131
|
+
const t = baseT - sc.from - k;
|
|
132
132
|
if (t >= 0) sum += Math.max(-8, Math.min(8, dm.ret[t] / msig));
|
|
133
133
|
}
|
|
134
134
|
zs[si * W + uk] = sum / Math.sqrt(sc.len);
|
|
@@ -316,8 +316,9 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
316
316
|
const chosen = p.modelType in MODELS ? p.modelType : 'attention';
|
|
317
317
|
const fitKey = JSON.stringify([autoAdapt, chosen, effHorizon, p.modelTrainDays, p.modelEmbedDim, p.modelUseMacro, p.modelUseDividends, p.modelTextWeight, ds.tokens.length]);
|
|
318
318
|
const barDayMsk = new Date((ds.timeline[idx] || Date.now()) + MSK).toISOString().slice(0, 10);
|
|
319
|
+
const dayFitKey = `${fitKey}:${barDayMsk}`;
|
|
319
320
|
|
|
320
|
-
const cached = trainedModelCache.get(
|
|
321
|
+
const cached = trainedModelCache.get(dayFitKey);
|
|
321
322
|
if (!forceTrain && cached && cached.dayMsk === barDayMsk && cached.fTrain.N === ds.tokens.length) {
|
|
322
323
|
const inf = buildInferenceFoldAt(ds, idx, cached.fTrain, cached.fitted, liveSnap);
|
|
323
324
|
return {
|
|
@@ -331,7 +332,6 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
331
332
|
const trainStart = Math.max(FEATURE_WARMUP + maxH + 2, idx - trainLen);
|
|
332
333
|
const profiles = p.modelTextWeight > 0 ? loadProfiles() : null;
|
|
333
334
|
const f = buildFold(ds, { trainStart, testStart: idx, testEnd: idx + 1, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
|
|
334
|
-
const u = idx - f.lo;
|
|
335
335
|
// Обучаем только выбранную модель (и attention для объяснения соседей и весов голов),
|
|
336
336
|
// не тратя 15 секунд на холостое обучение матрицы leadlag, которая в прогнозе и живой торговле не используется!
|
|
337
337
|
const list = [...new Set(['attention', chosen])];
|
|
@@ -356,7 +356,11 @@ export async function predictAt(ds, p, idx, { explain = true, liveSnap = null, f
|
|
|
356
356
|
...(fitted.attention ? { attention: { ...fitted.attention, F: null } } : {}),
|
|
357
357
|
};
|
|
358
358
|
const trainedAt = Date.now();
|
|
359
|
-
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 });
|
|
360
364
|
|
|
361
365
|
const inf = buildInferenceFoldAt(ds, idx, fTrain, lightFitted, liveSnap);
|
|
362
366
|
return {
|
|
@@ -389,6 +393,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
389
393
|
const idx1d = Math.max(0, idx - bpd);
|
|
390
394
|
const idx3d = Math.max(0, idx - 3 * bpd);
|
|
391
395
|
const baseIdx = liveBaseIndexOf(ds, idx, liveSnap);
|
|
396
|
+
const liveScale = liveSnap?.prices?.size > 0 ? (1 / Math.sqrt(liveElapsedBarsOf(ds, idx, liveSnap))) : 1;
|
|
392
397
|
const mPrevBar = idx > 0 ? (ds.mclose?.[baseIdx] || 0) : 0;
|
|
393
398
|
const mLiveRet = computeLiveMarketReturn(ds, idx, liveSnap);
|
|
394
399
|
const mNow = liveSnap?.prices?.size > 0 && mPrevBar > 0
|
|
@@ -403,8 +408,8 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
403
408
|
const prevPx = idx > 0 ? (tk.close[baseIdx] || 0) : 0;
|
|
404
409
|
const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
|
|
405
410
|
const rawBarRet = (livePx > 0 && prevPx > 0) ? Math.log(livePx / prevPx) : (tk.ret?.[idx] || 0);
|
|
406
|
-
const rawResPct = (rawBarRet - f.beta[i] * mLiveRet) * 100;
|
|
407
|
-
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;
|
|
408
413
|
const lastMovePct = round(rawResPct, 3);
|
|
409
414
|
// Контроль перегрева (защита от покупки на вершине уже состоявшегося пампа и ложного «отскока на откате»):
|
|
410
415
|
const p30m = tk.close[idx30m] || 0;
|
|
@@ -446,15 +451,16 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
446
451
|
const isRegimeCrash = excess1d < -REGIME_SHOCK_1D || excess30m < -REGIME_CRASH_30M || isLimitDownLocked;
|
|
447
452
|
const isRegimeSqueeze = excess1d > REGIME_SHOCK_1D || isLimitUpLocked;
|
|
448
453
|
const isExtremeDip = Boolean(isRegimeCrash || (liveSnap && (lastMovePct < -EXTREME_BAR_MOVE_PCT || rawPriceMovePct < -EXTREME_BAR_MOVE_PCT)));
|
|
449
|
-
const isExtremeSpike = Boolean(isRegimeSqueeze ||
|
|
454
|
+
const isExtremeSpike = Boolean(isRegimeSqueeze || (liveSnap && (lastMovePct > EXTREME_BAR_MOVE_PCT || rawPriceMovePct > EXTREME_BAR_MOVE_PCT)));
|
|
450
455
|
const liveBuyOk = !isExtremeDip && !buyBlockedByOverheat;
|
|
451
|
-
const liveShortOk = !isExtremeSpike;
|
|
456
|
+
const liveShortOk = !isExtremeSpike && !isMorningOpenShortBlocked;
|
|
452
457
|
// сигналы — только по бумагам из текущего состава индекса (исторические бумаги остаются в матрице рынка как контекст)
|
|
453
458
|
const inIdx = !tk.inIndex || tk.inIndex[idx] === 1;
|
|
454
459
|
const shortAllowed = shortableMap && tk.symbol in shortableMap ? Boolean(shortableMap[tk.symbol]) : null;
|
|
455
460
|
const rawShortHit = inIdx && dir !== 'long' && tk.liq[idx] >= LIQ_MIN && exp < -symNeedS && liveShortOk;
|
|
456
461
|
const shortBlockedByBroker = Boolean(exp < 0 && shortAllowed === false);
|
|
457
462
|
const shortBlockedBySqueeze = Boolean(exp < 0 && isExtremeSpike);
|
|
463
|
+
const shortBlockedByTime = Boolean(exp < 0 && !isExtremeSpike && isMorningOpenShortBlocked);
|
|
458
464
|
const buyBlockedByDip = Boolean(exp > 0 && isExtremeDip);
|
|
459
465
|
const row = {
|
|
460
466
|
i, symbol: tk.symbol, name: tk.name,
|
|
@@ -481,6 +487,7 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
|
|
|
481
487
|
row.shortSignal = rawShortHit && !shortBlockedByBroker;
|
|
482
488
|
if (shortBlockedByBroker) row.shortBlockedByBroker = true;
|
|
483
489
|
if (shortBlockedBySqueeze) row.shortBlockedBySqueeze = true;
|
|
490
|
+
if (shortBlockedByTime) row.shortBlockedByTime = true;
|
|
484
491
|
}
|
|
485
492
|
// контекст торгового дня (экстремумы, движение к закрытию прошлого дня) — для фильтра входа против события, стопа и журнала решений
|
|
486
493
|
const dc = dayContext(ds, i, idx, f.sigma[i] * 100, livePx);
|
|
@@ -531,6 +538,11 @@ function withNews(result, p) {
|
|
|
531
538
|
} else {
|
|
532
539
|
delete r.shortBlockedBySqueeze;
|
|
533
540
|
}
|
|
541
|
+
if (r.expectedPct < 0 && r.shortBlockedByTime) {
|
|
542
|
+
r.shortSignal = false;
|
|
543
|
+
} else if (r.expectedPct >= 0) {
|
|
544
|
+
delete r.shortBlockedByTime;
|
|
545
|
+
}
|
|
534
546
|
if (r.expectedPct > 0 && (r.buyBlockedByDip || r.regimeCrash || (r.lastMovePct ?? 0) < -4.5)) {
|
|
535
547
|
r.buyBlockedByDip = true;
|
|
536
548
|
r.signal = false;
|
|
@@ -648,6 +660,7 @@ async function updateNearSignalsJournal(ds, p, last, key) {
|
|
|
648
660
|
if (side === 'short') {
|
|
649
661
|
if (p.modelDirection === 'long') reasons.push('режим «только в рост»');
|
|
650
662
|
if (row.shortBlockedBySqueeze || row.regimeSqueeze || (row.lastMovePct ?? 0) > 4.5) reasons.push('защита от сквиза');
|
|
663
|
+
if (row.shortBlockedByTime) reasons.push('вне окна шорта по времени сессии');
|
|
651
664
|
if (row.shortBlockedByBroker || row.shortAllowed === false) reasons.push('запрет шорта у брокера');
|
|
652
665
|
if (dg?.blockShort) reasons.push(`отсечка дивидендов ${dg.exDate}`);
|
|
653
666
|
if (bNews?.good) reasons.push(`фильтр новостей (${bNews.good.gist})`);
|
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));
|