neuroalpha 0.5.19 → 0.5.21

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/public/legal.html CHANGED
@@ -37,13 +37,14 @@
37
37
  <h1>Правовые документы сервиса «НейроАльфа»</h1>
38
38
 
39
39
  <section class="doc" id="offer">
40
- <h2>1. Публичная оферта (Договор об оказании информационных услуг доступа к SaaS-платформе)</h2>
41
- <p>Настоящий документ является официальным предложением (публичной офертой в соответствии со ст. 435 и ч. 2 ст. 437 Гражданского кодекса РФ) Администрации сервиса «НейроАльфа» (далее — Исполнитель) любому дееспособному физическому или юридическому лицу (далее — Пользователь) заключить договор на предоставление доступа к вычислительному программному комплексу «НейроАльфа» через сеть Интернет (модель SaaS).</p>
40
+ <h2>1. Лицензионная публичная оферта (Договор предоставления права использования ПО и аренды инфраструктуры PaaS)</h2>
41
+ <p>Настоящий документ является официальным предложением (публичной офертой в соответствии со ст. 435, ч. 2 ст. 437 и ст. 1286 Гражданского кодекса РФ) Правообладателя и Оператора платформы «НейроАльфа» (далее — Лицензиар / Исполнитель) любому дееспособному физическому или юридическому лицу (далее — Лицензиат / Пользователь) заключить договор на предоставление простой (неисключительной) лицензии на право использования программного обеспечения для ЭВМ «НейроАльфа» и аренду выделенной виртуальной машины PaaS.</p>
42
42
 
43
- <h3>1.1. Предмет договора и правовой статус сервиса</h3>
43
+ <h3>1.1. Предмет договора и правовой статус программного обеспечения</h3>
44
44
  <ul>
45
- <li>Исполнитель предоставляет Пользователю дистанционный доступ к функциям облачного программно-вычислительного комплекса «НейроАльфа» (модель PaaS/SaaS) в соответствии с выбранным Пользователем тарифным планом («Старт», «Инвестор», «Профи», «VIP»).</li>
46
- <li>Сервис «НейроАльфа» является исключительно программно-техническим средством (калькулятором статистических закономерностей и интерфейсом автоматизации отправки поручений по параметрам и предохранителям, заданным самим Пользователем).</li>
45
+ <li>Лицензиар предоставляет Пользователю за вознаграждение простую (неисключительную) лицензию на право дистанционного использования программно-вычислительного комплекса «НейроАльфа» (модель PaaS или локальный дистрибутив на тарифе VIP) в соответствии с выбранным тарифным планом («Инвестор», «Профи», «VIP»).</li>
46
+ <li>Оплата по настоящему Договору взимается <b>исключительно за предоставление права использования программного обеспечения (лицензии)</b> и выделенные вычислительные ресурсы PaaS.</li>
47
+ <li>Программный комплекс «НейроАльфа» <b>не является сервисом по зарабатыванию денег</b>, а представляет собой исключительно техническое программное обеспечение для ЭВМ (статистический калькулятор котировок и технический шлюз автоматизации обмена командами с API брокеров по параметрам и предохранителям, заданным самим Пользователем).</li>
47
48
  <li><b>Исполнитель не осуществляет деятельность по инвестиционному консультированию</b> (в смысле ст. 6.1 Федерального закона № 39-ФЗ «О рынке ценных бумаг»), не составляет индивидуальных инвестиционных профилей и не даёт индивидуальных инвестиционных рекомендаций (ИИР).</li>
48
49
  <li><b>Исполнитель не осуществляет доверительное управление</b> или брокерскую деятельность, не принимает денежные средства или ценные бумаги Пользователей на свои счета. Все торговые операции совершаются Пользователем самостоятельно на его личном счёте у лицензированного брокера.</li>
49
50
  </ul>
@@ -6,7 +6,7 @@ import { ROOT, strategyDefaults } from '../config.js';
6
6
  import { activeModelInfo } from '../llm/client.js';
7
7
  import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel, resolveUpstreamForProxy, providerFetch } from '../llm/providers.js';
8
8
  import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles } from '../db.js';
9
- import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createOrUpdateAdminUser, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
9
+ import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createOrUpdateAdminUser, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, parseAllCookieValues, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
10
10
  import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments, listUserPayments } from '../billing/yookassa.js';
