neuroalpha 0.5.17 → 0.5.19
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 +61 -60
- package/public/admin.html +603 -571
- package/public/app.js +96 -18
- package/public/landing.html +299 -73
- package/public/splat3d.html +671 -0
- package/public/styles.css +12 -3
- package/scripts/set-admin-password.mjs +30 -0
- package/server/api/routes.js +41 -14
- package/server/auth/index.js +35 -4
- package/server/billing/yookassa.js +142 -133
- package/server/brokers/index.js +81 -50
- package/server/data/moex.js +6 -5
- package/server/db.js +1 -1
- package/server/index.js +3 -3
- package/server/instance/central.js +514 -505
- package/server/model/dataset.js +8 -9
- package/server/model/edge.js +41 -19
- package/server/model/forecast.js +111 -17
- package/server/model/live.js +16 -18
- package/server/model/research.js +41 -15
- package/server/strategist/index.js +758 -757
- package/server/strategist/tools.js +707 -675
package/server/brokers/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { getSettings, normalizeParams } from '../settings.js';
|
|
|
8
8
|
import { latestBarTs } from '../data/moex.js';
|
|
9
9
|
import { barsFromHours, BAR_INTERVAL, BAR_MS } from '../model/dataset.js';
|
|
10
10
|
import { forecastNow } from '../model/forecast.js';
|
|
11
|
-
import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/edge.js';
|
|
11
|
+
import { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from '../model/edge.js';
|
|
12
12
|
import { liveDividendGuard } from '../model/dividends.js';
|
|
13
13
|
import { MODELS } from '../model/models.js';
|
|
14
14
|
import { blockingNews } from '../news/index.js';
|
|
@@ -294,7 +294,8 @@ function computeExecutionAudit(a) {
|
|
|
294
294
|
// отсекаем старую историю автоследования (до последнего удержания комиссии автоследования)
|
|
295
295
|
const lastFollowTs = Math.max(0, ...sorted.filter((o) => o.kind === 'fee' && /автослед/i.test(o.title || '')).map((o) => o.date));
|
|
296
296
|
const algoOps = sorted.filter((o) => o.date > lastFollowTs);
|
|
297
|
-
const
|
|
297
|
+
const isCashFund = (sym) => sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM';
|
|
298
|
+
const trades = algoOps.filter((o) => (o.kind === 'buy' || o.kind === 'sell') && o.qty > 0 && o.price > 0 && o.symbol && !isCashFund(o.symbol));
|
|
298
299
|
if (!trades.length) return null;
|
|
299
300
|
|
|
300
301
|
let turnoverRub = 0;
|
|
@@ -313,7 +314,7 @@ function computeExecutionAudit(a) {
|
|
|
313
314
|
}
|
|
314
315
|
}
|
|
315
316
|
}
|
|
316
|
-
const feesRub = algoOps.filter((o) => o.kind === 'fee').reduce((s, o) => s + Math.abs(o.paymentRub || 0), 0);
|
|
317
|
+
const feesRub = algoOps.filter((o) => o.kind === 'fee' && !isCashFund(o.symbol)).reduce((s, o) => s + Math.abs(o.paymentRub || 0), 0);
|
|
317
318
|
const commPct = turnoverRub > 0 ? round((feesRub / turnoverRub) * 100, 3) : 0.04;
|
|
318
319
|
const slipAllPct = (rubW1 + rubW2) > 0 ? round((slipW1 + slipW2) / (rubW1 + rubW2), 3) : null;
|
|
319
320
|
const slipTier1Pct = rubW1 > 0 ? round(slipW1 / rubW1, 3) : null;
|
|
@@ -1484,8 +1485,8 @@ export async function freezeBrokerBaseline(id) {
|
|
|
1484
1485
|
function sessionEdgeWarning(now = Date.now()) {
|
|
1485
1486
|
const d = new Date(now + MSK);
|
|
1486
1487
|
const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
1487
|
-
//
|
|
1488
|
-
if ((mins >= 9 * 60 + 50 && mins <= 10 * 60 + 10) || (mins >= 18 * 60 + 35 && mins <= 18 * 60 + 55)) {
|
|
1488
|
+
// открытие утренней сессии 06:50–07:05, открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
|
|
1489
|
+
if ((mins >= 6 * 60 + 50 && mins <= 7 * 60 + 5) || (mins >= 9 * 60 + 50 && mins <= 10 * 60 + 10) || (mins >= 18 * 60 + 35 && mins <= 18 * 60 + 55)) {
|
|
1489
1490
|
return 'Первые или последние 10 минут сессии (широкий спред в стакане)';
|
|
1490
1491
|
}
|
|
1491
1492
|
return null;
|
|
@@ -1556,12 +1557,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1556
1557
|
const vipBoost = Boolean(plan.allowVipBoost);
|
|
1557
1558
|
const rawDir = params.modelDirection || 'long';
|
|
1558
1559
|
const dir = (marginAllowed && planAllowsShorts) ? rawDir : 'long';
|
|
1559
|
-
//
|
|
1560
|
-
|
|
1561
|
-
const exitRatio = autoAdapt ? 0.58 : Number(params.modelExitRatio ?? 0.5);
|
|
1560
|
+
// Единые пороги удержания с эталонным симулятором (research.js): первые 65 минут — мягкий порог 0.35, затем плотный порог 0.95 (с учётом эшелона)
|
|
1561
|
+
const exitRatio = autoAdapt ? 0.95 : Number(params.modelExitRatio ?? 0.5);
|
|
1562
1562
|
const exitThresholdPct = round((fc.thresholdPct || 0) * exitRatio, 3);
|
|
1563
|
-
const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.
|
|
1563
|
+
const earlyExitThresholdPct = round((fc.thresholdPct || 0) * 0.35, 3);
|
|
1564
1564
|
const nowTs = Date.now();
|
|
1565
|
+
const effHorizon = autoAdapt ? 2.5 : params.modelHorizon;
|
|
1566
|
+
const H = barsFromHours(effHorizon);
|
|
1567
|
+
const stepBars = autoAdapt ? Math.max(3, Math.floor(H / 5)) : H;
|
|
1568
|
+
const timeOk = !autoAdapt || canEnterIntraday(nowTs, H, BAR_INTERVAL);
|
|
1565
1569
|
const entryTsMap = acc.entryTsBySymbol || {};
|
|
1566
1570
|
const exitCooldownMap = acc.exitCooldownBySymbol || {};
|
|
1567
1571
|
// Спред-фильтр реального стакана по эшелонам ликвидности: для 2–3 эшелона порог выше в TIER2_EDGE_MULT раз.
|
|
@@ -1574,12 +1578,12 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1574
1578
|
const reqBuyEdgeOf = (sym) => round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
|
|
1575
1579
|
const reqShortEdgeOf = (sym) => round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
|
|
1576
1580
|
const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
|
|
1577
|
-
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг,
|
|
1578
|
-
// если за последние
|
|
1579
|
-
// временно исключаем такую бумагу из новых
|
|
1581
|
+
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
|
|
1582
|
+
// если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
|
|
1583
|
+
// временно исключаем такую бумагу из новых входов!
|
|
1580
1584
|
const quarantined = new Set();
|
|
1581
1585
|
if (autoAdapt && Array.isArray(acc.lastHistory?.operations)) {
|
|
1582
|
-
const cutoffTs = nowTs -
|
|
1586
|
+
const cutoffTs = nowTs - 21 * DAY;
|
|
1583
1587
|
const recentOps = acc.lastHistory.operations
|
|
1584
1588
|
.filter((op) => op.date >= cutoffTs && op.symbol && (op.kind === 'buy' || op.kind === 'sell' || op.kind === 'fee'))
|
|
1585
1589
|
.sort((a, b) => a.date - b.date);
|
|
@@ -1595,7 +1599,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1595
1599
|
}
|
|
1596
1600
|
for (const [sym, st] of Object.entries(statBySym)) {
|
|
1597
1601
|
const closedRounds = Math.min(st.buys, st.sells);
|
|
1598
|
-
|
|
1602
|
+
const netPct = st.buyRub > 10000 ? (st.netRub / st.buyRub) : 0;
|
|
1603
|
+
if (st.buyRub > 10000 && isStockQuarantined(closedRounds, netPct)) {
|
|
1599
1604
|
quarantined.add(sym);
|
|
1600
1605
|
}
|
|
1601
1606
|
}
|
|
@@ -1636,10 +1641,10 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1636
1641
|
// Дивидендный предохранитель (общие правила с бэктестом, server/model/dividends.js)
|
|
1637
1642
|
const divGuardOf = (sym, px) => liveDividendGuard(sym, Number(px) || specs[sym]?.lastPrice || 0, nowTs);
|
|
1638
1643
|
const divBlocked = [];
|
|
1639
|
-
const buyCands = fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
|
|
1644
|
+
const buyCands = !timeOk ? [] : fc.rows.filter((r) => r.signal && r.expectedPct >= reqBuyEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.bad)
|
|
1640
1645
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockLong) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
1641
1646
|
.sort((a, b) => b.expectedPct - a.expectedPct).slice(0, K);
|
|
1642
|
-
const allShortSignals = rawDir !== 'long' ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
|
|
1647
|
+
const allShortSignals = (rawDir !== 'long' && timeOk) ? fc.rows.filter((r) => r.shortSignal && r.expectedPct <= -reqShortEdgeOf(r.symbol) && !isInCooldown(r.symbol) && !quarantined.has(r.symbol) && !nb?.get(r.symbol)?.good)
|
|
1643
1648
|
.filter((r) => { const g = divGuardOf(r.symbol, r.price); if (g?.blockShort) { divBlocked.push(`${r.symbol} (отсечка ${g.exDate}, ${g.yieldPct}%)`); return false; } return true; })
|
|
1644
1649
|
.sort((a, b) => a.expectedPct - b.expectedPct) : [];
|
|
1645
1650
|
const nonShortableSkipped = [];
|
|
@@ -1688,51 +1693,58 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1688
1693
|
const effEntryTs = Math.max(entryTsMap[sym] || 0, lastHistEntryTs);
|
|
1689
1694
|
const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
|
|
1690
1695
|
// Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
|
|
1691
|
-
// чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95)
|
|
1692
|
-
const curExitThr = (autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct;
|
|
1696
|
+
// чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95) с учётом эшелона
|
|
1697
|
+
const curExitThr = round(((autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct) * symbolEdgeMult(sym), 3);
|
|
1698
|
+
const nightExit = !onlyEntries && autoAdapt && shouldExitBeforeNight(nowTs, effEntryTs, stepBars, BAR_INTERVAL);
|
|
1693
1699
|
const curPx = Number(r.price || pos.price) || 0;
|
|
1694
1700
|
const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
|
|
1695
1701
|
if (aQty > 0) {
|
|
1696
1702
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
1697
1703
|
const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
|
|
1698
|
-
const hitHardStopLoss = curGainPct <= -
|
|
1704
|
+
const hitHardStopLoss = curGainPct <= -5.0;
|
|
1699
1705
|
const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
|
|
1700
1706
|
const dg = divGuardOf(sym, curPx);
|
|
1701
1707
|
const divExit = Boolean(dg?.exitLong);
|
|
1702
|
-
const keep = keepByForecast && !hitHardStopLoss && !divExit;
|
|
1708
|
+
const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
|
|
1703
1709
|
if (lots >= 1 && !keep) {
|
|
1704
1710
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
1705
1711
|
const exitReason = hitHardStopLoss
|
|
1706
1712
|
? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
|
|
1707
1713
|
: divExit
|
|
1708
1714
|
? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
|
|
1709
|
-
:
|
|
1715
|
+
: nightExit
|
|
1716
|
+
? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
|
|
1717
|
+
: `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
|
|
1710
1718
|
orders.push({
|
|
1711
1719
|
symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1712
1720
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
1713
1721
|
reason: exitReason,
|
|
1722
|
+
isStopLoss: hitHardStopLoss,
|
|
1714
1723
|
});
|
|
1715
1724
|
closing.add(sym);
|
|
1716
1725
|
}
|
|
1717
1726
|
} else if (aQty < 0) {
|
|
1718
1727
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
1719
1728
|
const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
|
|
1720
|
-
const hitHardStopLoss = curGainPct <= -
|
|
1729
|
+
const hitHardStopLoss = curGainPct <= -4.5;
|
|
1721
1730
|
const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
|
|
1722
1731
|
const dg = divGuardOf(sym, curPx);
|
|
1723
1732
|
const divExit = Boolean(dg?.exitShort);
|
|
1724
|
-
const keep = keepByForecast && !hitHardStopLoss && !divExit;
|
|
1733
|
+
const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
|
|
1725
1734
|
if (lots >= 1 && !keep) {
|
|
1726
1735
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|
|
1727
1736
|
const exitReason = hitHardStopLoss
|
|
1728
1737
|
? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
|
|
1729
1738
|
: divExit
|
|
1730
1739
|
? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
|
|
1731
|
-
:
|
|
1740
|
+
: nightExit
|
|
1741
|
+
? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
|
|
1742
|
+
: `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
|
|
1732
1743
|
orders.push({
|
|
1733
1744
|
symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
|
|
1734
1745
|
refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
|
|
1735
1746
|
reason: exitReason,
|
|
1747
|
+
isStopLoss: hitHardStopLoss,
|
|
1736
1748
|
});
|
|
1737
1749
|
closing.add(sym);
|
|
1738
1750
|
}
|
|
@@ -1957,21 +1969,28 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1957
1969
|
}
|
|
1958
1970
|
}
|
|
1959
1971
|
|
|
1960
|
-
// При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых
|
|
1961
|
-
//
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
const
|
|
1965
|
-
const
|
|
1966
|
-
const
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
});
|
|
1972
|
+
// При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых заявок:
|
|
1973
|
+
// учитываем, что в Т-Банке сделка с LQDT облагается комиссией 0.04% на сторону (0.085% за круг со спредом), что окупается только за >= 2 дня удержания.
|
|
1974
|
+
// Поэтому покупаем LQDT только перед выходными (пятница вечер после закрытия внутридневного окна входов) либо если на счёте не было сделок по акциям более 24 часов!
|
|
1975
|
+
if (!onlyEntries && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length === 0 && !activePendingOrders.length && !edgeWarn && !orders.length) {
|
|
1976
|
+
const mskNow = new Date(nowTs + MSK);
|
|
1977
|
+
const isFridayNight = mskNow.getUTCDay() === 5 && !timeOk;
|
|
1978
|
+
const histOpsAll = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
|
|
1979
|
+
const hadStockTrade24h = histOpsAll.some((op) => (op.kind === 'buy' || op.kind === 'sell') && op.symbol && op.symbol !== 'LQDT' && op.symbol !== 'TMON@' && (nowTs - (op.date || 0) < DAY));
|
|
1980
|
+
if (isFridayNight || !hadStockTrade24h) {
|
|
1981
|
+
const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.0001, decimals: 4 };
|
|
1982
|
+
const lqdtPx = Number(heldMap.get('LQDT')?.price || lqdtSp.lastPrice || 0);
|
|
1983
|
+
const reserveRub = Math.max(5000, round(budget * 0.03, 0));
|
|
1984
|
+
const sweepRub = Math.min(availRub - reserveRub, (acc.lastSync?.cashRub || 0) - reserveRub);
|
|
1985
|
+
if (lqdtPx > 0 && sweepRub >= 20000) {
|
|
1986
|
+
const ord = calcLotOrder('LQDT', sweepRub, lqdtPx, 'buy', 0.02, { ...specs, LQDT: lqdtSp });
|
|
1987
|
+
if (ord) {
|
|
1988
|
+
availRub = Math.max(0, availRub - ord.amountRub);
|
|
1989
|
+
orders.push({
|
|
1990
|
+
...ord,
|
|
1991
|
+
reason: `Фонд денежного рынка LQDT: размещение свободных ${fmtRubSimple(ord.amountRub)} ₽ под многодневную ставку ликвидности до появления сигнала`,
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1975
1994
|
}
|
|
1976
1995
|
}
|
|
1977
1996
|
}
|
|
@@ -1989,7 +2008,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
1989
2008
|
planThrottleNote = 'Тариф «VIP» (лимиты по договору): активны покупки и шорты с повышенной концентрацией слота.';
|
|
1990
2009
|
}
|
|
1991
2010
|
if (!orders.length) {
|
|
1992
|
-
if (
|
|
2011
|
+
if (!timeOk) {
|
|
2012
|
+
diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
|
|
2013
|
+
} else if ((newBuys.length || newShorts.length) && availRub < 1500) {
|
|
1993
2014
|
const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
|
|
1994
2015
|
const cashNow = round(acc.lastSync?.cashRub || 0, 0);
|
|
1995
2016
|
diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
|
|
@@ -2002,9 +2023,14 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
|
|
|
2002
2023
|
} else if (!newBuys.length && !newShorts.length) {
|
|
2003
2024
|
const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
|
|
2004
2025
|
const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует 60-минутная пауза после недавнего выхода из позиции, чтобы не платить повторную комиссию.` : '';
|
|
2026
|
+
const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2027
|
+
const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
|
|
2028
|
+
const thrRangeStr = dir !== 'long'
|
|
2029
|
+
? `+${round(minBuyEdgePct, 2)}% / +${buyThrT2}% в покупку, −${round(minShortEdgePct, 2)}% / −${shortThrT2}% в шорт`
|
|
2030
|
+
: `+${round(minBuyEdgePct, 2)}% (1-й эшелон) / +${buyThrT2}% (2–3-й эшелон)`;
|
|
2005
2031
|
diagNote = cdNote
|
|
2006
2032
|
? `На свече ${fc.bar} новых входов нет.${cdNote}${noShortNote}`
|
|
2007
|
-
: `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды
|
|
2033
|
+
: `На свече ${fc.bar} ни одна доступная бумага не превысила индивидуальный порог выгоды (${thrRangeStr})${noShortNote}.`;
|
|
2008
2034
|
}
|
|
2009
2035
|
}
|
|
2010
2036
|
if (divBlocked.length) {
|
|
@@ -2407,7 +2433,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
|
|
|
2407
2433
|
hadExits = true;
|
|
2408
2434
|
delete acc.entryTsBySymbol[ord.symbol];
|
|
2409
2435
|
delete acc.peakPriceBySymbol[ord.symbol];
|
|
2410
|
-
acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
|
|
2436
|
+
if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
|
|
2411
2437
|
}
|
|
2412
2438
|
} catch (e) {
|
|
2413
2439
|
// Брокер явно отклонил заявку — при повторе нужен новый ID. Сетевая ошибка или таймаут — ID сохраняется:
|
|
@@ -2633,7 +2659,7 @@ async function tickBrokerSchedule() {
|
|
|
2633
2659
|
const nowMsk = new Date(Date.now() + MSK);
|
|
2634
2660
|
const dow = nowMsk.getUTCDay();
|
|
2635
2661
|
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
2636
|
-
if (dow === 0 || dow === 6 || mins <
|
|
2662
|
+
if (dow === 0 || dow === 6 || mins < 6 * 60 + 50 || mins > 23 * 60 + 55) return;
|
|
2637
2663
|
schedulerBusy = true;
|
|
2638
2664
|
try {
|
|
2639
2665
|
const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
|
|
@@ -2711,8 +2737,8 @@ async function resumeBrokerAccountsOnBoot() {
|
|
|
2711
2737
|
// • перед любой закрывающей заявкой по бумаге её стоп-заявка снимается (closeBrokerStopBeforeExit);
|
|
2712
2738
|
// • внутренний защитный стоп по бумаге со стоп-заявкой у брокера не выставляется;
|
|
2713
2739
|
// • трогаются только стоп-заявки, выставленные самой программой (их ID хранятся в brokerStopBySymbol).
|
|
2714
|
-
const BROKER_STOP_LONG_PCT =
|
|
2715
|
-
const BROKER_STOP_SHORT_PCT =
|
|
2740
|
+
const BROKER_STOP_LONG_PCT = 5.0;
|
|
2741
|
+
const BROKER_STOP_SHORT_PCT = 4.5;
|
|
2716
2742
|
const tbankBase = (acc) => (acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest');
|
|
2717
2743
|
const tbankHeaders = (acc) => ({ Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' });
|
|
2718
2744
|
const qPrice = (v) => { const units = Math.trunc(v); return { units: String(units), nano: Math.round((v - units) * 1e9) }; };
|
|
@@ -2796,11 +2822,16 @@ async function reconcileBrokerStopsLocked(list, acc) {
|
|
|
2796
2822
|
const base = tbankBase(acc);
|
|
2797
2823
|
const headers = tbankHeaders(acc);
|
|
2798
2824
|
const want = new Map();
|
|
2825
|
+
const activeOrders = acc.lastSync?.activeOrders || [];
|
|
2799
2826
|
for (const pos of acc.lastSync.positions || []) {
|
|
2800
2827
|
const sym = pos.symbol;
|
|
2828
|
+
if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
|
|
2801
2829
|
const sp = specs[sym];
|
|
2802
|
-
if (!
|
|
2830
|
+
if (!sp) continue;
|
|
2803
2831
|
const aQty = algoQtyFor(acc, sym, Number(pos.qty) || 0);
|
|
2832
|
+
if (!aQty) continue;
|
|
2833
|
+
const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
|
|
2834
|
+
if (activeOrders.some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
|
|
2804
2835
|
const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
|
|
2805
2836
|
const avgPx = Number(pos.avgPrice) || 0;
|
|
2806
2837
|
if (lots < 1 || avgPx <= 0) continue;
|
|
@@ -2869,7 +2900,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
2869
2900
|
const nowMsk = new Date(Date.now() + MSK);
|
|
2870
2901
|
const dow = nowMsk.getUTCDay();
|
|
2871
2902
|
const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
|
|
2872
|
-
if (dow === 0 || dow === 6 || mins <
|
|
2903
|
+
if (dow === 0 || dow === 6 || mins < 6 * 60 + 55 || mins > 23 * 60 + 48) return;
|
|
2873
2904
|
if (acc.skipEdgeMinutes && sessionEdgeWarning()) return;
|
|
2874
2905
|
|
|
2875
2906
|
const positions = acc.lastSync?.positions || [];
|
|
@@ -2895,7 +2926,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
2895
2926
|
const lockOrders = [];
|
|
2896
2927
|
for (const pos of positions) {
|
|
2897
2928
|
const sym = pos.symbol;
|
|
2898
|
-
if (!sym) continue;
|
|
2929
|
+
if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
|
|
2899
2930
|
const aQty = algoQtyOf(sym, pos.qty);
|
|
2900
2931
|
if (!aQty) continue;
|
|
2901
2932
|
// позицию уже защищает стоп-заявка у брокера — внутренний стоп не дублируем
|
|
@@ -2916,7 +2947,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
2916
2947
|
const lots = Math.floor(aQty / sp.lotSize);
|
|
2917
2948
|
if (lots < 1) continue;
|
|
2918
2949
|
const curGainPct = ((curPx - avgPx) / avgPx) * 100;
|
|
2919
|
-
const hitHardStopLoss = curGainPct <= -
|
|
2950
|
+
const hitHardStopLoss = curGainPct <= -BROKER_STOP_LONG_PCT;
|
|
2920
2951
|
if (hitHardStopLoss) {
|
|
2921
2952
|
const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
|
|
2922
2953
|
lockOrders.push({
|
|
@@ -2929,7 +2960,7 @@ async function checkIntradayProfitLock(accId) {
|
|
|
2929
2960
|
const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
|
|
2930
2961
|
if (lots < 1) continue;
|
|
2931
2962
|
const curGainPct = ((avgPx - curPx) / avgPx) * 100;
|
|
2932
|
-
const hitHardStopLoss = curGainPct <= -
|
|
2963
|
+
const hitHardStopLoss = curGainPct <= -BROKER_STOP_SHORT_PCT;
|
|
2933
2964
|
if (hitHardStopLoss) {
|
|
2934
2965
|
const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
|
|
2935
2966
|
lockOrders.push({
|
package/server/data/moex.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
2
|
import { saveCandles, loadCandles, candleRange, kvGet, kvSet } from '../db.js';
|
|
3
3
|
import { DAY, MSK } from '../util.js';
|
|
4
4
|
|
|
@@ -119,16 +119,17 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
|
|
|
119
119
|
const mskDate = new Date(now + MSK);
|
|
120
120
|
const mskDow = mskDate.getUTCDay();
|
|
121
121
|
const mskHour = mskDate.getUTCHours();
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const
|
|
122
|
+
const mskMins = mskHour * 60 + mskDate.getUTCMinutes();
|
|
123
|
+
const isTradingHours = mskDow >= 1 && mskDow <= 5 && mskMins >= 6 * 60 + 50 && mskHour < 24;
|
|
124
|
+
const checkCooldownMs = isTradingHours ? 4 * 60000 : 4 * 3600000;
|
|
125
|
+
const dbIsAlreadyFresh = (now - range.max < 10 * 60000) || (!isTradingHours && now - range.max < 18 * 3600000);
|
|
125
126
|
|
|
126
127
|
if (!dbIsAlreadyFresh && range.max < Math.min(tillTs, now) - barMs(interval) && (now - lastCheck >= checkCooldownMs)) {
|
|
127
128
|
// Записываем время проверки хвоста ВСЕГДА (даже если новых свечей у бумаги на бирже ещё не появилось),
|
|
128
129
|
// чтобы не опрашивать MOEX ISS по всем 100 бумагам каждые 2 минуты!
|
|
129
130
|
lastTailCheckAt.set(tailKey, now);
|
|
130
131
|
kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
|
|
131
|
-
await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now ? tsToDate(tillTs) : null, {
|
|
132
|
+
await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now - DAY ? tsToDate(tillTs) : null, {
|
|
132
133
|
signal,
|
|
133
134
|
onBatch: (page) => saveCandles(symbol, interval, page),
|
|
134
135
|
});
|
package/server/db.js
CHANGED
|
@@ -205,7 +205,7 @@ if (!db.prepare('SELECT id FROM users WHERE id = 1').get()) {
|
|
|
205
205
|
} else {
|
|
206
206
|
// Общеизвестный код привязки владельца заменяется на случайный (привязанные ранее чаты сохраняются)
|
|
207
207
|
db.prepare("UPDATE users SET notify_code = ? WHERE id = 1 AND (notify_code IS NULL OR notify_code = '' OR UPPER(notify_code) = 'NA-OWNER1')").run(randomNotifyCode());
|
|
208
|
-
db.prepare(`UPDATE users SET plan = 'owner' WHERE id = 1 AND role = 'admin' AND plan
|
|
208
|
+
db.prepare(`UPDATE users SET plan = 'owner', plan_until = 0 WHERE id = 1 AND role = 'admin' AND plan <> 'owner'`).run();
|
|
209
209
|
db.prepare("UPDATE users SET name = 'Пользователь' WHERE id = 1 AND name = 'Владелец платформы'").run();
|
|
210
210
|
}
|
|
211
211
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_api_key ON users(api_key) WHERE api_key IS NOT NULL');
|
package/server/index.js
CHANGED
|
@@ -128,9 +128,9 @@ app.get('/admin', (req, res) => {
|
|
|
128
128
|
return res.sendFile(path.join(ROOT, 'public', 'admin.html'));
|
|
129
129
|
});
|
|
130
130
|
app.get('/', (req, res, next) => {
|
|
131
|
-
//
|
|
132
|
-
// а
|
|
133
|
-
if (!req.user || (process.env.CLOUD_MODE === '1' && req.query.landing === '1')) {
|
|
131
|
+
// На центральном сервере (IS_CENTRAL) у клиентов на главном домене работает витрина и Личный кабинет (с биллингом и переходом на их PaaS-машину),
|
|
132
|
+
// а локальный торговый терминал самого центра открывается только для администратора (или в dev/instance режиме)
|
|
133
|
+
if (!req.user || (process.env.CLOUD_MODE === '1' && req.query.landing === '1') || (IS_CENTRAL && (req.user.role !== 'admin' || req.query.payment_check === '1') && req.query.terminal !== '1')) {
|
|
134
134
|
return res.sendFile(path.join(ROOT, 'public', 'landing.html'));
|
|
135
135
|
}
|
|
136
136
|
setAppCsp(res);
|