neuroalpha 0.6.10 → 0.6.12
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/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +9 -6
- package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +27 -19
- package/package.json +1 -1
- package/public/app.js +14 -0
- package/public/index.html +4 -0
- package/server/brokers/index.js +180 -148
- package/server/model/edge.js +4 -3
- package/server/model/policy.js +190 -19
- package/server/model/research.js +5 -3
- package/server/model/simulator.js +151 -21
- package/server/strategist/index.js +24 -9
package/server/brokers/index.js
CHANGED
|
@@ -13,7 +13,10 @@ import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
|
|
|
13
13
|
import { forecastNow } from '../model/forecast.js';
|
|
14
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
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
STRATEGY_POLICY, sessionEdgeWarning, evaluateProfitLock, protectiveStopPct, exitHoldRatio,
|
|
18
|
+
planSizingProfile, convictionMult, tierSizeMult, symbolCapRub, targetPositionRub, waterFillAllocations, MAX_LEVERAGE,
|
|
19
|
+
} from '../model/policy.js';
|
|
17
20
|
import { MODELS } from '../model/models.js';
|
|
18
21
|
import { blockingNews } from '../news/index.js';
|
|
19
22
|
import { round, MSK, DAY, fmtBarMsk } from '../util.js';
|
|
@@ -2093,32 +2096,25 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2093
2096
|
return isStockQuarantined(q.cnt, q.pnlPct / 100, edgeRatio);
|
|
2094
2097
|
};
|
|
2095
2098
|
const slots = dir === 'long' ? K : 2 * K;
|
|
2096
|
-
//
|
|
2097
|
-
// • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов),
|
|
2098
|
-
// • Профи: до 5 счетов по 1 млн ₽, покупки + шорты,
|
|
2099
|
-
// • VIP: счета и сумма по договору,
|
|
2100
|
-
// • Хозяин: Conviction Sizing до
|
|
2099
|
+
// Профиль мощности слотов и индивидуальных потолков по тарифу (единый с бэктестом, server/model/policy.js):
|
|
2100
|
+
// • Инвестор: 1 счёт до 1 млн ₽, только покупки (без шортов), слот до 58% T1 / 14% T2
|
|
2101
|
+
// • Профи: до 5 счетов по 1 млн ₽, покупки + шорты, плечо до 3×, слот до 52% T1 / 12% T2
|
|
2102
|
+
// • VIP: счета и сумма по договору, Conviction Sizing до 75% T1 / 14% T2
|
|
2103
|
+
// • Хозяин: живой Conviction Sizing до 92% T1 лонг / 70% T1 шорт (с сублинейным ростом по плечу)
|
|
2101
2104
|
const isOwnerPlan = Boolean(plan.isOwner || plan.id === 'owner');
|
|
2105
|
+
const sizingProfile = planSizingProfile(isOwnerPlan ? 'owner' : (vipBoost ? 'vip' : (plan.id || 'investor')), dir);
|
|
2102
2106
|
let slotRub;
|
|
2103
|
-
if (
|
|
2104
|
-
|
|
2105
|
-
slotRub = budget * maxSlotWeight;
|
|
2106
|
-
} else if (vipBoost && autoAdapt) {
|
|
2107
|
-
const maxSlotWeight = Math.min(0.40, Math.max(1 / K, 1.4 / K));
|
|
2108
|
-
slotRub = budget * maxSlotWeight;
|
|
2107
|
+
if (autoAdapt) {
|
|
2108
|
+
slotRub = budget * sizingProfile.slotW;
|
|
2109
2109
|
} else if (plan.id === 'pro') {
|
|
2110
2110
|
slotRub = budget * Math.min(0.28, 1.15 / Math.max(2, slots));
|
|
2111
2111
|
} else {
|
|
2112
2112
|
slotRub = budget * Math.min(0.24, 1.05 / Math.max(3, K + 1));
|
|
2113
2113
|
}
|
|
2114
|
-
// (Потолки доходности по тарифам и «дроссель» размера позиций удалены: тарифы различаются возможностями,
|
|
2115
|
-
// а не обещанной доходностью — реалистичная доходность определяется стресс-тестом, а не тарифом.)
|
|
2116
2114
|
const needPct = Math.max(0.01, Number(fc.thresholdPct || 0));
|
|
2117
2115
|
const convOf = (r) => {
|
|
2118
2116
|
if (!autoAdapt || !r.expectedPct) return 1;
|
|
2119
|
-
|
|
2120
|
-
if (vipBoost) return Math.min(1.30, Math.max(0.85, 0.80 + 0.25 * (Math.abs(r.expectedPct) / needPct)));
|
|
2121
|
-
return 1;
|
|
2117
|
+
return convictionMult(Math.abs(r.expectedPct) / needPct, sizingProfile);
|
|
2122
2118
|
};
|
|
2123
2119
|
const heldMap = new Map((acc.lastSync?.positions || []).map((p) => [p.symbol, p]));
|
|
2124
2120
|
const rowMap = new Map((fc.rows || []).map((r) => [r.symbol, r]));
|
|
@@ -2315,67 +2311,21 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2315
2311
|
let usedRub = [...heldMap.values()].filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) !== 0 && !closing.has(p.symbol)).reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0)
|
|
2316
2312
|
+ (onlyEntries ? activePendingOrders.reduce((s, o) => s + Math.abs(Number(o.amountRub) || 0), 0) : 0);
|
|
2317
2313
|
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
const
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
if (!aQ) continue;
|
|
2332
|
-
const rCur = rowMap.get(sym);
|
|
2333
|
-
const curSignedPct = aQ > 0 ? (rCur.expectedPct || 0) : -(rCur.expectedPct || 0);
|
|
2334
|
-
if (curSignedPct < exitThresholdPct * 0.90 && curSignedPct < weakestEdge && bestNewPct > Math.max(needPct, curSignedPct) * 2.4) {
|
|
2335
|
-
weakestEdge = curSignedPct;
|
|
2336
|
-
weakestSym = sym;
|
|
2337
|
-
}
|
|
2338
|
-
}
|
|
2339
|
-
if (weakestSym) {
|
|
2340
|
-
const wPos = heldMap.get(weakestSym);
|
|
2341
|
-
const wRow = rowMap.get(weakestSym);
|
|
2342
|
-
const wSp = specs[weakestSym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
|
|
2343
|
-
const wAq = algoQtyOf(weakestSym, wPos.qty);
|
|
2344
|
-
const wLots = Math.floor(Math.abs(wAq) / wSp.lotSize);
|
|
2345
|
-
const wPx = Number(wRow?.price || wPos.price) || 0;
|
|
2346
|
-
if (wLots >= 1 && wPx > 0) {
|
|
2347
|
-
const isLongPos = wAq > 0;
|
|
2348
|
-
const limPx = sniperLimitPrice(wPx, isLongPos ? -1 : 1, acc.limitOffsetPct, wSp);
|
|
2349
|
-
orders.push({
|
|
2350
|
-
symbol: weakestSym,
|
|
2351
|
-
action: isLongPos ? 'sell' : 'cover',
|
|
2352
|
-
lotSize: wSp.lotSize,
|
|
2353
|
-
lots: wLots,
|
|
2354
|
-
shares: wLots * wSp.lotSize,
|
|
2355
|
-
refPrice: wPx,
|
|
2356
|
-
limitPrice: limPx,
|
|
2357
|
-
amountRub: round(wLots * wSp.lotSize * limPx, 2),
|
|
2358
|
-
reason: `Ротация капитала из остывшей бумаги (${wRow?.expectedPct > 0 ? '+' : ''}${wRow?.expectedPct}%) в нового сильного лидера (${bestNewPct}%)`,
|
|
2359
|
-
});
|
|
2360
|
-
closing.add(weakestSym);
|
|
2361
|
-
if (isLongPos) activeLongs = Math.max(0, activeLongs - 1);
|
|
2362
|
-
else activeShorts = Math.max(0, activeShorts - 1);
|
|
2363
|
-
usedRub = Math.max(0, usedRub - Math.abs(wAq * wPos.price));
|
|
2364
|
-
}
|
|
2365
|
-
}
|
|
2366
|
-
}
|
|
2367
|
-
}
|
|
2368
|
-
const freedRub = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
|
|
2369
|
-
const coverSpentRub = orders.filter((o) => o.action === 'cover').reduce((s, o) => s + o.amountRub, 0);
|
|
2370
|
-
const openShortRub = [...heldMap.values()]
|
|
2371
|
-
.filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
|
|
2372
|
-
.reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0);
|
|
2373
|
-
const cashLimit = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freedRub - coverSpentRub - openShortRub : budget;
|
|
2374
|
-
let availRub = marginSizing
|
|
2375
|
-
? Math.max(0, budget * 0.99 * leverage - usedRub)
|
|
2376
|
-
: Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLimit));
|
|
2314
|
+
const recomputeAvailRub = () => {
|
|
2315
|
+
const freed = orders.filter((o) => o.action === 'sell').reduce((s, o) => s + o.amountRub, 0);
|
|
2316
|
+
const coverSpent = orders.filter((o) => o.action === 'cover').reduce((s, o) => s + o.amountRub, 0);
|
|
2317
|
+
const trimmedShortRub = orders.filter((o) => o.action === 'cover' && o.isTrim).reduce((s, o) => s + o.amountRub, 0);
|
|
2318
|
+
const openShortRub = Math.max(0, [...heldMap.values()]
|
|
2319
|
+
.filter((p) => rowMap.has(p.symbol) && algoQtyOf(p.symbol, p.qty) < 0 && !closing.has(p.symbol))
|
|
2320
|
+
.reduce((s, p) => s + Math.abs(algoQtyOf(p.symbol, p.qty) * p.price), 0) - trimmedShortRub);
|
|
2321
|
+
const cashLim = acc.lastSync?.cashRub !== undefined ? acc.lastSync.cashRub + freed - coverSpent - openShortRub : budget;
|
|
2322
|
+
return marginSizing
|
|
2323
|
+
? Math.max(0, budget * 0.99 * leverage - usedRub)
|
|
2324
|
+
: Math.min(Math.max(0, budget * 0.99 - usedRub), Math.max(0, cashLim));
|
|
2325
|
+
};
|
|
2326
|
+
let availRub = recomputeAvailRub();
|
|
2377
2327
|
|
|
2378
|
-
|
|
2328
|
+
// 2) вход в лучшие сигналы (с учётом лотности Мосбиржи, свободного лимита и адаптивного отбора числа акций Dynamic Top-K)
|
|
2379
2329
|
const rawNewBuys = buyCands.filter((r) => !(algoQtyOf(r.symbol, heldMap.get(r.symbol)?.qty || 0) > 0 && !closing.has(r.symbol)) && !(onlyEntries && activeOrderSymbols.has(r.symbol)));
|
|
2380
2330
|
// Не открываем шорт по тикеру, в котором ещё лежит непроданный остаток старого портфеля (baseQty > 0), чтобы брокер не превратил шорт в продажу старой позиции
|
|
2381
2331
|
const rawNewShorts = shortCands.filter((r) => {
|
|
@@ -2395,30 +2345,53 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2395
2345
|
const newBuys = buyExhaustion.ready;
|
|
2396
2346
|
const newShorts = shortExhaustion.ready;
|
|
2397
2347
|
const deferredByImpulse = [...buyExhaustion.deferred, ...shortExhaustion.deferred];
|
|
2398
|
-
const
|
|
2399
|
-
|
|
2400
|
-
const
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2348
|
+
const dynSlotRub = slotRub;
|
|
2349
|
+
|
|
2350
|
+
const netPickWeight = (r) => {
|
|
2351
|
+
const rawPct = Math.abs(r.expectedPct || 0);
|
|
2352
|
+
if (!autoAdapt) return Math.max(0.01, rawPct);
|
|
2353
|
+
const roundTripCostPct = TIER1_LIQUID_SYMBOLS.has(r.symbol) ? 0.13 : 0.25;
|
|
2354
|
+
return Math.max(0.01, rawPct - roundTripCostPct);
|
|
2355
|
+
};
|
|
2356
|
+
const calcDesiredNewRub = (r, side) => {
|
|
2357
|
+
const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
|
|
2358
|
+
const posSide = side === 'short' ? 'short' : 'long';
|
|
2359
|
+
const tierMult = autoAdapt ? tierSizeMult(r.symbol, leverage, posSide) : 1;
|
|
2360
|
+
const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
|
|
2361
|
+
const stopPct = protectiveStopPct(posSide);
|
|
2362
|
+
const effSlotRub = (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * tierMult;
|
|
2363
|
+
const targetRub = autoAdapt
|
|
2364
|
+
? targetPositionRub({ symbol: r.symbol, side: posSide, budget: budget * tierMult, slotRub: effSlotRub, conv: convOf(r), stopPct, barTurnoverRub, profile: sizingProfile })
|
|
2365
|
+
: Math.min(symbolCapRub(r.symbol, posSide, budget * tierMult, barTurnoverRub), effSlotRub);
|
|
2366
|
+
return { targetRub, tierMult, stopPct };
|
|
2367
|
+
};
|
|
2368
|
+
|
|
2369
|
+
const combinedNew = [
|
|
2370
|
+
...newBuys.map((r) => ({ r, side: 'buy' })),
|
|
2371
|
+
...newShorts.map((r) => ({ r, side: 'short' })),
|
|
2372
|
+
];
|
|
2373
|
+
if (autoAdapt && combinedNew.length > 1) {
|
|
2374
|
+
combinedNew.sort((a, b) => netPickWeight(b.r) - netPickWeight(a.r));
|
|
2375
|
+
}
|
|
2376
|
+
const preparedNew = combinedNew.map(({ r, side }) => {
|
|
2377
|
+
const { targetRub, tierMult, stopPct } = calcDesiredNewRub(r, side);
|
|
2378
|
+
return { r, side, weight: netPickWeight(r), targetRub, tierMult, stopPct };
|
|
2379
|
+
}).filter((x) => x.targetRub > 5000);
|
|
2380
|
+
const totalDesiredNewRub = preparedNew.reduce((s, x) => s + x.targetRub, 0);
|
|
2405
2381
|
|
|
2406
2382
|
// В режиме «Плавный переход» (smooth):
|
|
2407
|
-
// • высвобождаем кэш из старого портфеля как под новые ПОКУПКИ, так и под новые
|
|
2408
|
-
// • даже если прямо сейчас нет нового входа, но свободного кэша на счёте меньше 1 рабочего слота, плавно продаём на каждом шаге по 1–2 самые слабые старые акции с отрицательным прогнозом (expectedPct <= -0.02%)
|
|
2409
|
-
// • сортируем кандидатов строго по худшему прогнозу ИИ (сначала акции с отрицательным ожиданием, среди близких — позиции в плюсе или с минимальным убытком), до 4 бумаг за шаг при активном сигнале.
|
|
2383
|
+
// • высвобождаем кэш из старого портфеля как под новые ПОКУПКИ, так и под новые ШОРТЫ;
|
|
2384
|
+
// • даже если прямо сейчас нет нового входа, но свободного кэша на счёте меньше 1 рабочего слота, плавно продаём на каждом шаге по 1–2 самые слабые старые акции с отрицательным прогнозом (expectedPct <= -0.02%).
|
|
2410
2385
|
if (legacyMode === 'smooth' && smoothSellPool.length > 0) {
|
|
2411
|
-
const activeEntries =
|
|
2412
|
-
const neededForSignalsRub = activeEntries.reduce((s, r) => s + (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r), 0);
|
|
2386
|
+
const activeEntries = preparedNew.map((x) => x.r);
|
|
2413
2387
|
const hasWeakLegacy = smoothSellPool.some((x) => (x.r.expectedPct || 0) <= -0.02);
|
|
2414
2388
|
const proactiveReserveRub = (!activeEntries.length && hasWeakLegacy && availRub < slotRub) ? slotRub : 0;
|
|
2415
|
-
const targetNeedRub = Math.max(
|
|
2389
|
+
const targetNeedRub = Math.max(totalDesiredNewRub, proactiveReserveRub);
|
|
2416
2390
|
let shortfall = Math.min(Math.max(0, budget * 0.99 - usedRub), targetNeedRub) - availRub;
|
|
2417
2391
|
if (shortfall > 1000) {
|
|
2418
2392
|
smoothSellPool.sort((a, b) => {
|
|
2419
2393
|
const ea = Number(a.r.expectedPct || 0);
|
|
2420
2394
|
const eb = Number(b.r.expectedPct || 0);
|
|
2421
|
-
// Сначала бумаги с явно худшим прогнозом (разница > 0.03%), при близком прогнозе — сначала плюсовые/околонулевые
|
|
2422
2395
|
if (Math.abs(ea - eb) > 0.03) return ea - eb;
|
|
2423
2396
|
return ((b.pos.pnlRub >= 0 ? 1 : 0) - (a.pos.pnlRub >= 0 ? 1 : 0)) || (ea - eb);
|
|
2424
2397
|
});
|
|
@@ -2445,27 +2418,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2445
2418
|
}
|
|
2446
2419
|
}
|
|
2447
2420
|
}
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
...newShorts.map((r) => ({ r, side: 'short' })),
|
|
2452
|
-
];
|
|
2453
|
-
// Если включено «Свободные рубли в LQDT» (cashSweep) и для новых сигналов не хватает свободных рублей,
|
|
2454
|
-
// сначала автоматически продаём нужное число паёв фонда ликвидности LQDT, которые открыла стратегия (сверх неприкосновенного baselineQty)
|
|
2455
|
-
if (!collateral && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length > 0 && !closing.has('LQDT') && !activeOrderSymbols.has('LQDT')) {
|
|
2421
|
+
|
|
2422
|
+
// Если включено «Свободные рубли в LQDT» (cashSweep) и счёт без залогового плеча (!collateral), при нехватке рублей продаём нужное число паёв LQDT
|
|
2423
|
+
if (!collateral && acc.cashSweep && acc.broker === 'tbank' && preparedNew.length > 0 && !closing.has('LQDT') && !activeOrderSymbols.has('LQDT')) {
|
|
2456
2424
|
const lqdtPos = heldMap.get('LQDT');
|
|
2457
2425
|
const lqdtAlgoQty = lqdtPos ? algoQtyOf('LQDT', lqdtPos.qty) : 0;
|
|
2458
2426
|
if (lqdtAlgoQty > 0 && lqdtPos.price > 0) {
|
|
2459
2427
|
const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.001, decimals: 3 };
|
|
2460
2428
|
const lqdtLots = Math.floor(lqdtAlgoQty / lqdtSp.lotSize);
|
|
2461
|
-
const
|
|
2462
|
-
const isT1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
|
|
2463
|
-
const rawCap = budget * (side === 'short' ? (isT1 ? 0.35 : 0.18) : (isT1 ? 0.45 : 0.22));
|
|
2464
|
-
const barTurnRub = !isT1 ? recentBarTurnoverRub(r.symbol) : 0;
|
|
2465
|
-
const liqCap = (!isT1 && barTurnRub > 0) ? Math.max(45000, Math.min(rawCap, barTurnRub * 0.12)) : rawCap;
|
|
2466
|
-
return s + Math.min(liqCap, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * convOf(r));
|
|
2467
|
-
}, 0);
|
|
2468
|
-
const needFromLqdtRub = Math.min(Math.max(0, budget * 0.99 - usedRub), neededForNewRub) - availRub;
|
|
2429
|
+
const needFromLqdtRub = Math.min(Math.max(0, budget * 0.99 - usedRub), totalDesiredNewRub) - availRub;
|
|
2469
2430
|
if (lqdtLots >= 1 && needFromLqdtRub > 1500) {
|
|
2470
2431
|
const limPx = sniperLimitPrice(lqdtPos.price, -1, 0.03, lqdtSp);
|
|
2471
2432
|
const lotVal = limPx * lqdtSp.lotSize;
|
|
@@ -2474,46 +2435,114 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2474
2435
|
orders.push({
|
|
2475
2436
|
symbol: 'LQDT', action: 'sell', lotSize: lqdtSp.lotSize, lots: sellLots, shares: sellLots * lqdtSp.lotSize,
|
|
2476
2437
|
refPrice: lqdtPos.price, limitPrice: limPx, amountRub,
|
|
2477
|
-
reason: `Высвобождение ${fmtRubSimple(amountRub)} ₽ из фонда денежного рынка LQDT под сигнал стратегии (${
|
|
2438
|
+
reason: `Высвобождение ${fmtRubSimple(amountRub)} ₽ из фонда денежного рынка LQDT под сигнал стратегии (${preparedNew.map((x) => x.r.symbol).join(', ')})`,
|
|
2478
2439
|
});
|
|
2479
2440
|
closing.add('LQDT');
|
|
2480
2441
|
availRub += amountRub;
|
|
2481
2442
|
}
|
|
2482
2443
|
}
|
|
2483
2444
|
}
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2445
|
+
|
|
2446
|
+
// Двухступенчатое динамическое потеснение под новый сильный сигнал 1-го эшелона (единое с server/model/simulator.js):
|
|
2447
|
+
// в режиме auto работает и на быстрых внутрисвечных входах, а в режиме confirm — на плановых 30-минутных шагах (!onlyEntries),
|
|
2448
|
+
// ротируя остывшие позиции либо частично подрезая позицию 2-го эшелона (с сохранением entryTs и peakPrice оставшихся лотов).
|
|
2449
|
+
if (autoAdapt && STRATEGY_POLICY.crowdOut !== false && (acc.mode === 'auto' || !onlyEntries) && preparedNew.length > 0) {
|
|
2450
|
+
const bestT1 = preparedNew.find((x) => TIER1_LIQUID_SYMBOLS.has(x.r.symbol));
|
|
2451
|
+
const minChunkRub = Math.max(45000, budget * (STRATEGY_POLICY.crowdMinPct ?? 0.06));
|
|
2452
|
+
let shortfall = totalDesiredNewRub - availRub;
|
|
2453
|
+
if (bestT1 && shortfall >= minChunkRub) {
|
|
2454
|
+
const minHoldMs = (STRATEGY_POLICY.crowdMinMin ?? 20) * 60000;
|
|
2455
|
+
const rotBufPct = STRATEGY_POLICY.crowdRotBufPct ?? 0.08;
|
|
2456
|
+
const trimBufPct = STRATEGY_POLICY.crowdTrimBufPct ?? 0.12;
|
|
2457
|
+
const elig = [];
|
|
2458
|
+
for (const [sym, pos] of heldMap) {
|
|
2459
|
+
if (closing.has(sym) || activeOrderSymbols.has(sym) || !rowMap.has(sym)) continue;
|
|
2460
|
+
const aQ = algoQtyOf(sym, pos.qty);
|
|
2461
|
+
if (!aQ) continue;
|
|
2462
|
+
// На безналичном счёте без маржиналки закрытие шорта тратит кэш, а не высвобождает его под покупку
|
|
2463
|
+
if (!marginSizing && aQ < 0) continue;
|
|
2464
|
+
const entryAt = Number(acc.entryTsBySymbol?.[sym]) || 0;
|
|
2465
|
+
if (entryAt > 0 && nowTs - entryAt < minHoldMs) continue;
|
|
2466
|
+
const sp = specs[sym] || { lotSize: 1, minStep: 0.01, decimals: 2 };
|
|
2467
|
+
const totalLots = Math.floor(Math.abs(aQ) / sp.lotSize);
|
|
2468
|
+
const rCur = rowMap.get(sym);
|
|
2469
|
+
const px = Number(rCur?.price || pos.price) || 0;
|
|
2470
|
+
if (totalLots < 1 || !(px > 0)) continue;
|
|
2471
|
+
const isLongPos = aQ > 0;
|
|
2472
|
+
const curSignedPct = isLongPos ? Number(rCur?.expectedPct || 0) : -Number(rCur?.expectedPct || 0);
|
|
2473
|
+
const thrEntryPct = isLongPos ? reqBuyEdgeOf(sym) : reqShortEdgeOf(sym);
|
|
2474
|
+
const heldIsT1 = TIER1_LIQUID_SYMBOLS.has(sym);
|
|
2475
|
+
elig.push({ sym, pos, rCur, sp, totalLots, px, isLongPos, curSignedPct, thrEntryPct, heldIsT1, posRub: totalLots * sp.lotSize * px, tierRank: heldIsT1 ? 1 : 0 });
|
|
2476
|
+
}
|
|
2477
|
+
elig.sort((a, b) => (a.tierRank - b.tierRank) || (a.curSignedPct - b.curSignedPct));
|
|
2478
|
+
|
|
2479
|
+
for (const el of elig) {
|
|
2480
|
+
if (shortfall < minChunkRub * 0.5) break;
|
|
2481
|
+
const coolRatio = el.heldIsT1 ? (STRATEGY_POLICY.crowdCoolRatioT1 ?? 0.45) : (STRATEGY_POLICY.crowdCoolRatioT2 ?? 0.80);
|
|
2482
|
+
const cooled = el.curSignedPct < el.thrEntryPct * coolRatio;
|
|
2483
|
+
const netAdvPct = bestT1.weight - Math.max(0, el.curSignedPct);
|
|
2484
|
+
const limPx = sniperLimitPrice(el.px, el.isLongPos ? -1 : 1, acc.limitOffsetPct, el.sp);
|
|
2485
|
+
const lotVal = limPx * el.sp.lotSize;
|
|
2486
|
+
if (!(lotVal > 0)) continue;
|
|
2487
|
+
|
|
2488
|
+
let lotsToClose = 0;
|
|
2489
|
+
let fullRotate = false;
|
|
2490
|
+
if (cooled && netAdvPct >= rotBufPct) {
|
|
2491
|
+
if (!el.heldIsT1 || el.curSignedPct < el.thrEntryPct * 0.35 || el.posRub - shortfall < minChunkRub) {
|
|
2492
|
+
lotsToClose = el.totalLots;
|
|
2493
|
+
fullRotate = true;
|
|
2494
|
+
} else {
|
|
2495
|
+
const maxTrimRub = el.posRub - minChunkRub;
|
|
2496
|
+
const trimRub = Math.min(shortfall, maxTrimRub);
|
|
2497
|
+
if (trimRub >= minChunkRub) lotsToClose = Math.min(el.totalLots - 1, Math.max(1, Math.ceil(trimRub / lotVal)));
|
|
2498
|
+
}
|
|
2499
|
+
} else if (!cooled && !el.heldIsT1 && netAdvPct >= trimBufPct && el.posRub >= minChunkRub * 1.5) {
|
|
2500
|
+
const maxTrimRub = el.posRub - minChunkRub;
|
|
2501
|
+
const trimRub = Math.min(shortfall, maxTrimRub);
|
|
2502
|
+
if (trimRub >= minChunkRub) lotsToClose = Math.min(el.totalLots - 1, Math.max(1, Math.ceil(trimRub / lotVal)));
|
|
2503
|
+
}
|
|
2504
|
+
if (lotsToClose >= 1) {
|
|
2505
|
+
const isTrim = !fullRotate && lotsToClose < el.totalLots;
|
|
2506
|
+
const amountRub = round(lotsToClose * el.sp.lotSize * limPx, 2);
|
|
2507
|
+
const freedExpRub = lotsToClose * el.sp.lotSize * el.pos.price;
|
|
2508
|
+
orders.push({
|
|
2509
|
+
symbol: el.sym,
|
|
2510
|
+
action: el.isLongPos ? 'sell' : 'cover',
|
|
2511
|
+
lotSize: el.sp.lotSize,
|
|
2512
|
+
lots: lotsToClose,
|
|
2513
|
+
shares: lotsToClose * el.sp.lotSize,
|
|
2514
|
+
refPrice: el.px,
|
|
2515
|
+
limitPrice: limPx,
|
|
2516
|
+
amountRub,
|
|
2517
|
+
isTrim,
|
|
2518
|
+
urgentExit: true,
|
|
2519
|
+
reason: isTrim
|
|
2520
|
+
? `Частичное потеснение (${lotsToClose} из ${el.totalLots} лот., прогноз ${el.rCur?.expectedPct > 0 ? '+' : ''}${el.rCur?.expectedPct}%) под сильный сигнал 1-го эшелона ${bestT1.r.symbol} (${bestT1.r.expectedPct > 0 ? '+' : ''}${bestT1.r.expectedPct}%)`
|
|
2521
|
+
: `Ротация капитала из остывшей бумаги (${el.rCur?.expectedPct > 0 ? '+' : ''}${el.rCur?.expectedPct}%) в флагман 1-го эшелона ${bestT1.r.symbol} (${bestT1.r.expectedPct > 0 ? '+' : ''}${bestT1.r.expectedPct}%)`,
|
|
2522
|
+
});
|
|
2523
|
+
if (!isTrim) {
|
|
2524
|
+
closing.add(el.sym);
|
|
2525
|
+
if (el.isLongPos) activeLongs = Math.max(0, activeLongs - 1);
|
|
2526
|
+
else activeShorts = Math.max(0, activeShorts - 1);
|
|
2527
|
+
}
|
|
2528
|
+
usedRub = Math.max(0, usedRub - freedExpRub);
|
|
2529
|
+
availRub = recomputeAvailRub();
|
|
2530
|
+
shortfall = totalDesiredNewRub - availRub;
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
// Итеративное распределение свободного ресурса методом перелива (waterFillAllocations в policy.js):
|
|
2537
|
+
// кандидаты с узким потолком (2-й эшелон / лимит 15% от 10-минутного оборота свечи) забирают ровно свой потолок,
|
|
2538
|
+
// а весь оставшийся лимит без холостого простоя переливается флагманским лидерам 1-го эшелона.
|
|
2539
|
+
const allocSums = autoAdapt
|
|
2540
|
+
? waterFillAllocations(preparedNew, availRub)
|
|
2541
|
+
: Float64Array.from(preparedNew.map((x) => Math.min(x.targetRub, availRub / Math.max(1, preparedNew.length))));
|
|
2542
|
+
for (let idx = 0; idx < preparedNew.length; idx++) {
|
|
2543
|
+
const { r, side, targetRub, tierMult, stopPct } = preparedNew[idx];
|
|
2503
2544
|
const isTier1 = TIER1_LIQUID_SYMBOLS.has(r.symbol);
|
|
2504
|
-
|
|
2505
|
-
const tierMult = autoAdapt ? tierSizeMult(r.symbol, leverage) : 1;
|
|
2506
|
-
const rawSideCapRub = tierMult * (side === 'short'
|
|
2507
|
-
? (isTier1 ? maxShortTier1Rub : maxShortTier2Rub)
|
|
2508
|
-
: (isTier1 ? maxLongTier1Rub : maxLongTier2Rub));
|
|
2509
|
-
// Лимит от реального 10-минутного оборота стакана (Liquidity Cap): во 2-м эшелоне не входим объёмом больше 12% от 10-минутного оборота биржи,
|
|
2510
|
-
// чтобы заявка не становилась «стенкой» для стаканных роботов и исполнялась целиком без проскальзывания
|
|
2511
|
-
const barTurnoverRub = !isTier1 ? recentBarTurnoverRub(r.symbol) : 0;
|
|
2512
|
-
const sideCapRub = (!isTier1 && barTurnoverRub > 0)
|
|
2513
|
-
? Math.max(45000, Math.min(rawSideCapRub, barTurnoverRub * 0.12))
|
|
2514
|
-
: rawSideCapRub;
|
|
2515
|
-
const rawSum = Math.min(sideCapRub, (params.modelAllocMode === 'invvol' && r.weight ? dynSlotRub * r.weight : dynSlotRub) * tierMult * convOf(r));
|
|
2516
|
-
const sum = Math.min(rawSum, propShare, availRub);
|
|
2545
|
+
const sum = Math.min(allocSums[idx], availRub);
|
|
2517
2546
|
const refPx = r.price || specs[r.symbol]?.lastPrice || 0;
|
|
2518
2547
|
const effOffsetPct = isTier1 ? Math.min(Number(acc.limitOffsetPct) || 0.05, 0.06) : Math.min(Number(acc.limitOffsetPct) || 0.04, 0.04);
|
|
2519
2548
|
const ord = calcLotOrder(r.symbol, sum, refPx, side, effOffsetPct, specs);
|
|
@@ -2527,8 +2556,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false, scheduledBa
|
|
|
2527
2556
|
bar: fc.bar, expectedPct: r.expectedPct,
|
|
2528
2557
|
thresholdPct: side === 'buy' ? reqBuyEdgeOf(r.symbol) : reqShortEdgeOf(r.symbol),
|
|
2529
2558
|
edgeMult: r.edgeMult, sigmaBarPct: r.sigmaPct, lastMovePct: r.lastMovePct, selfPct: r.selfPct,
|
|
2530
|
-
marketDown: Boolean(fc.marketDown), conv: round(convOf(r), 3), budgetRub: budget, targetRub: round(
|
|
2531
|
-
stopPct
|
|
2559
|
+
marketDown: Boolean(fc.marketDown), conv: round(convOf(r), 3), budgetRub: budget, targetRub: round(targetRub, 0), leverage, tierMult, collateral,
|
|
2560
|
+
stopPct, day: r.day || null, onlyEntries: Boolean(onlyEntries),
|
|
2532
2561
|
},
|
|
2533
2562
|
reason: side === 'buy'
|
|
2534
2563
|
? `Покупка (${MODELS[params.modelType]?.label || params.modelType}): ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% за ${params.modelHorizon} ч`
|
|
@@ -2786,7 +2815,7 @@ async function sendOrderToBroker(acc, ord) {
|
|
|
2786
2815
|
// при узком спреде (<= 0,10% или <= 2 шагов) ставим ровно в лучшую встречную цену (bestAsk / bestBid),
|
|
2787
2816
|
// а при широком спреде встаём на середине спреда (midPx), не переплачивая стаканным роботам!
|
|
2788
2817
|
const tightExitSpread = spreadPct <= 0.10 || (bestAsk - bestBid) <= 2.1 * step;
|
|
2789
|
-
if (ord.isStopLoss && spreadPct <= 0.80) {
|
|
2818
|
+
if ((ord.isStopLoss || ord.urgentExit) && spreadPct <= (ord.isStopLoss ? 0.80 : 0.35)) {
|
|
2790
2819
|
const mktExitPx = roundToStep(isBuy ? bestAsk : bestBid, step, sp.decimals);
|
|
2791
2820
|
if (mktExitPx > 0) finalLimitPx = isBuy ? Math.max(finalLimitPx, mktExitPx) : Math.min(finalLimitPx, mktExitPx);
|
|
2792
2821
|
} else if (tightExitSpread) {
|
|
@@ -3178,10 +3207,12 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
3178
3207
|
delete acc.entryFailReasonBySymbol[ord.symbol];
|
|
3179
3208
|
} else if (ord.action === 'sell' || ord.action === 'cover') {
|
|
3180
3209
|
hadExits = true;
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3210
|
+
if (!ord.isTrim) {
|
|
3211
|
+
delete acc.entryTsBySymbol[ord.symbol];
|
|
3212
|
+
delete acc.peakPriceBySymbol[ord.symbol];
|
|
3213
|
+
if (ord.symbol !== 'LQDT') {
|
|
3214
|
+
acc.exitCooldownBySymbol[ord.symbol] = ord.isStopLoss ? execNowTs : (execNowTs - 30 * 60000);
|
|
3215
|
+
}
|
|
3185
3216
|
}
|
|
3186
3217
|
}
|
|
3187
3218
|
} catch (e) {
|
|
@@ -3516,8 +3547,9 @@ async function tickBrokerSchedule() {
|
|
|
3516
3547
|
const lqdtBaseQty = Number(acc.baselineQty?.LQDT || 0);
|
|
3517
3548
|
const lqdtAvailRub = lqdtPos ? Math.max(0, (lqdtPos.qty - (acc.legacyMode === 'takeover' ? 0 : Math.max(0, lqdtBaseQty))) * (lqdtPos.price || 0)) : 0;
|
|
3518
3549
|
const freeCashRub = Number(acc.lastSync?.cashRub ?? acc.maxCapitalRub ?? 0) + lqdtAvailRub;
|
|
3550
|
+
const canEnterOrCrowd = freeCashRub >= 15000 || (acc.marginEnabled && (Number(acc.maxLeverage) || 1) > 1) || hasOpenAlgoPos;
|
|
3519
3551
|
const marketActiveNow = Boolean(latest && nowTs - latest <= 25 * 60000);
|
|
3520
|
-
if (marketActiveNow && acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) &&
|
|
3552
|
+
if (marketActiveNow && acc.mode === 'auto' && acc.token && !acc.haltedReason && !(acc.skipEdgeMinutes && sessionEdgeWarning(nowTs)) && canEnterOrCrowd && nowTs - Math.max(lastFullWallTs, lastAnyWallTs) >= 20000) {
|
|
3521
3553
|
await prepareBrokerOrders(acc.id, { onlyEntries: true }).catch(() => {});
|
|
3522
3554
|
}
|
|
3523
3555
|
}
|
package/server/model/edge.js
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
export const ENTRY_EDGE_MULT = 1.60;
|
|
9
9
|
export const TIER2_EDGE_MULT = 1.20;
|
|
10
10
|
|
|
11
|
-
// Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный
|
|
12
|
-
//
|
|
11
|
+
// Голубые фишки и наиболее ликвидные акции 1-го эшелона Мосбиржи (глубокий стакан, минимальный спред, нулевая частота срабатывания 7%-го стопа):
|
|
12
|
+
// бумаги с повышенным хвостовым риском и широким спредом (SMLT, PIKK, POSI, AFKS, VKCO, SPBE, UGLD, HEAD, CBOM) идут по правилам 2-го эшелона (TIER2_EDGE_MULT).
|
|
13
13
|
export const TIER1_LIQUID_SYMBOLS = new Set([
|
|
14
14
|
'SBER', 'SBERP', 'LKOH', 'GAZP', 'T', 'YDEX', 'ROSN', 'NVTK', 'GMKN', 'PLZL',
|
|
15
15
|
'VTBR', 'TATN', 'TATNP', 'SNGS', 'SNGSP', 'ALRS', 'CHMF', 'NLMK', 'MAGN', 'MOEX',
|
|
16
|
-
'OZON', '
|
|
16
|
+
'OZON', 'PHOR', 'AFLT', 'MTSS', 'IRAO', 'TRNFP', 'RUAL', 'FLOT', 'X5',
|
|
17
|
+
'MTLR', 'BSPB', 'RTKM', 'SVCB',
|
|
17
18
|
]);
|
|
18
19
|
|
|
19
20
|
export function symbolEdgeMult(symbol, { sigma = 0, avgSigma = 0, avgBarValRub = 0 } = {}) {
|