neuroalpha 0.5.17 → 0.5.20

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.
@@ -1,133 +1,142 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import crypto from 'node:crypto';
3
- import { db, kvGet, kvSet } from '../db.js';
4
- import { PLANS, encryptSecret, decryptSecret, getUserById, publicUser } from '../auth/index.js';
5
-
6
- export function getBillingConfig() {
7
- const saved = kvGet('yookassaConfig') || {};
8
- const shopId = String(saved.shopId || process.env.YOOKASSA_SHOP_ID || '').trim();
9
- const secretKey = decryptSecret(saved.secretKey || '') || String(process.env.YOOKASSA_SECRET_KEY || '').trim();
10
- const sendReceipt = saved.sendReceipt !== undefined ? Boolean(saved.sendReceipt) : (process.env.YOOKASSA_RECEIPT === '1');
11
- const vatCode = Number(saved.vatCode || process.env.YOOKASSA_VAT_CODE || 1);
12
- return { shopId, secretKey, sendReceipt, vatCode, enabled: Boolean(shopId && secretKey) };
13
- }
14
-
15
- export function publicBillingConfig() {
16
- const c = getBillingConfig();
17
- return { shopId: c.shopId, hasSecret: Boolean(c.secretKey), sendReceipt: c.sendReceipt, vatCode: c.vatCode, enabled: c.enabled, plans: PLANS };
18
- }
19
-
20
- export function saveBillingConfig(input = {}) {
21
- const prev = kvGet('yookassaConfig') || {};
22
- const next = {
23
- shopId: String(input.shopId ?? prev.shopId ?? '').trim(),
24
- secretKey: input.clearSecret ? '' : (input.secretKey && String(input.secretKey).trim() ? encryptSecret(input.secretKey) : (prev.secretKey || '')),
25
- sendReceipt: input.sendReceipt !== undefined ? Boolean(input.sendReceipt) : Boolean(prev.sendReceipt),
26
- vatCode: Number(input.vatCode ?? prev.vatCode ?? 1) || 1,
27
- };
28
- kvSet('yookassaConfig', next);
29
- return publicBillingConfig();
30
- }
31
-
32
- export async function createYookassaPayment({ user, plan, months = 1, returnOrigin = 'http://localhost:3000' }) {
33
- if (!user) throw new Error('Войдите в аккаунт для оформления подписки');
34
- const planObj = PLANS[plan];
35
- if (!planObj || planObj.priceRub <= 0) throw new Error('Выберите платный тариф (Инвестор или Профи)');
36
- const m = Math.max(1, Math.min(12, Math.floor(Number(months) || 1)));
37
- const discount = m >= 12 ? 0.8 : m >= 3 ? 0.9 : 1;
38
- const amountRub = Math.round(planObj.priceRub * m * discount);
39
- const cfg = getBillingConfig();
40
- if (!cfg.enabled) throw new Error('Платёжный шлюз ЮKassa ещё не настроен администратором (укажите Shop ID и секретный ключ)');
41
-
42
- const description = `Подписка «${planObj.name}» на ${m} мес. (${user.email})`;
43
- const payload = {
44
- amount: { value: amountRub.toFixed(2), currency: 'RUB' },
45
- capture: true,
46
- confirmation: {
47
- type: 'redirect',
48
- return_url: `${returnOrigin.replace(/\/+$/, '')}/?payment_check=1`,
49
- },
50
- description,
51
- metadata: { userId: String(user.id), plan, months: String(m) },
52
- };
53
- if (cfg.sendReceipt && user.email) {
54
- payload.receipt = {
55
- customer: { email: user.email },
56
- items: [{
57
- description: description.slice(0, 128),
58
- quantity: '1.00',
59
- amount: { value: amountRub.toFixed(2), currency: 'RUB' },
60
- vat_code: cfg.vatCode,
61
- payment_mode: 'full_payment',
62
- payment_subject: 'service',
63
- }],
64
- };
65
- }
66
-
67
- const auth = Buffer.from(`${cfg.shopId}:${cfg.secretKey}`).toString('base64');
68
- const r = await fetch('https://api.yookassa.ru/v3/payments', {
69
- method: 'POST',
70
- headers: {
71
- Authorization: `Basic ${auth}`,
72
- 'Idempotence-Key': crypto.randomUUID(),
73
- 'Content-Type': 'application/json',
74
- },
75
- body: JSON.stringify(payload),
76
- signal: AbortSignal.timeout(20000),
77
- });
78
- const j = await r.json().catch(() => ({}));
79
- if (!r.ok || !j.id) throw new Error(j.description || `Ошибка ЮKassa: HTTP ${r.status}`);
80
-
81
- db.prepare(`INSERT OR REPLACE INTO payments (id, user_id, plan, months, amount_rub, status, created_at, raw)
82
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(j.id, user.id, plan, m, amountRub, j.status || 'pending', Date.now(), JSON.stringify(j));
83
-
84
- return {
85
- paymentId: j.id,
86
- status: j.status,
87
- amountRub,
88
- confirmationUrl: j.confirmation?.confirmation_url || null,
89
- };
90
- }
91
-
92
- function applyPaidSubscription(payRow, remoteObj) {
93
- if (!payRow || payRow.status === 'succeeded') return;
94
- const u = getUserById(payRow.user_id);
95
- if (!u) return;
96
- const baseTs = u.plan === payRow.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
97
- const nextUntil = baseTs + (Number(payRow.months) || 1) * 30 * 86400000;
98
- db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(payRow.plan, nextUntil, u.id);
99
- db.prepare('UPDATE payments SET status = ?, paid_at = ?, raw = ? WHERE id = ?')
100
- .run('succeeded', Date.now(), JSON.stringify(remoteObj || {}), payRow.id);
101
- }
102
-
103
- export async function verifyYookassaPayment(paymentId) {
104
- const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(String(paymentId));
105
- if (!row) throw new Error('Платёж не найден');
106
- if (row.status === 'succeeded') return { status: 'succeeded', user: publicUser(getUserById(row.user_id)) };
107
- const cfg = getBillingConfig();
108
- if (!cfg.enabled) throw new Error('ЮKassa не настроена');
109
- const auth = Buffer.from(`${cfg.shopId}:${cfg.secretKey}`).toString('base64');
110
- const r = await fetch(`https://api.yookassa.ru/v3/payments/${encodeURIComponent(row.id)}`, {
111
- headers: { Authorization: `Basic ${auth}` },
112
- signal: AbortSignal.timeout(15000),
113
- });
114
- const j = await r.json().catch(() => ({}));
115
- if (!r.ok) throw new Error(j.description || `HTTP ${r.status}`);
116
- if (j.status === 'succeeded' && j.paid) {
117
- applyPaidSubscription(row, j);
118
- } else if (j.status && j.status !== row.status) {
119
- db.prepare('UPDATE payments SET status = ?, raw = ? WHERE id = ?').run(j.status, JSON.stringify(j), row.id);
120
- }
121
- return { status: j.status, user: publicUser(getUserById(row.user_id)) };
122
- }
123
-
124
- export async function verifyUserLatestPayment(userId) {
125
- const row = db.prepare("SELECT * FROM payments WHERE user_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1").get(Number(userId));
126
- if (!row) return { status: 'none', user: publicUser(getUserById(userId)) };
127
- return verifyYookassaPayment(row.id);
128
- }
129
-
130
- export function listAllPayments(limit = 100) {
131
- return db.prepare(`SELECT p.id, p.user_id, u.email, p.plan, p.months, p.amount_rub, p.status, p.created_at, p.paid_at
132
- FROM payments p LEFT JOIN users u ON u.id = p.user_id ORDER BY p.created_at DESC LIMIT ?`).all(limit);
133
- }
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ import crypto from 'node:crypto';
3
+ import { db, kvGet, kvSet } from '../db.js';
4
+ import { PLANS, encryptSecret, decryptSecret, getUserById, publicUser } from '../auth/index.js';
5
+
6
+ export function getBillingConfig() {
7
+ const saved = kvGet('yookassaConfig') || {};
8
+ const shopId = String(saved.shopId || process.env.YOOKASSA_SHOP_ID || '').trim();
9
+ const secretKey = decryptSecret(saved.secretKey || '') || String(process.env.YOOKASSA_SECRET_KEY || '').trim();
10
+ const sendReceipt = saved.sendReceipt !== undefined ? Boolean(saved.sendReceipt) : (process.env.YOOKASSA_RECEIPT === '1');
11
+ const vatCode = Number(saved.vatCode || process.env.YOOKASSA_VAT_CODE || 1);
12
+ return { shopId, secretKey, sendReceipt, vatCode, enabled: Boolean(shopId && secretKey) };
13
+ }
14
+
15
+ export function publicBillingConfig() {
16
+ const c = getBillingConfig();
17
+ return { shopId: c.shopId, hasSecret: Boolean(c.secretKey), sendReceipt: c.sendReceipt, vatCode: c.vatCode, enabled: c.enabled, plans: PLANS };
18
+ }
19
+
20
+ export function saveBillingConfig(input = {}) {
21
+ const prev = kvGet('yookassaConfig') || {};
22
+ const next = {
23
+ shopId: String(input.shopId ?? prev.shopId ?? '').trim(),
24
+ secretKey: input.clearSecret ? '' : (input.secretKey && String(input.secretKey).trim() ? encryptSecret(input.secretKey) : (prev.secretKey || '')),
25
+ sendReceipt: input.sendReceipt !== undefined ? Boolean(input.sendReceipt) : Boolean(prev.sendReceipt),
26
+ vatCode: Number(input.vatCode ?? prev.vatCode ?? 1) || 1,
27
+ };
28
+ kvSet('yookassaConfig', next);
29
+ return publicBillingConfig();
30
+ }
31
+
32
+ export async function createYookassaPayment({ user, plan, months = 1, returnOrigin = 'http://localhost:3000' }) {
33
+ if (!user) throw new Error('Войдите в аккаунт для оформления подписки');
34
+ const planObj = PLANS[plan];
35
+ if (!planObj || planObj.priceRub <= 0) throw new Error('Выберите платный тариф (Инвестор или Профи)');
36
+ const m = Math.max(1, Math.min(12, Math.floor(Number(months) || 1)));
37
+ const discount = m >= 12 ? 0.8 : m >= 3 ? 0.9 : 1;
38
+ const amountRub = Math.round(planObj.priceRub * m * discount);
39
+ const cfg = getBillingConfig();
40
+ if (!cfg.enabled) throw new Error('Платёжный шлюз ЮKassa ещё не настроен администратором (укажите Shop ID и секретный ключ)');
41
+
42
+ const description = `Подписка «${planObj.name}» на ${m} мес. (${user.email})`;
43
+ const payload = {
44
+ amount: { value: amountRub.toFixed(2), currency: 'RUB' },
45
+ capture: true,
46
+ confirmation: {
47
+ type: 'redirect',
48
+ return_url: `${returnOrigin.replace(/\/+$/, '')}/landing?payment_check=1`,
49
+ },
50
+ description,
51
+ metadata: { userId: String(user.id), plan, months: String(m) },
52
+ };
53
+ if (cfg.sendReceipt && user.email) {
54
+ payload.receipt = {
55
+ customer: { email: user.email },
56
+ items: [{
57
+ description: description.slice(0, 128),
58
+ quantity: '1.00',
59
+ amount: { value: amountRub.toFixed(2), currency: 'RUB' },
60
+ vat_code: cfg.vatCode,
61
+ payment_mode: 'full_payment',
62
+ payment_subject: 'service',
63
+ }],
64
+ };
65
+ }
66
+
67
+ const auth = Buffer.from(`${cfg.shopId}:${cfg.secretKey}`).toString('base64');
68
+ const r = await fetch('https://api.yookassa.ru/v3/payments', {
69
+ method: 'POST',
70
+ headers: {
71
+ Authorization: `Basic ${auth}`,
72
+ 'Idempotence-Key': crypto.randomUUID(),
73
+ 'Content-Type': 'application/json',
74
+ },
75
+ body: JSON.stringify(payload),
76
+ signal: AbortSignal.timeout(20000),
77
+ });
78
+ const j = await r.json().catch(() => ({}));
79
+ if (!r.ok || !j.id) throw new Error(j.description || `Ошибка ЮKassa: HTTP ${r.status}`);
80
+
81
+ db.prepare(`INSERT OR REPLACE INTO payments (id, user_id, plan, months, amount_rub, status, created_at, raw)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(j.id, user.id, plan, m, amountRub, j.status || 'pending', Date.now(), JSON.stringify(j));
83
+
84
+ return {
85
+ paymentId: j.id,
86
+ status: j.status,
87
+ amountRub,
88
+ confirmationUrl: j.confirmation?.confirmation_url || null,
89
+ };
90
+ }
91
+
92
+ function applyPaidSubscription(payRow, remoteObj) {
93
+ if (!payRow || payRow.status === 'succeeded') return;
94
+ const u = getUserById(payRow.user_id);
95
+ if (!u) return;
96
+ const baseTs = u.plan === payRow.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
97
+ const nextUntil = baseTs + (Number(payRow.months) || 1) * 30 * 86400000;
98
+ // Главный владелец и администраторы сохраняют полный тариф owner
99
+ if (u.id !== 1 && u.role !== 'admin') {
100
+ db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(payRow.plan, nextUntil, u.id);
101
+ }
102
+ db.prepare('UPDATE payments SET status = ?, paid_at = ?, raw = ? WHERE id = ?')
103
+ .run('succeeded', Date.now(), JSON.stringify(remoteObj || {}), payRow.id);
104
+ }
105
+
106
+ export async function verifyYookassaPayment(paymentId) {
107
+ const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(String(paymentId));
108
+ if (!row) throw new Error('Платёж не найден');
109
+ if (row.status === 'succeeded') return { status: 'succeeded', user: publicUser(getUserById(row.user_id)) };
110
+ const cfg = getBillingConfig();
111
+ if (!cfg.enabled) throw new Error('ЮKassa не настроена');
112
+ const auth = Buffer.from(`${cfg.shopId}:${cfg.secretKey}`).toString('base64');
113
+ const r = await fetch(`https://api.yookassa.ru/v3/payments/${encodeURIComponent(row.id)}`, {
114
+ headers: { Authorization: `Basic ${auth}` },
115
+ signal: AbortSignal.timeout(15000),
116
+ });
117
+ const j = await r.json().catch(() => ({}));
118
+ if (!r.ok) throw new Error(j.description || `HTTP ${r.status}`);
119
+ if (j.status === 'succeeded' && j.paid) {
120
+ applyPaidSubscription(row, j);
121
+ } else if (j.status && j.status !== row.status) {
122
+ db.prepare('UPDATE payments SET status = ?, raw = ? WHERE id = ?').run(j.status, JSON.stringify(j), row.id);
123
+ }
124
+ return { status: j.status, user: publicUser(getUserById(row.user_id)) };
125
+ }
126
+
127
+ export async function verifyUserLatestPayment(userId) {
128
+ const row = db.prepare("SELECT * FROM payments WHERE user_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1").get(Number(userId));
129
+ if (!row) return { status: 'none', user: publicUser(getUserById(userId)) };
130
+ return verifyYookassaPayment(row.id);
131
+ }
132
+
133
+ export function listAllPayments(limit = 100) {
134
+ return db.prepare(`SELECT p.id, p.user_id, u.email, p.plan, p.months, p.amount_rub, p.status, p.created_at, p.paid_at
135
+ FROM payments p LEFT JOIN users u ON u.id = p.user_id ORDER BY p.created_at DESC LIMIT ?`).all(limit);
136
+ }
137
+
138
+ export function listUserPayments(userId, limit = 20) {
139
+ if (!userId) return [];
140
+ return db.prepare(`SELECT id, plan, months, amount_rub, status, created_at, paid_at
141
+ FROM payments WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`).all(Number(userId), limit);
142
+ }
@@ -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 trades = algoOps.filter((o) => (o.kind === 'buy' || o.kind === 'sell') && o.qty > 0 && o.price > 0 && o.symbol && o.symbol !== 'LQDT');
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
- // утреннее открытие основной сессии 09:50–10:10 и закрытие дневной 18:35–18:55 МСК
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
- // В реальном стакане брокера (где каждый вход-выход стоит 0.08% комиссии + спред) используем гистерезис удержания 0.58
1560
- // (около +0.145%), чтобы не закрывать открытую позицию преждевременно при положительном прогнозе +0.16%…+0.23%!
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.25, 3);
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 раз.
@@ -1571,15 +1575,15 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1571
1575
  const baseShortNeedPct = Number(fc.shortThresholdPct ?? fc.thresholdPct ?? 0.50);