11
11
  import { publicNotifyConfig, getNotifyConfig, saveNotifyConfig, notifyUser, createTelegramLoginChallenge, checkTelegramLoginChallenge, handleMaxWebhook } from '../notify/index.js';
12
12
  import { PARAMS } from '../params-meta.js';
@@ -73,6 +73,14 @@ export function createRouter() {
73
73
  const secure = isHttpsRequest(res.req) ? '; Secure' : '';
74
74
  res.setHeader('Set-Cookie', `sid=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`);
75
75
  };
76
+ const isRuSegmentRequest = (req) => {
77
+ if (process.env.ALLOW_FOREIGN_AUTH === '1') return false;
78
+ const country = String(req.headers?.['cf-ipcountry'] || req.headers?.['x-country-code'] || req.headers?.['x-geo-country'] || '').trim().toUpperCase();
79
+ if (country) return country === 'RU';
80
+ const lang = String(req.headers?.['accept-language'] || '').toLowerCase();
81
+ if (lang && !lang.includes('ru')) return false;
82
+ return true;
83
+ };
76
84
  r.get('/auth/me', (req, res) => res.json({
77
85
  user: req.user ? { ...publicUser(req.user), instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } : null,
78
86
  downloads: IS_CENTRAL ? {
@@ -83,6 +91,7 @@ export function createRouter() {
83
91
  role: IS_INSTANCE ? 'instance' : IS_CENTRAL ? 'central' : 'dev',
84
92
  centralUrl: IS_INSTANCE ? CENTRAL_URL : '',
85
93
  cloudMode: process.env.CLOUD_MODE === '1',
94
+ isRuSegment: isRuSegmentRequest(req),
86
95
  plans: PLANS,
87
96
  notify: publicNotifyConfig(),
88
97
  oauth: publicOAuthConfig(),
@@ -154,8 +163,9 @@ export function createRouter() {
154
163
  res.json({ user: publicUser(u), token });
155
164
  }));
156
165
  r.post('/auth/logout', (req, res) => {
157
- const cookies = parseCookies(req.headers?.cookie);
158
- if (cookies.sid) deleteSession(cookies.sid);
166
+ for (const tok of parseAllCookieValues(req.headers?.cookie, 'sid')) {
167
+ if (tok && tok !== 'logged_out') deleteSession(tok);
168
+ }
159
169
  setCookie(res, 'logged_out', 365 * 86400);
160
170
  res.json({ ok: true, ...(IS_INSTANCE ? { redirect: CENTRAL_URL } : {}) });
161
171
  });
@@ -748,6 +758,8 @@ export function createRouter() {
748
758
  yearChangePct,
749
759
  tier: isTier1 ? 1 : 2,
750
760
  tierLabel: isTier1 ? '1-й эшелон (высокая ликвидность)' : '2-й эшелон',
761
+ edgeMult: fcRow?.edgeMult ?? round(tierMult, 3),
762
+ sigmaPct: fcRow?.sigmaPct ?? null,
751
763
  buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.413) * tierMult, 0.30), 3),
752
764
  shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.516) * tierMult, 0.31), 3),
753
765
  shortAllowedByBroker: shortableMap[sym] !== undefined ? Boolean(shortableMap[sym]) : null,
@@ -360,8 +360,20 @@ export function parseCookies(cookieHeader = '') {
360
360
  return out;
361
361
  }
362
362
 
