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.
- package/package.json +61 -60
- package/public/admin.html +604 -571
- package/public/app.js +101 -22
- package/public/landing.html +1207 -1273
- package/public/legal.html +6 -5
- 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 +55 -16
- package/server/auth/index.js +58 -13
- package/server/billing/yookassa.js +142 -133
- package/server/brokers/index.js +84 -52
- 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 +74 -19
- package/server/model/forecast.js +117 -20
- package/server/model/live.js +16 -18
- package/server/model/research.js +48 -17
- package/server/security.js +4 -1
- package/server/strategist/index.js +758 -757
- package/server/strategist/tools.js +707 -675
|
@@ -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(/\/+$/, '')}
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
.
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
if (!
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
}
|
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 раз.
|
|
@@ -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
|
-
// Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг,
|
|
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,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
|
|
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 <= -
|
|
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
|
-
:
|
|
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 <= -
|
|
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
|
-
:
|
|
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
|
-
//
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
const
|
|
1965
|
-
const
|
|
1966
|
-
const
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
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 (
|
|
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} ни одна доступная бумага не превысила порог выгоды
|
|
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 <
|
|
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 =
|
|
2715
|
-
const BROKER_STOP_SHORT_PCT =
|
|
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 (!
|
|
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 <
|
|
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 <= -
|
|
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 <= -
|
|
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({
|