1572
1576
  const minBuyEdgePct = round(baseBuyNeedPct, 3);
1573
1577
  const minShortEdgePct = round(baseShortNeedPct, 3);
1574
- const reqBuyEdgeOf = (sym) => round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
1575
- const reqShortEdgeOf = (sym) => round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
1578
+ const reqBuyEdgeOf = (sym) => rowMap.get(sym)?.buyThresholdPct ?? round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
1579
+ const reqShortEdgeOf = (sym) => rowMap.get(sym)?.shortThresholdPct ?? round(Math.max(minShortEdgePct * symbolEdgeMult(sym), 0.31), 3);
1576
1580
  const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
1577
- // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, как в research.js):
1578
- // если за последние 14 дней по бумаге было >= 3 закрытых сделок на счёте и их суммарный итог отрицательный (< -1.0%),
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 - 14 * DAY;
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
- if (closedRounds >= 3 && st.buyRub > 10000 && (st.netRub / st.buyRub) < -0.009) {
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,59 @@ 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 symMult = r.edgeMult || symbolEdgeMult(sym);
1698
+ const curExitThr = round(((autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct) * symMult, 3);
1699
+ const nightExit = !onlyEntries && autoAdapt && shouldExitBeforeNight(nowTs, effEntryTs, stepBars, BAR_INTERVAL);
1693
1700
  const curPx = Number(r.price || pos.price) || 0;
1694
1701
  const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
1695
1702
  if (aQty > 0) {
1696
1703
  const lots = Math.floor(aQty / sp.lotSize);
1697
1704
  const curGainPct = avgPx > 0 ? ((curPx - avgPx) / avgPx) * 100 : 0;
1698
- const hitHardStopLoss = curGainPct <= -2.6;
1705
+ const hitHardStopLoss = curGainPct <= -5.0;
1699
1706
  const keepByForecast = onlyEntries ? (r.liquid && r.expectedPct > -needPct) : (r.liquid && r.expectedPct > curExitThr);
1700
1707
  const dg = divGuardOf(sym, curPx);
1701
1708
  const divExit = Boolean(dg?.exitLong);
1702
- const keep = keepByForecast && !hitHardStopLoss && !divExit;
1709
+ const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
1703
1710
  if (lots >= 1 && !keep) {
1704
1711
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
1705
1712
  const exitReason = hitHardStopLoss
1706
1713
  ? `Защитный стоп-лосс позиции (${round(curGainPct, 2)}%)`
1707
1714
  : divExit
1708
1715
  ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие покупки в последний день с правом, чтобы не терять 13% налога с дивиденда`
1709
- : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1716
+ : nightExit
1717
+ ? 'Закрытие внутридневной позиции перед ночным клирингом (защита от утреннего гэпа)'
1718
+ : `Выход из покупки: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% ниже порога удержания +${curExitThr}%`;
1710
1719
  orders.push({
1711
1720
  symbol: sym, action: 'sell', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1712
1721
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1713
1722
  reason: exitReason,
1723
+ isStopLoss: hitHardStopLoss,
1714
1724
  });
1715
1725
  closing.add(sym);
1716
1726
  }
1717
1727
  } else if (aQty < 0) {
1718
1728
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
1719
1729
  const curGainPct = avgPx > 0 ? ((avgPx - curPx) / avgPx) * 100 : 0;
1720
- const hitHardStopLoss = curGainPct <= -2.2;
1730
+ const hitHardStopLoss = curGainPct <= -4.5;
1721
1731
  const keepByForecast = onlyEntries ? (dir !== 'long' && r.liquid && r.expectedPct < needPct) : (dir !== 'long' && r.liquid && r.expectedPct < -curExitThr);
1722
1732
  const dg = divGuardOf(sym, curPx);
1723
1733
  const divExit = Boolean(dg?.exitShort);
1724
- const keep = keepByForecast && !hitHardStopLoss && !divExit;
1734
+ const keep = keepByForecast && !hitHardStopLoss && !divExit && !nightExit;
1725
1735
  if (lots >= 1 && !keep) {
1726
1736
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
1727
1737
  const exitReason = hitHardStopLoss
1728
1738
  ? `Защитный стоп-лосс шорта от выноса вверх (${round(curGainPct, 2)}%)`
1729
1739
  : divExit
1730
1740
  ? `Дивидендная отсечка ${dg.exDate} (дивиденд ${dg.yieldPct}%): закрытие шорта в последний день с правом, чтобы брокер не списал компенсацию дивиденда`
1731
- : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1741
+ : nightExit
1742
+ ? 'Закрытие короткой позиции перед ночным клирингом (защита от утреннего гэпа и платы за перенос)'
1743
+ : `Закрытие шорта: ожидание ${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}% выше порога удержания −${curExitThr}%`;
1732
1744
  orders.push({
1733
1745
  symbol: sym, action: 'cover', lotSize: sp.lotSize, lots, shares: lots * sp.lotSize,
1734
1746
  refPrice: curPx, limitPrice, amountRub: round(lots * sp.lotSize * limitPrice, 2),
1735
1747
  reason: exitReason,
1748
+ isStopLoss: hitHardStopLoss,
1736
1749
  });
1737
1750
  closing.add(sym);
1738
1751
  }
@@ -1957,21 +1970,28 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1957
1970
  }
1958
1971
  }
1959
1972
 
1960
- // При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых заявок,
1961
- // а свободных рублей выше резерва — размещаем излишек в фонд ликвидности LQDT, чтобы кэш приносил ставку денежного рынка (~16–18% годовых)
1962
- if (!onlyEntries && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length === 0 && !activePendingOrders.length && !edgeWarn) {
1963
- const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.001, decimals: 3 };
1964
- const lqdtPx = Number(heldMap.get('LQDT')?.price || lqdtSp.lastPrice || 0);
1965
- const reserveRub = Math.max(5000, round(budget * 0.03, 0));
1966
- const sweepRub = Math.min(availRub - reserveRub, (acc.lastSync?.cashRub || 0) - reserveRub);
1967
- if (lqdtPx > 0 && sweepRub >= 20000) {
1968
- const ord = calcLotOrder('LQDT', sweepRub, lqdtPx, 'buy', 0.02, { ...specs, LQDT: lqdtSp });
1969
- if (ord) {
1970
- availRub = Math.max(0, availRub - ord.amountRub);
1971
- orders.push({
1972
- ...ord,
1973
- reason: `Фонд денежного рынка LQDT: размещение свободных ${fmtRubSimple(ord.amountRub)} ₽ под дневную ставку ликвидности до появления сигнала`,
1974
- });
1973
+ // При включённом «Свободные рубли в LQDT» на плановом шаге (!onlyEntries), если новых входов нет и нет открытых заявок:
1974
+ // учитываем, что в Т-Банке сделка с LQDT облагается комиссией 0.04% на сторону (0.085% за круг со спредом), что окупается только за >= 2 дня удержания.
1975
+ // Поэтому покупаем LQDT только перед выходными (пятница вечер после закрытия внутридневного окна входов) либо если на счёте не было сделок по акциям более 24 часов!
1976
+ if (!onlyEntries && acc.cashSweep && acc.broker === 'tbank' && combinedNew.length === 0 && !activePendingOrders.length && !edgeWarn && !orders.length) {
1977
+ const mskNow = new Date(nowTs + MSK);
1978
+ const isFridayNight = mskNow.getUTCDay() === 5 && !timeOk;
1979
+ const histOpsAll = Array.isArray(acc.lastHistory?.operations) ? acc.lastHistory.operations : [];
1980
+ const hadStockTrade24h = histOpsAll.some((op) => (op.kind === 'buy' || op.kind === 'sell') && op.symbol && op.symbol !== 'LQDT' && op.symbol !== 'TMON@' && (nowTs - (op.date || 0) < DAY));
1981
+ if (isFridayNight || !hadStockTrade24h) {
1982
+ const lqdtSp = specs.LQDT || { lotSize: 1, minStep: 0.0001, decimals: 4 };
1983
+ const lqdtPx = Number(heldMap.get('LQDT')?.price || lqdtSp.lastPrice || 0);
1984
+ const reserveRub = Math.max(5000, round(budget * 0.03, 0));
1985
+ const sweepRub = Math.min(availRub - reserveRub, (acc.lastSync?.cashRub || 0) - reserveRub);
1986
+ if (lqdtPx > 0 && sweepRub >= 20000) {
1987
+ const ord = calcLotOrder('LQDT', sweepRub, lqdtPx, 'buy', 0.02, { ...specs, LQDT: lqdtSp });
1988
+ if (ord) {
1989
+ availRub = Math.max(0, availRub - ord.amountRub);
1990
+ orders.push({
1991
+ ...ord,
1992
+ reason: `Фонд денежного рынка LQDT: размещение свободных ${fmtRubSimple(ord.amountRub)} ₽ под многодневную ставку ликвидности до появления сигнала`,
1993
+ });
1994
+ }
1975
1995
  }
1976
1996
  }
1977
1997
  }
@@ -1989,7 +2009,9 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1989
2009
  planThrottleNote = 'Тариф «VIP» (лимиты по договору): активны покупки и шорты с повышенной концентрацией слота.';
1990
2010
  }
1991
2011
  if (!orders.length) {
1992
- if ((newBuys.length || newShorts.length) && availRub < 1500) {
2012
+ if (!timeOk) {
2013
+ diagNote = `На свече ${fc.bar} вечернее окно новых входов закрыто: до конца сессии осталось меньше горизонта прогноза (${effHorizon} ч) — внутридневные сделки не переносятся через ночь.`;
2014
+ } else if ((newBuys.length || newShorts.length) && availRub < 1500) {
1993
2015
  const sigList = [...newBuys.map((r) => `${r.symbol} (+${r.expectedPct}%)`), ...newShorts.map((r) => `${r.symbol} (${r.expectedPct}%)`)].join(', ');
1994
2016
  const cashNow = round(acc.lastSync?.cashRub || 0, 0);
1995
2017
  diagNote = `У стратегии есть сигналы (${sigList}), но для открытия позиций не хватает свободных рублей (на счёте свободно ${fmtRubSimple(cashNow)} ₽, остальные средства размещены в других активах). Пополните счёт рублями или выберите счёт со свободным кэшем.`;
@@ -2002,9 +2024,14 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
2002
2024
  } else if (!newBuys.length && !newShorts.length) {
2003
2025
  const noShortNote = nonShortableSkipped.length ? ` (пропущены немаржинальные бумаги, недоступные для шорта у брокера: ${nonShortableSkipped.map((r) => `${r.symbol} ${r.expectedPct}%`).join(', ')})` : '';
2004
2026
  const cdNote = cooldownSkipped.length ? ` Сигнал ${cooldownSkipped.map((r) => `${r.symbol} (${r.expectedPct > 0 ? '+' : ''}${r.expectedPct}%)`).join(', ')} временно пропущен: действует 60-минутная пауза после недавнего выхода из позиции, чтобы не платить повторную комиссию.` : '';
2027
+ const buyThrT2 = round(minBuyEdgePct * symbolEdgeMult('__T2__'), 2);
2028
+ const shortThrT2 = round(minShortEdgePct * symbolEdgeMult('__T2__'), 2);
2029
+ const thrRangeStr = dir !== 'long'
2030
+ ? `+${round(minBuyEdgePct, 2)}% / +${buyThrT2}% в покупку, −${round(minShortEdgePct, 2)}% / −${shortThrT2}% в шорт`
2031
+ : `+${round(minBuyEdgePct, 2)}% (1-й эшелон) / +${buyThrT2}% (2–3-й эшелон)`;
2005
2032
  diagNote = cdNote
2006
2033
  ? `На свече ${fc.bar} новых входов нет.${cdNote}${noShortNote}`
2007
- : `На свече ${fc.bar} ни одна доступная бумага не превысила порог выгоды +${fc.thresholdPct}%${noShortNote}.`;
2034
+ : `На свече ${fc.bar} ни одна доступная бумага не превысила индивидуальный порог выгоды (${thrRangeStr})${noShortNote}.`;
2008
2035
  }
2009
2036
  }
2010
2037
  if (divBlocked.length) {
@@ -2407,7 +2434,7 @@ export async function executeBrokerOrders(id, { _lockHeld = false } = {}) {
2407
2434
  hadExits = true;
2408
2435
  delete acc.entryTsBySymbol[ord.symbol];
2409
2436
  delete acc.peakPriceBySymbol[ord.symbol];
2410
- acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2437
+ if (ord.isStopLoss) acc.exitCooldownBySymbol[ord.symbol] = execNowTs;
2411
2438
  }
2412
2439
  } catch (e) {
2413
2440
  // Брокер явно отклонил заявку — при повторе нужен новый ID. Сетевая ошибка или таймаут — ID сохраняется:
@@ -2633,7 +2660,7 @@ async function tickBrokerSchedule() {
2633
2660
  const nowMsk = new Date(Date.now() + MSK);
2634
2661
  const dow = nowMsk.getUTCDay();
2635
2662
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
2636
- if (dow === 0 || dow === 6 || mins < 9 * 60 + 50 || mins > 23 * 60 + 55) return;
2663
+ if (dow === 0 || dow === 6 || mins < 6 * 60 + 50 || mins > 23 * 60 + 55) return;
2637
2664
  schedulerBusy = true;
2638
2665
  try {
2639
2666
  const latest = await latestBarTs('IMOEX', BAR_INTERVAL).catch(() => null);
@@ -2711,8 +2738,8 @@ async function resumeBrokerAccountsOnBoot() {
2711
2738
  // • перед любой закрывающей заявкой по бумаге её стоп-заявка снимается (closeBrokerStopBeforeExit);
2712
2739
  // • внутренний защитный стоп по бумаге со стоп-заявкой у брокера не выставляется;
2713
2740
  // • трогаются только стоп-заявки, выставленные самой программой (их ID хранятся в brokerStopBySymbol).
2714
- const BROKER_STOP_LONG_PCT = 2.6;
2715
- const BROKER_STOP_SHORT_PCT = 2.2;
2741
+ const BROKER_STOP_LONG_PCT = 5.0;
2742
+ const BROKER_STOP_SHORT_PCT = 4.5;
2716
2743
  const tbankBase = (acc) => (acc.sandbox ? 'https://sandbox-invest-public-api.tinkoff.ru/rest' : 'https://invest-public-api.tinkoff.ru/rest');
2717
2744
  const tbankHeaders = (acc) => ({ Authorization: `Bearer ${acc.token}`, 'Content-Type': 'application/json' });
2718
2745
  const qPrice = (v) => { const units = Math.trunc(v); return { units: String(units), nano: Math.round((v - units) * 1e9) }; };
@@ -2796,11 +2823,16 @@ async function reconcileBrokerStopsLocked(list, acc) {
2796
2823
  const base = tbankBase(acc);
2797
2824
  const headers = tbankHeaders(acc);
2798
2825
  const want = new Map();
2826
+ const activeOrders = acc.lastSync?.activeOrders || [];
2799
2827
  for (const pos of acc.lastSync.positions || []) {
2800
2828
  const sym = pos.symbol;
2829
+ if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
2801
2830
  const sp = specs[sym];
2802
- if (!sym || !sp) continue;
2831
+ if (!sp) continue;
2803
2832
  const aQty = algoQtyFor(acc, sym, Number(pos.qty) || 0);
2833
+ if (!aQty) continue;
2834
+ const wantCloseDir = aQty > 0 ? 'sell' : 'buy';
2835
+ if (activeOrders.some((o) => o.symbol === sym && o.direction === wantCloseDir)) continue;
2804
2836
  const lots = Math.floor(Math.abs(aQty) / (sp.lotSize || 1));
2805
2837
  const avgPx = Number(pos.avgPrice) || 0;
2806
2838
  if (lots < 1 || avgPx <= 0) continue;
@@ -2869,7 +2901,7 @@ async function checkIntradayProfitLock(accId) {
2869
2901
  const nowMsk = new Date(Date.now() + MSK);
2870
2902
  const dow = nowMsk.getUTCDay();
2871
2903
  const mins = nowMsk.getUTCHours() * 60 + nowMsk.getUTCMinutes();
2872
- if (dow === 0 || dow === 6 || mins < 9 * 60 + 55 || mins > 23 * 60 + 48) return;
2904
+ if (dow === 0 || dow === 6 || mins < 6 * 60 + 55 || mins > 23 * 60 + 48) return;
2873
2905
  if (acc.skipEdgeMinutes && sessionEdgeWarning()) return;
2874
2906
 
2875
2907
  const positions = acc.lastSync?.positions || [];
@@ -2895,7 +2927,7 @@ async function checkIntradayProfitLock(accId) {
2895
2927
  const lockOrders = [];
2896
2928
  for (const pos of positions) {
2897
2929
  const sym = pos.symbol;
2898
- if (!sym) continue;
2930
+ if (!sym || pos.instrumentType === 'etf' || pos.instrumentType === 'bond' || sym === 'LQDT' || sym === 'TMON@' || sym === 'TMON' || sym === 'SBMM') continue;
2899
2931
  const aQty = algoQtyOf(sym, pos.qty);
2900
2932
  if (!aQty) continue;
2901
2933
  // позицию уже защищает стоп-заявка у брокера — внутренний стоп не дублируем
@@ -2916,7 +2948,7 @@ async function checkIntradayProfitLock(accId) {
2916
2948
  const lots = Math.floor(aQty / sp.lotSize);
2917
2949
  if (lots < 1) continue;
2918
2950
  const curGainPct = ((curPx - avgPx) / avgPx) * 100;
2919
- const hitHardStopLoss = curGainPct <= -2.6;
2951
+ const hitHardStopLoss = curGainPct <= -BROKER_STOP_LONG_PCT;
2920
2952
  if (hitHardStopLoss) {
2921
2953
  const limitPrice = sniperLimitPrice(curPx, -1, acc.limitOffsetPct, sp);
2922
2954
  lockOrders.push({
@@ -2929,7 +2961,7 @@ async function checkIntradayProfitLock(accId) {
2929
2961
  const lots = Math.floor(Math.abs(aQty) / sp.lotSize);
2930
2962
  if (lots < 1) continue;
2931
2963
  const curGainPct = ((avgPx - curPx) / avgPx) * 100;
2932
- const hitHardStopLoss = curGainPct <= -2.2;
2964
+ const hitHardStopLoss = curGainPct <= -BROKER_STOP_SHORT_PCT;
2933
2965
  if (hitHardStopLoss) {
2934
2966
  const limitPrice = sniperLimitPrice(curPx, 1, acc.limitOffsetPct, sp);
2935
2967
  lockOrders.push({