363
+ export function parseAllCookieValues(cookieHeader = '', key = 'sid') {
364
+ const vals = [];
365
+ for (const part of String(cookieHeader || '').split(';')) {
366
+ const i = part.indexOf('=');
367
+ if (i > 0 && part.slice(0, i).trim() === key) {
368
+ vals.push(decodeURIComponent(part.slice(i + 1).trim()));
369
+ }
370
+ }
371
+ return vals;
372
+ }
373
+
363
374
  export function userFromRequest(req) {
364
375
  const cookies = parseCookies(req.headers?.cookie);
376
+ const allSids = parseAllCookieValues(req.headers?.cookie, 'sid');
365
377
  const authHeader = String(req.headers?.authorization || '');
366
378
  const apiKeyHeader = String(req.headers?.['x-api-key'] || '').trim();
367
379
  const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
@@ -370,8 +382,8 @@ export function userFromRequest(req) {
370
382
  const byKey = getUserByApiKey(apiCandidate);
371
383
  if (byKey) return byKey;
372
384
  }
373
- const token = cookies.sid || bearer;
374
- if (token) {
385
+ const candidates = [...allSids.filter((s) => s && s !== 'logged_out'), ...(bearer ? [bearer] : [])];
386
+ for (const token of candidates) {
375
387
  const sess = db.prepare('SELECT * FROM sessions WHERE token = ? AND expires_at > ?').get(token, Date.now());
376
388
  if (sess) {
377
389
  const u = getUserById(sess.user_id);
@@ -381,7 +393,8 @@ export function userFromRequest(req) {
381
393
  // В локальном режиме (без CLOUD_MODE=1) владелец (id = 1) открывается без входа только с этого компьютера
382
394
  // Установка на компьютер клиента после активации — так же, без пароля только с этого компьютера
383
395
  const localTrusted = !IS_INSTANCE || (IS_DESKTOP && isActivated());
384
- if (process.env.CLOUD_MODE !== '1' && localTrusted && cookies.sid !== 'logged_out' && isLoopbackRequest(req)) {
396
+ const explicitlyLoggedOut = cookies.sid === 'logged_out' || allSids.includes('logged_out');
397
+ if (process.env.CLOUD_MODE !== '1' && localTrusted && !explicitlyLoggedOut && isLoopbackRequest(req)) {
385
398
  return getUserById(1);
386
399
  }
387
400
  return null;
@@ -549,11 +562,11 @@ export function authMiddleware(req, res, next) {
549
562
 
550
563
  // ---------- OAuth 2.0 / Социальный вход: Яндекс, Google, Сбер ID, Т-Банк ID, VK ID ----------
551
564
  export const OAUTH_PROVIDERS = [
552
- { id: 'yandex', name: 'Яндекс ID', authUrl: 'https://oauth.yandex.ru/authorize', tokenUrl: 'https://oauth.yandex.ru/token', userUrl: 'https://login.yandex.ru/info?format=json', scope: 'login:email login:info' },
553
- { id: 'google', name: 'Google', authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userUrl: 'https://www.googleapis.com/oauth2/v3/userinfo', scope: 'openid email profile' },
554
- { id: 'sber', name: 'Сбер ID', authUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oauth', tokenUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oidc', userUrl: 'https://oauth.sber.ru/ru/prod/sberbankid/v2.1/userinfo', scope: 'openid name email' },
555
- { id: 'tbank', name: 'T-ID (Т-Банк)', authUrl: 'https://id.tbank.ru/auth/authorize', tokenUrl: 'https://id.tbank.ru/auth/token', userUrl: 'https://id.tbank.ru/userinfo/userinfo', scope: 'openid profile email' },
556
- { id: 'vk', name: 'VK ID', authUrl: 'https://oauth.vk.com/authorize', tokenUrl: 'https://oauth.vk.com/access_token', userUrl: 'https://api.vk.com/method/users.get?v=5.199', scope: 'email' },
565
+ { id: 'yandex', name: 'Яндекс ID', foreign: false, authUrl: 'https://oauth.yandex.ru/authorize', tokenUrl: 'https://oauth.yandex.ru/token', userUrl: 'https://login.yandex.ru/info?format=json', scope: 'login:email login:info' },
566
+ { id: 'google', name: 'Google', foreign: true, authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userUrl: 'https://www.googleapis.com/oauth2/v3/userinfo', scope: 'openid email profile' },
567
+ { id: 'sber', name: 'Сбер ID', foreign: false, authUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oauth', tokenUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oidc', userUrl: 'https://oauth.sber.ru/ru/prod/sberbankid/v2.1/userinfo', scope: 'openid name email' },
568
+ { id: 'tbank', name: 'T-ID (Т-Банк)', foreign: false, authUrl: 'https://id.tbank.ru/auth/authorize', tokenUrl: 'https://id.tbank.ru/auth/token', userUrl: 'https://id.tbank.ru/userinfo/userinfo', scope: 'openid profile email' },
569
+ { id: 'vk', name: 'VK ID', foreign: false, authUrl: 'https://oauth.vk.com/authorize', tokenUrl: 'https://oauth.vk.com/access_token', userUrl: 'https://api.vk.com/method/users.get?v=5.199', scope: 'email' },
557
570
  ];
558
571
 
559
572
  export function getOAuthConfig() {
@@ -564,7 +577,7 @@ export function getOAuthConfig() {
564
577
  const cur = saved[p.id] || {};
565
578
  const clientId = String(cur.clientId || process.env[`${envPrefix}CLIENT_ID`] || '').trim();
566
579
  const clientSecret = decryptSecret(cur.clientSecret || '') || String(process.env[`${envPrefix}CLIENT_SECRET`] || '').trim();
567
- out[p.id] = { id: p.id, name: p.name, clientId, clientSecret, enabled: Boolean(clientId && clientSecret) };
580
+ out[p.id] = { id: p.id, name: p.name, foreign: Boolean(p.foreign), clientId, clientSecret, enabled: Boolean(clientId && clientSecret) };
568
581
  }
569
582
  return out;
570
583
  }
@@ -574,6 +587,7 @@ export function publicOAuthConfig() {
574
587
  return Object.fromEntries(OAUTH_PROVIDERS.map((p) => [p.id, {
575
588
  id: p.id,
576
589
  name: p.name,
590
+ foreign: Boolean(p.foreign),
577
591
  clientId: cfg[p.id].clientId,
578
592
  hasSecret: Boolean(cfg[p.id].clientSecret),
579
593
  enabled: cfg[p.id].enabled,
@@ -1575,8 +1575,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1575
1575
  const baseShortNeedPct = Number(fc.shortThresholdPct ?? fc.thresholdPct ?? 0.50);
1576
1576
  const minBuyEdgePct = round(baseBuyNeedPct, 3);
1577
1577
  const minShortEdgePct = round(baseShortNeedPct, 3);
1578
- const reqBuyEdgeOf = (sym) => round(Math.max(minBuyEdgePct * symbolEdgeMult(sym), 0.30), 3);
1579
- 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);
1580
1580
  const isInCooldown = (sym) => autoAdapt && exitCooldownMap[sym] && (nowTs - exitCooldownMap[sym] < 60 * 60000);
1581
1581
  // Самообучение на истории реальных сделок счёта (адаптивный карантин убыточных бумаг, единый с research.js):
1582
1582
  // если за последние 15 торговых дней по бумаге накоплен убыток (< -1.2% за >= 2 сделки или одиночный пролив < -2.5%),
@@ -1694,7 +1694,8 @@ export async function prepareBrokerOrders(id, { onlyEntries = false } = {}) {
1694
1694
  const heldMs = effEntryTs > 0 ? Math.max(0, nowTs - effEntryTs) : 0;
1695
1695
  // Как в эталонном симуляторе (research.js): первые 65 минут после открытия позиции действует мягкий порог удержания (0.35),
1696
1696
  // чтобы не резать сделку от рыночного шума каждые 30 минут; затем включается плотный порог фиксации (0.95) с учётом эшелона
1697
- const curExitThr = round(((autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct) * symbolEdgeMult(sym), 3);
1697
+ const symMult = r.edgeMult || symbolEdgeMult(sym);
1698
+ const curExitThr = round(((autoAdapt && heldMs <= 65 * 60000) ? earlyExitThresholdPct : exitThresholdPct) * symMult, 3);
1698
1699
  const nightExit = !onlyEntries && autoAdapt && shouldExitBeforeNight(nowTs, effEntryTs, stepBars, BAR_INTERVAL);
1699
1700
  const curPx = Number(r.price || pos.price) || 0;
1700
1701
  const avgPx = Number(pos.avgPrice) > 0 ? Number(pos.avgPrice) : (Number(lastHistEntryOp?.price) || curPx);
@@ -16,7 +16,40 @@ export const TIER1_LIQUID_SYMBOLS = new Set([
16
16
  'OZON', 'HEAD', 'PHOR', 'AFLT', 'MTSS', 'IRAO', 'TRNFP', 'RUAL', 'FLOT', 'X5',
17
17
  ]);
18
18
 
19
- export const symbolEdgeMult = (symbol) => (TIER1_LIQUID_SYMBOLS.has(symbol) ? 1 : TIER2_EDGE_MULT);
19
+ export function symbolEdgeMult(symbol, { sigma = 0, avgSigma = 0, avgBarValRub = 0 } = {}) {
20
+ const isT1 = TIER1_LIQUID_SYMBOLS.has(symbol);
21
+ const base = isT1 ? 1.0 : TIER2_EDGE_MULT;
22
+ if (!(sigma > 0 || avgBarValRub > 0)) return base;
23
+ const refVal = isT1 ? 15e6 : 1.5e6;
24
+ const liqAdj = avgBarValRub > 0
25
+ ? Math.max(-0.07, Math.min(0.10, -0.05 * Math.log10(Math.max(5e4, avgBarValRub) / refVal)))
26
+ : 0;
27
+ const sigRatio = (sigma > 0 && avgSigma > 0) ? (sigma / avgSigma) : 1;
28
+ const volAdj = Math.max(-0.05, Math.min(0.10, 0.12 * (sigRatio - 1)));
29
+ return Math.max(0.88, Math.min(1.45, base * (1 + liqAdj + volAdj)));
30
+ }
31
+
32
+ export function computeTokenEdgeMults(tokens, sigma, fromIdx = 0, tillIdx = 0) {
33
+ const N = tokens.length;
34
+ let sigSum = 0, sigCnt = 0;
35
+ if (sigma) {
36
+ for (let i = 0; i < N; i++) if (sigma[i] > 0) { sigSum += sigma[i]; sigCnt++; }
37
+ }
38
+ const avgSigma = sigCnt > 0 ? sigSum / sigCnt : 0;
39
+ const out = new Float64Array(N);
40
+ for (let i = 0; i < N; i++) {
41
+ const tk = tokens[i];
42
+ const lo = Math.max(0, fromIdx);
43
+ const hi = tillIdx > lo ? Math.min(tk.value.length, tillIdx) : tk.value.length;
44
+ let vSum = 0, vCnt = 0;
45
+ for (let t = lo; t < hi; t++) {
46
+ if (tk.value[t] > 0) { vSum += tk.value[t]; vCnt++; }
47
+ }
48
+ const avgBarValRub = vCnt > 0 ? vSum / vCnt : 0;
49
+ out[i] = symbolEdgeMult(tk.symbol, { sigma: sigma ? sigma[i] : 0, avgSigma, avgBarValRub });
50
+ }
51
+ return out;
52
+ }
20
53
 
21
54
  // Затухание штрафных счётчиков карантина на 30-минутном шаге (период полураспада ~15 торговых дней: 0,9985^(15×33) ≈ 0,48)
22
55
  export const QUARANTINE_DECAY = 0.9985;
@@ -5,8 +5,8 @@ import { loadDataset, barsFromHours, BAR_MS } from './dataset.js';
5
5
  import { buildFold, MODELS, HEADS, KINDS, SCALES, BETAS, LIQ_MIN, FEATURE_WARMUP, explainAttention, mapSnapshot } from './models.js';
6
6
  import { loadProfiles } from './profiles.js';
7
7
  import { isMarketDown } from './research.js';
8
- import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from './edge.js';
9
- export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult };
8
+ import { ENTRY_EDGE_MULT, TIER2_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults } from './edge.js';
9
+ export { TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults };
10
10
  import { newsForSymbols, blockingNews } from '../news/index.js';
11
11
  import { liveDividendGuard } from './dividends.js';
12
12
  import { fetchTbankRealtimePrices } from '../brokers/index.js';
@@ -313,14 +313,15 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
313
313
  let sInv = 0, nInv = 0;
314
314
  for (let i = 0; i < ds.tokens.length; i++) if (f.sigma[i] > 0 && ds.tokens[i].liq[idx] >= LIQ_MIN) { sInv += 1 / f.sigma[i]; nInv++; }
315
315
  const avgInv = nInv ? sInv / nInv : 0;
316
+ const trainSpan = Math.round(90 * (ds.barsPerDay || 54));
317
+ const edgeMults = computeTokenEdgeMults(ds.tokens, f.sigma, Math.max(0, idx - trainSpan), idx);
316
318
  const rows = ds.tokens.map((tk, i) => {
317
319
  const exp = preds[chosen][i] * f.sigma[i] * Math.sqrt(H);
318
320
  const weight = avgInv > 0 && f.sigma[i] > 0 ? Math.max(0.4, Math.min(1.8, (1 / f.sigma[i]) / avgInv)) : 1;
319
321
  const livePx = liveSnap?.prices?.get(tk.symbol) || tk.close[idx] || 0;
320
322
  const lastMovePct = round(f.z[u * f.N + i] * f.sigma[i] * 100, 3);
321
- // Спред-фильтр по эшелонам ликвидности:
322
- // для 1-го эшелона базовый порог needL/needS (≥ 0.50%), для 2–3 эшелона (широкий спред стакана) — в TIER2_EDGE_MULT (1.35×) выше
323
- const tierMult = symbolEdgeMult(tk.symbol);
323
+ // Индивидуальный множитель порога каждой акции по её фактическому обороту в рублях и волатильности свечи:
324
+ const tierMult = edgeMults[i] || symbolEdgeMult(tk.symbol);
324
325
  const symNeedL = Math.max(needL * tierMult, 0.0030);
325
326
  const symNeedS = Math.max(needS * tierMult, 0.0031);
326
327
  // Аварийный предохранитель экстремальных свечей (±4,5% за одну 10-минутную свечу — планка / остановка торгов / обвал):
@@ -337,7 +338,9 @@ function buildPredictionRows(ds, p, idx, f, u, fitted, chosen, H, effMinEdge, au
337
338
  const buyBlockedByDip = Boolean(liveSnap && exp > 0 && lastMovePct < -EXTREME_BAR_MOVE_PCT);
338
339
  const row = {
339
340
  i, symbol: tk.symbol, name: tk.name,
340
- tier: tierMult === 1 ? 1 : 2,
341
+ tier: TIER1_LIQUID_SYMBOLS.has(tk.symbol) ? 1 : 2,
342
+ edgeMult: round(tierMult, 3),
343
+ sigmaPct: round(f.sigma[i] * 100, 3),
341
344
  price: round(livePx, 4),
342
345
  liquid: tk.liq[idx] >= LIQ_MIN,
343
346
  expected: exp,
@@ -9,7 +9,7 @@ import { ensureProfiles } from './profiles.js';
9
9
  import { spearman } from './linalg.js';
10
10
  import { listRuns, getRun, createRun, finishRun, saveTrade, saveRunExtra, kvGet } from '../db.js';
11
11
  import { strategyDefaults } from '../config.js';
12
- import { ENTRY_EDGE_MULT, symbolEdgeMult, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
12
+ import { ENTRY_EDGE_MULT, TIER1_LIQUID_SYMBOLS, symbolEdgeMult, computeTokenEdgeMults, QUARANTINE_DECAY, isStockQuarantined, canEnterIntraday, shouldExitBeforeNight } from './edge.js';
13
13
  import { buildDividendCalendar, nextDividendAt, dividendRules, DIV_TAX } from './dividends.js';
14
14
 
15
15
  const DAY = 86400000;
@@ -39,7 +39,8 @@ export function isMarketDown(ds, t) {
39
39
  // 8 — скорректированный спектр Марченко–Пастура (sigmaBulk) для 0 — Авто + динамическая концентрация слота (Conviction Sizing)
40
40
  // 9: исторический состав индекса (без ошибки выжившего), дивиденды префов, правила v0.5.x
41
41
  // 10: запрет переноса внутридневных сделок через ночь, период полураспада карантина 15 дн., строгий 1-й эшелон
42
- const DATA_VERSION = 10;
42
+ // 11: индивидуальный расчёт порога каждой акции по её фактическому рублёвому обороту и остаточной волатильности
43
+ const DATA_VERSION = 11;
43
44
 
44
45
  const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
45
46
  function reuseParamsKey(params) {
@@ -195,6 +196,7 @@ async function run(runId, p, state) {
195
196
  const yraw = new Map(); // t -> Float64Array(N)
196
197
  const sigmaAt = new Map(); // t -> обычный размах акций в этом окне обучения
197
198
  const betaAt = new Map(); // t -> бета акций к индексу в этом окне обучения (для защиты от движения рынка)
199
+ const multAt = new Map(); // t -> индивидуальные множители порога каждой акции в этом окне обучения
198
200
  const snapshots = [];
199
201
  let prevPts = null, prevCl = null, lastFold = null, lastModels = null;
200
202
  const folds = Math.ceil((T - H - firstTest) / testLen);
@@ -207,6 +209,7 @@ async function run(runId, p, state) {
207
209
  const mainText = `Обучение ${fi + 1} из ${folds}: учимся до ${fmtDay(timeline[testStart])}, прогнозируем до ${fmtDay(timeline[testEnd - 1])}`;
208
210
  await tick();
209
211
  const f = buildFold(ds, { trainStart, testStart, testEnd, H, embedDim: p.modelEmbedDim, textWeight: p.modelTextWeight, profiles, multiHorizon: autoAdapt });
212
+ const foldEdgeMults = computeTokenEdgeMults(tokens, f.sigma, trainStart, testStart);
210
213
  const fitted = {};
211
214
  for (const n of names) {
212
215
  fitted[n] = await MODELS[n].fit(f);
@@ -225,6 +228,7 @@ async function run(runId, p, state) {
225
228
  yraw.set(t, Float64Array.from(f.yraw.subarray(u * N, u * N + N)));
226
229
  sigmaAt.set(t, f.sigma);
227
230
  betaAt.set(t, f.beta);
231
+ multAt.set(t, foldEdgeMults);
228
232
  }
229
233
  // снимок карты рынка на начало месяца прогноза
230
234
  const snap = mapSnapshot(f);
@@ -291,7 +295,7 @@ async function run(runId, p, state) {
291
295
  const mktFilter = p.modelMarketFilter === 'ma';
292
296
  const invVol = p.modelAllocMode === 'invvol';
293
297
  const realCost = (p.commissionPct + p.slippagePct) / 100;
294
- const symMult = Float64Array.from(tokens, (tk) => symbolEdgeMult(tk.symbol));
298
+ const baseSymMult = Float64Array.from(tokens, (tk) => symbolEdgeMult(tk.symbol));
295
299
  const divCal = buildDividendCalendar(ds);
296
300
  // реальные цены вечного фьючерса на индекс МосБиржи (IMOEXF) — для честной оценки защиты фьючерсом
297
301
  let futOpen = null;
@@ -351,7 +355,7 @@ async function run(runId, p, state) {
351
355
  // множитель порога входа и выхода (1 — как в стратегии): проверка «торговать реже, но с запасом над издержками»
352
356
  const needMult = Number(ex.needMult) > 0 ? Number(ex.needMult) : ENTRY_EDGE_MULT;
353
357
  const cashRateAnnual = Number(ex.cashRateAnnual) > 0 ? Number(ex.cashRateAnnual) : 0;
354
- const costOf = (i) => (ex.tierCost ? (symMult[i] === 1 ? 0.00065 : 0.00125) : cost);
358
+ const costOf = (i) => (ex.tierCost ? (TIER1_LIQUID_SYMBOLS.has(tokens[i].symbol) ? 0.00065 : 0.00125) : cost);
355
359
  // дивидендный предохранитель (правило стратегии, как в живом контуре; выключается только для сравнения)
356
360
  const divGuard = ex.divGuard !== false;
357
361
  // налог 13% с дивиденда по покупкам, удержанным через отсечку (цены в бэктесте скорректированы на полный дивиденд)
@@ -436,6 +440,7 @@ async function run(runId, p, state) {
436
440
  }
437
441
  const pr = preds[name].get(t);
438
442
  const sg = sigmaAt.get(t);
443
+ const symMult = multAt.get(t) || baseSymMult;
439
444
  const bear = isMarketDown(ds, t);
440
445
  const mktDown = mktFilter && bear;
441
446
  const barTs = tsOf(t);
@@ -33,7 +33,10 @@ export function isCrossOrigin(req) {
33
33
  const origin = req.headers?.origin;
34
34
  if (!origin || origin === 'null') return Boolean(origin);
35
35
  try {
36
- return new URL(origin).host.toLowerCase() !== String(req.headers?.host || '').toLowerCase();
36
+ const origHost = new URL(origin).host.toLowerCase();
37
+ const fwdHost = String(req.headers?.['x-forwarded-host'] || '').split(',')[0].trim().toLowerCase();
38
+ const host = String(req.headers?.host || '').toLowerCase();
39
+ return origHost !== host && (!fwdHost || origHost !== fwdHost);
37
40
  } catch {
38
41
  return true;
39
42
  }