neuroalpha 0.6.11 → 0.6.13

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/server/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
2
  import http from 'node:http';
3
+ import fs from 'node:fs';
3
4
  import os from 'node:os';
4
5
  import { spawn } from 'node:child_process';
5
6
  import path from 'node:path';
@@ -141,11 +142,26 @@ app.use('/docs', express.static(path.join(ROOT, 'docs')));
141
142
  app.use(express.static(path.join(ROOT, 'public')));
142
143
 
143
144
  function lanUrls() {
144
- return Object.values(os.networkInterfaces()).flat()
145
+ return Object.entries(os.networkInterfaces())
146
+ .filter(([name]) => !/vethernet|wsl|docker|hyper-v|virtualbox|vmware/i.test(name))
147
+ .flatMap(([, list]) => list || [])
145
148
  .filter((a) => a && a.family === 'IPv4' && !a.internal)
146
149
  .map((a) => `http://${a.address}:${PORT}`);
147
150
  }
148
151
 
152
+ function fmtClickableUrl(url, out = process.stdout) {
153
+ if (!out?.isTTY) return url;
154
+ const osc8 = Boolean(
155
+ process.env.WT_SESSION
156
+ || process.env.TERM_PROGRAM
157
+ || process.env.COLORTERM
158
+ || process.env.FORCE_HYPERLINK === '1'
159
+ || (process.platform !== 'win32' && process.env.TERM && process.env.TERM !== 'dumb'),
160
+ );
161
+ const styled = `\x1b[4;96m${url}\x1b[0m`;
162
+ return osc8 ? `\x1b]8;;${url}\x1b\\${styled}\x1b]8;;\x1b\\` : styled;
163
+ }
164
+
149
165
  const shouldOpenBrowser = process.env.OPEN_BROWSER === '1'
150
166
  || (process.env.OPEN_BROWSER !== '0'
151
167
  && process.env.NO_BROWSER !== '1'
@@ -157,9 +173,10 @@ const shouldOpenBrowser = process.env.OPEN_BROWSER === '1'
157
173
 
158
174
  wss.on('error', () => {});
159
175
  server.on('error', (e) => {
176
+ const clientUrl = `http://localhost:${PORT}`;
160
177
  if (e.code === 'EADDRINUSE') {
161
- console.error(`Порт ${PORT} занят: сервер уже запущен? Клиент: http://localhost:${PORT}`);
162
- if (shouldOpenBrowser) openBrowser(`http://localhost:${PORT}`);
178
+ console.error(`Порт ${PORT} занят: сервер уже запущен? Клиент: ${fmtClickableUrl(clientUrl, process.stderr)}`);
179
+ if (shouldOpenBrowser) openBrowser(clientUrl);
163
180
  } else {
164
181
  console.error(e);
165
182
  }
@@ -168,12 +185,20 @@ server.on('error', (e) => {
168
185
 
169
186
  server.listen(PORT, LISTEN_HOST, () => {
170
187
  const ident = IS_INSTANCE ? getIdentity() : null;
171
- console.log(`Консорциум агентов запущен · режим ${ROLE}${IS_INSTANCE ? ` (${INSTANCE_KIND}) · машина ${ident.id || 'не активирована'} · центр ${ident.centralUrl}` : ''}`);
188
+ let ver = '';
189
+ try { ver = ` v${JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version}`; } catch { /* ignore */ }
190
+ console.log(`◈ НейроАльфа${ver} запущена${IS_INSTANCE ? ` (${INSTANCE_KIND}) · машина ${ident.id || 'не активирована'} · центр ${ident.centralUrl}` : (IS_CENTRAL ? ' · центр' : '')}`);
172
191
  const llm = activeModelInfo();
173
- console.log(`ИИ-стратег: ${llm.enabled ? `${llm.provider} · ${llm.model}` : 'выключен (нет модели или ключа: Настройки → Модели ИИ)'}`);
174
- if (!LISTEN_HOST || LISTEN_HOST === '0.0.0.0') for (const url of lanUrls()) console.log(` В локальной сети: ${url}`);
175
- console.log(`Клиент: http://localhost:${PORT}`);
176
- if (shouldOpenBrowser) openBrowser(`http://localhost:${PORT}`);
192
+ console.log(` ИИ-стратег: ${llm.enabled ? `${llm.provider} · ${llm.model}` : 'выключен (Настройки → Модели ИИ)'}`);
193
+ if (!LISTEN_HOST || LISTEN_HOST === '0.0.0.0') for (const url of lanUrls()) console.log(` В сети: ${fmtClickableUrl(url)}`);
194
+ const clientUrl = `http://localhost:${PORT}`;
195
+ const enterHint = process.stdin?.isTTY && !IS_CENTRAL ? ' (Enter — открыть в браузере)' : '';
196
+ console.log(` Клиент: ${fmtClickableUrl(clientUrl)}${enterHint}`);
197
+ if (shouldOpenBrowser) openBrowser(clientUrl);
198
+ if (process.stdin?.isTTY && !IS_CENTRAL) {
199
+ process.stdin.on('data', () => openBrowser(clientUrl));
200
+ process.stdin.unref?.();
201
+ }
177
202
  });
178
203
 
179
204
  function openBrowser(url) {
@@ -295,10 +295,9 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
295
295
  killer.unref?.();
296
296
  }),
297
297
  ]);
298
- } catch (e) {
298
+ } catch {
299
299
  if (signal?.aborted) throw new Error('Отменено');
300
300
  raw[s] = EMPTY();
301
- console.warn(`[dataset] ${s}: ${e.message} — акция пропущена`);
302
301
  } finally {
303
302
  clearTimeout(killer);
304
303
  onProgress({ symbol: s, done: ++done, total: all.length });
@@ -309,10 +308,9 @@ export async function loadDataset({ interval = BAR_INTERVAL, fromTs, tillTs, use
309
308
  try {
310
309
  onProgress({ symbol: 'BR', done: done, total: all.length });
311
310
  raw.BR = await loadBrent(fromTs, tillTs, signal, { localOnly });
312
- } catch (e) {
311
+ } catch {
313
312
  if (signal?.aborted) throw new Error('Отменено');
314
313
  raw.BR = EMPTY();
315
- console.warn(`[dataset] BR: ${e.message} — нефть пропущена`);
316
314
  }
317
315
  }
318
316
  if (!raw.IMOEX?.n) throw new Error('Нет данных индекса МосБиржи');
@@ -966,9 +966,7 @@ export async function forecastNow(input, { force = false, onProgress } = {}) {
966
966
  if (saved?.result && saved.key === key && now - (saved.result.at || 0) < 4 * 3600000) {
967
967
  if (!inflight.has(key)) {
968
968
  setImmediate(() => {
969
- refreshDatasetStateInBackground(p, key, { force: false }).catch((err) => {
970
- console.warn('[forecast] background warm-up error:', err?.message || err);
971
- });
969
+ refreshDatasetStateInBackground(p, key, { force: false }).catch(() => {});
972
970
  });
973
971
  }
974
972
  const liveSnap = await fetchLiveMoexSnapshot().catch(() => null);
@@ -19,28 +19,44 @@ export const STRATEGY_POLICY = Object.freeze({
19
19
  profitLock: true,
20
20
  // размер позиции: 'slot' — доля бюджета × сила сигнала
21
21
  sizing: Object.freeze({ mode: 'slot' }),
22
- // Множитель размера позиции по эшелонам:
23
- // • 1-й эшелон (33 ликвидные акции, PF 2,86 и 0 стопов на 59 051 свече): масштабируется на полное плечо счёта (lev),
24
- // предельный вес бумаги — до 50% бюджета в лонг и до 38% в шорт (базовый слот 0,50);
25
- // • 2-й эшелон: асимметричное масштабирование от плеча счёта (лонг max(0,50, 0,38 × lev), шорт max(0,35, 0,22 × lev))
26
- // под защитой потолка реальной ликвидности стакана (15% от медианного 10-минутного оборота свечи barTurnoverRub)
27
- // и плотного сопровождения Take-Profit (удержание ≥75% пика и откат ≤0,35%).
28
- // Проверка 13.11.2026 (06.2024–11.2026, 59 051 свеча): улучшает итог на обеих половинах истории при любом плече
29
- // • плечо 1×: CAGR 49,2%/30,7% по половинам (весь 39,5%, PF 1,85, DD 2,0%) против 43,8%/27,3% (весь 35,1%, PF 1,63);
30
- // • плечо 2×: CAGR 73,8%/47,4% по половинам (весь 59,8%, 6 мес. 105,0%, PF 2,03, DD 3,6%) против 54,5%/40,6% (весь 47,2%, PF 1,87, DD 3,4%);
31
- // • плечо 3×: CAGR 104,6%/65,6% по половинам (весь 83,7%, PF 2,07, DD 5,9%) против 65,9%/55,2% (весь 60,3%, PF 2,03, DD 6,0%).
32
- sizeMultT2LongBase: 0.50,
33
- sizeMultT2LongLev: 0.38,
34
- sizeMultT2ShortBase: 0.35,
35
- sizeMultT2ShortLev: 0.22,
36
- capT1Long: 0.50,
37
- capT1Short: 0.38,
38
- capT2Long: 0.22,
39
- capT2Short: 0.18,
22
+ // Адаптивное распределение размера позиции, сублинейное плечо и перелив капитала (water-filling + потеснение):
23
+ // • 1-й эшелон (33 ликвидные акции, 0 стопов на 59 051 свече): живой Conviction Sizing (базовый слот 0,56 × conv до 1,92×)
24
+ // разгоняет одиночные сильные импульсы до потолка 92% бюджета в лонг и 70% в шорт на 1× (убирая холостой простой кэша,
25
+ // т.к. 81,5% активного времени открыта 1 позиция). С ростом плеча счёта потолок одной бумаги растёт сублинейно
26
+ // (1 + 0,80 × (lev − 1): до 166% на 2× и 239% на 3×), оставляя остаток бесплатного плеча под залог LQDT свободным резервом;
27
+ // • 2-й эшелон: единый прямой лимит без двойного перемножения — до 17% бюджета в лонг (масштаб плеча 0,72) и до 7% в шорт (0,40),
28
+ // под жёстким отсечением по реальной ёмкости стакана (15% от медианного 10-минутного оборота свечи barTurnoverRub);
29
+ // • waterFill: при пакетном входе мелкие по лимиту сделки 2-го эшелона не «замораживают» квоту крупных лидеров 1-го эшелона;
30
+ // • crowdOut: двухступенчатое потеснение (ротация остывшей позиции или частичное подрезание без потери peakPrice/entryTs)
31
+ // при нехватке лимита под сильный сигнал 1-го эшелона (повышение качества T2 → T1 либо перекладка из остывшей T1).
32
+ // Проверка 13.11.2026 (06.2024–11.2026, 59 051 свеча): улучшает итог и PF на обеих половинах истории при любом плече и тарифе
33
+ // • плечо 1×: CAGR 59,9%/38,7% по половинам (весь 48,7%, PF 1,90, DD 3,4%) против 49,2%/30,7% (весь 39,5%, PF 1,85);
34
+ // • плечо 2×: CAGR 95,0%/62,4% по половинам (весь 77,6%, 6 мес. 165,1%, PF 2,05, DD 5,6%) против 73,8%/47,4% (весь 59,8%, PF 2,03);
35
+ // • плечо 3×: CAGR 133,7%/90,1% по половинам (весь 110,3%, 6 мес. 292,3%, PF 2,18, DD 7,1%) против 104,6%/65,6% (весь 83,7%, PF 2,07).
36
+ slotOwner: 0.56,
37
+ convMaxOwner: 1.92,
38
+ convKOwner: 0.38,
39
+ capT1Long: 0.92,
40
+ capT1Short: 0.70,
41
+ capT2Long: 0.17,
42
+ capT2Short: 0.070,
43
+ slotT2LongMult: 0.25,
44
+ slotT2ShortMult: 0.10,
45
+ levSlopeT1: 0.80,
46
+ levSlopeT2Long: 0.72,
47
+ levSlopeT2Short: 0.40,
40
48
  turnoverCapMult: 0.15,
41
49
  tpRetainT2: 0.75,
42
50
  tpTrailT2: 0.35,
43
51
  netPickSort: true,
52
+ waterFill: true,
53
+ crowdOut: true,
54
+ crowdMinMin: 20,
55
+ crowdMinPct: 0.06,
56
+ crowdRotBufPct: 0.08,
57
+ crowdTrimBufPct: 0.12,
58
+ crowdCoolRatioT1: 0.45,
59
+ crowdCoolRatioT2: 0.80,
44
60
  // капитал хранится в фонде денежного рынка LQDT как залог: внутридневные позиции открываются под обеспечение
45
61
  // (без платы за перенос — позиции закрываются до ночи), LQDT не продаётся под входы
46
62
  cashYield: true,
@@ -141,44 +157,166 @@ export function exitHoldRatio(heldMin, horizonMin, { sigmaBarPct = 0, realizedBa
141
157
  return startRatio + (endRatio - startRatio) * ramp;
142
158
  }
143
159
 
144
- // ---------- плечо и множители размера по эшелонам ----------
160
+ // ---------- профиль слотов по тарифу, плечо и предельный размер позиции ----------
145
161
  export const MAX_LEVERAGE = 7;
162
+
163
+ export function planSizingProfile(plan = 'owner', dir = 'longshort', policy = STRATEGY_POLICY) {
164
+ const id = typeof plan === 'object' && plan ? (plan.isOwner ? 'owner' : (plan.id || 'investor')) : String(plan || 'owner');
165
+ if (id === 'owner') {
166
+ return {
167
+ id: 'owner',
168
+ slotW: policy?.slotPct ?? policy?.slotOwner ?? 0.56,
169
+ convMax: policy?.convMaxOwner ?? 1.92,
170
+ convK: policy?.convKOwner ?? 0.38,
171
+ capT1Long: policy?.capT1Long ?? 0.92,
172
+ capT1Short: policy?.capT1Short ?? 0.70,
173
+ capT2Long: policy?.capT2Long ?? 0.17,
174
+ capT2Short: policy?.capT2Short ?? 0.070,
175
+ };
176
+ }
177
+ if (id === 'vip') {
178
+ return {
179
+ id: 'vip',
180
+ slotW: policy?.slotPct ?? 0.46,
181
+ convMax: 1.50,
182
+ convK: 0.28,
183
+ capT1Long: 0.75,
184
+ capT1Short: 0.56,
185
+ capT2Long: 0.14,
186
+ capT2Short: 0.065,
187
+ };
188
+ }
189
+ if (id === 'pro') {
190
+ return {
191
+ id: 'pro',
192
+ slotW: policy?.slotPct ?? (dir === 'long' ? 0.36 : 0.34),
193
+ convMax: 1.0,
194
+ convK: 0,
195
+ capT1Long: 0.52,
196
+ capT1Short: 0.40,
197
+ capT2Long: 0.12,
198
+ capT2Short: 0.060,
199
+ };
200
+ }
201
+ return {
202
+ id: 'investor',
203
+ slotW: policy?.slotPct ?? 0.42,
204
+ convMax: 1.0,
205
+ convK: 0,
206
+ capT1Long: 0.58,
207
+ capT1Short: 0,
208
+ capT2Long: 0.14,
209
+ capT2Short: 0,
210
+ };
211
+ }
212
+
213
+ export function convictionMult(edgeRatio = 1, profile = null) {
214
+ const convMax = profile?.convMax ?? 1;
215
+ const convK = profile?.convK ?? 0;
216
+ if (!(convMax > 1) || !(edgeRatio > 0)) return 1;
217
+ return Math.min(convMax, Math.max(0.85, 0.80 + convK * edgeRatio));
218
+ }
219
+
220
+ // Сублинейный множитель масштабирования одиночной позиции от плеча счёта (на плече 1× равен 1,00):
221
+ // разгружает риск одиночной бумаги на высоких плечах и оставляет свободный маржинальный резерв под параллельные идеи.
146
222
  export function tierSizeMult(symbol, leverage = 1, sideOrPolicy = 'long', policyArg = STRATEGY_POLICY) {
147
223
  const isObj = sideOrPolicy && typeof sideOrPolicy === 'object';
148
224
  const side = isObj ? 'long' : (sideOrPolicy === 'short' ? 'short' : 'long');
149
225
  const policy = isObj ? sideOrPolicy : (policyArg || STRATEGY_POLICY);
150
226
  const lev = Math.max(1, Math.min(MAX_LEVERAGE, Number(leverage) || 1));
151
- if (TIER1_LIQUID_SYMBOLS.has(symbol)) return policy.sizeMultT1 ?? lev;
152
- if (policy.sizeMultT2 !== undefined) return policy.sizeMultT2;
153
- const raw = side === 'short'
154
- ? Math.max(policy.sizeMultT2ShortBase ?? 0.35, (policy.sizeMultT2ShortLev ?? 0.22) * lev)
155
- : Math.max(policy.sizeMultT2LongBase ?? 0.50, (policy.sizeMultT2LongLev ?? 0.38) * lev);
156
- return Math.round(raw * 1000) / 1000;
227
+ const isT1 = TIER1_LIQUID_SYMBOLS.has(symbol);
228
+ // Обратная совместимость со старыми вариантами лаборатории (sizeMultT2 / sizeMultT2LongBase)
229
+ if (policy?.sizeMultT2 !== undefined) return isT1 ? (policy.sizeMultT1 ?? lev) : policy.sizeMultT2;
230
+ if (policy?.sizeMultT2LongBase !== undefined) {
231
+ if (isT1) return policy.sizeMultT1 ?? lev;
232
+ const rawOld = side === 'short'
233
+ ? Math.max(policy.sizeMultT2ShortBase ?? 0.35, (policy.sizeMultT2ShortLev ?? 0.22) * lev)
234
+ : Math.max(policy.sizeMultT2LongBase ?? 0.50, (policy.sizeMultT2LongLev ?? 0.38) * lev);
235
+ return Math.round(rawOld * 1000) / 1000;
236
+ }
237
+ if (isT1 && policy?.sizeMultT1 !== undefined) return policy.sizeMultT1;
238
+ const slope = isT1
239
+ ? (policy?.levSlopeT1 ?? 0.80)
240
+ : (side === 'short' ? (policy?.levSlopeT2Short ?? 0.40) : (policy?.levSlopeT2Long ?? 0.72));
241
+ return Math.round((1 + slope * (lev - 1)) * 1000) / 1000;
157
242
  }
158
243
 
159
244
  // ---------- предельный размер позиции на одну бумагу ----------
160
- export function symbolCapRub(symbol, side, budget, barTurnoverRub = 0, policy = STRATEGY_POLICY) {
245
+ export function symbolCapRub(symbol, side, budget, barTurnoverRub = 0, policy = STRATEGY_POLICY, profile = null) {
161
246
  const isT1 = TIER1_LIQUID_SYMBOLS.has(symbol);
162
247
  const isShort = side === 'short';
163
- const capPct = isShort
164
- ? (isT1 ? (policy?.capT1Short ?? 0.38) : (policy?.capT2Short ?? 0.18))
165
- : (isT1 ? (policy?.capT1Long ?? 0.50) : (policy?.capT2Long ?? 0.22));
248
+ const legacyMode = policy?.sizeMultT2 !== undefined || policy?.sizeMultT2LongBase !== undefined;
249
+ let capPct;
250
+ if (legacyMode) {
251
+ capPct = isShort
252
+ ? (isT1 ? (policy?.capT1Short ?? 0.38) : (policy?.capT2Short ?? 0.18))
253
+ : (isT1 ? (policy?.capT1Long ?? 0.50) : (policy?.capT2Long ?? 0.22));
254
+ } else if (profile) {
255
+ capPct = isShort
256
+ ? (isT1 ? profile.capT1Short : profile.capT2Short)
257
+ : (isT1 ? profile.capT1Long : profile.capT2Long);
258
+ } else {
259
+ capPct = isShort
260
+ ? (isT1 ? (policy?.capT1Short ?? 0.70) : (policy?.capT2Short ?? 0.070))
261
+ : (isT1 ? (policy?.capT1Long ?? 0.92) : (policy?.capT2Long ?? 0.17));
262
+ }
166
263
  const rawCap = budget * capPct;
167
264
  const turnMult = policy?.turnoverCapMult ?? 0.15;
168
265
  return (!isT1 && barTurnoverRub > 0) ? Math.max(45000, Math.min(rawCap, barTurnoverRub * turnMult)) : rawCap;
169
266
  }
170
267
 
171
- // Размер новой позиции в рублях до учёта свободного кэша и пропорциональной доли:
172
- // 'slot' — рабочий слот × сила сигнала (conv), не больше предела бумаги;
268
+ // Размер новой позиции в рублях до учёта свободного лимита и итеративного перелива (water-filling):
269
+ // 'slot' — рабочий слот × множитель эшелона × сила сигнала (conv), не больше предела бумаги;
173
270
  // 'risk' — при срабатывании стопа счёт теряет не больше riskPct% бюджета.
174
- export function targetPositionRub({ symbol, side, budget, slotRub, conv = 1, stopPct, barTurnoverRub = 0 }, policy = STRATEGY_POLICY) {
175
- const cap = symbolCapRub(symbol, side, budget, barTurnoverRub, policy);
176
- const sz = policy.sizing || {};
271
+ export function targetPositionRub({ symbol, side, budget, slotRub, conv = 1, stopPct, barTurnoverRub = 0, profile = null }, policy = STRATEGY_POLICY) {
272
+ const cap = symbolCapRub(symbol, side, budget, barTurnoverRub, policy, profile);
273
+ const sz = policy?.sizing || {};
177
274
  if (sz.mode === 'risk' && stopPct > 0) {
178
275
  const byRisk = (budget * (sz.riskPct / 100)) / (stopPct / 100);
179
276
  return Math.min(cap, byRisk * (sz.useConv ? conv : 1), sz.maxSlotPct ? budget * sz.maxSlotPct : Infinity);
180
277
  }
181
- return Math.min(cap, slotRub * conv);
278
+ const isT1 = TIER1_LIQUID_SYMBOLS.has(symbol);
279
+ const legacyMode = policy?.sizeMultT2 !== undefined || policy?.sizeMultT2LongBase !== undefined;
280
+ const slotTierMult = (isT1 || legacyMode)
281
+ ? 1.0
282
+ : (side === 'short' ? (policy?.slotT2ShortMult ?? 0.10) : (policy?.slotT2LongMult ?? 0.25));
283
+ return Math.min(cap, slotRub * slotTierMult * conv);
284
+ }
285
+
286
+ // Итеративное распределение доступного лимита методом перелива (water-filling):
287
+ // бумаги с низким индивидуальным потолком (2-й эшелон / лимит оборота стакана) забирают ровно свой потолок,
288
+ // а весь невыбранный остаток капитала перераспределяется между флагманскими лидерами пропорционально чистой выгоде (weight).
289
+ export function waterFillAllocations(items, availRub) {
290
+ const n = Array.isArray(items) ? items.length : 0;
291
+ const out = new Float64Array(n);
292
+ if (!(availRub > 0) || !n) return out;
293
+ const totalDesired = items.reduce((s, x) => s + Math.max(0, Number(x.targetRub) || 0), 0);
294
+ if (totalDesired <= availRub) {
295
+ for (let k = 0; k < n; k++) out[k] = Math.max(0, Number(items[k].targetRub) || 0);
296
+ return out;
297
+ }
298
+ let remAvail = availRub;
299
+ const open = new Set(items.map((_, idx) => idx));
300
+ while (open.size > 0 && remAvail > 0) {
301
+ let wSum = 0;
302
+ for (const idx of open) wSum += Math.max(1e-6, Number(items[idx].weight) || 0);
303
+ let clampedAny = false;
304
+ for (const idx of [...open]) {
305
+ const share = remAvail * (Math.max(1e-6, Number(items[idx].weight) || 0) / wSum);
306
+ const cap = Math.max(0, Number(items[idx].targetRub) || 0);
307
+ if (cap <= share + 1e-6) {
308
+ out[idx] = cap;
309
+ remAvail = Math.max(0, remAvail - cap);
310
+ open.delete(idx);
311
+ clampedAny = true;
312
+ }
313
+ }
314
+ if (!clampedAny) {
315
+ for (const idx of open) out[idx] = remAvail * (Math.max(1e-6, Number(items[idx].weight) || 0) / wSum);
316
+ break;
317
+ }
318
+ }
319
+ return out;
182
320
  }
183
321
 
184
322
  // ---------- контекст торгового дня бумаги по свечам до момента решения ----------
@@ -41,7 +41,8 @@ export { isMarketDown };
41
41
  // 17: капитал в LQDT как залог, половинный размер 2-го эшелона, строки с плечом 3 и 5
42
42
  // 18: глубина сопровождения позиции и вечернего окна входов holdBars=13 (130 мин) во всех контурах симуляции
43
43
  // 19: ревизия правил 1-го и 2-го эшелонов (MTLR/BSPB/RTKM/SVCB в T1, асимметричный масштаб T2 от плеча, лимит 50%/38% T1 и плотный трал T2)
44
- const DATA_VERSION = 19;
44
+ // 20: разблокировка Conviction Sizing в T1 (до 92%/70% с сублинейным плечом 0,80), прямой риск-лимит T2, перелив water-filling и двухступенчатое потеснение
45
+ const DATA_VERSION = 20;
45
46
 
46
47
  const AUTO_LOCKED = { modelHorizon: 2.5, modelMinEdge: 2.5, modelExitRatio: 0.95 };
47
48
  function reuseParamsKey(params) {
@@ -822,7 +823,7 @@ async function run(runId, p, state) {
822
823
  const lqdtRows = await ensureLqdtHistory().catch(() => []);
823
824
  const lqdt = lqdtRows.length ? alignSeries(timeline, lqdtRows) : null;
824
825
  const liveRows = [];
825
- for (const lev of [1, 3, 5]) {
826
+ for (const lev of [1, 2, 3, 5]) {
826
827
  const lp = simulateStrategy({ ds, wf: wfLive, p, capital: p.capital, shortable: shortableSet(), divCal, lqdt, leverage: lev });
827
828
  const yrs = Math.max(1 / 365, ((lp.curve.at(-1)?.ts || 0) - (lp.curve[0]?.ts || 0)) / (365.25 * 86400000));
828
829
  const lastLpTs = lp.curve.at(-1)?.ts || 0;
@@ -837,7 +838,7 @@ async function run(runId, p, state) {
837
838
  liveRows.push({
838
839
  key: lev === 1 ? 'liveRules' : `liveRulesL${lev}`,
839
840
  label: lev === 1 ? 'Правила живого контура, без плеча (главная оценка)' : `Правила живого контура, плечо ${lev}`,
840
- note: `исполнение и сопровождение как у реального счёта: входы на любой свече, аварийный стоп и фиксация прибыли внутри свечи, окна сессий, планки, перегрев, паузы, размер позиции и пределы на бумагу, издержки по эшелонам${lqdt ? ', капитал в LQDT как залог' : ''}${lev > 1 ? `; позиции 1-го эшелона ×${lev}` : ''}`,
841
+ note: `исполнение и сопровождение как у реального счёта: входы на любой свече, аварийный стоп и фиксация прибыли внутри свечи, окна сессий, планки, перегрев, паузы, итеративный перелив (water-filling), двухступенчатое потеснение и издержки по эшелонам${lqdt ? ', капитал в LQDT как залог' : ''}${lev > 1 ? `; сублинейный масштаб одиночной позиции при лимите портфеля ×${lev}` : ''}`,
841
842
  full: { returnPct: full.returnPct, cagrPct, profitFactor: full.profitFactor, maxDrawdownPct: full.maxDrawdownPct, trades: full.trades, winRate: full.winRate, avgTradePct: full.avgTradePct, longPnl: round(lp.trades.filter((t) => t.side === 'long').reduce((a, t) => a + t.pnl, 0), 0), shortPnl: round(lp.trades.filter((t) => t.side === 'short').reduce((a, t) => a + t.pnl, 0), 0), afterTaxCagrPct: cagrPct === null ? null : round(cagrPct > 0 ? cagrPct * 0.87 : cagrPct, 1) },
842
843
  costs: null,
843
844
  last12: segOf(lastLpTs - 365 * 86400000),
@@ -17,7 +17,11 @@ import {
17
17
  isStockQuarantined, canEnterIntraday, shouldExitBeforeNight, isMarketDown,
18
18
  } from './edge.js';
19
19
  import { nextDividendAt, dividendRules } from './dividends.js';
20
- import { STRATEGY_POLICY, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, exitHoldRatio, tierSizeMult, targetPositionRub, counterTrendBlocked, thesisStopPx, dayContext } from './policy.js';
20
+ import {
21
+ STRATEGY_POLICY, MAX_LEVERAGE, sessionEdgeWarning, profitLockLevels, protectiveStopPct, exitHoldRatio,
22
+ planSizingProfile, convictionMult, tierSizeMult, targetPositionRub, waterFillAllocations,
23
+ counterTrendBlocked, thesisStopPx, dayContext,
24
+ } from './policy.js';
21
25
 
22
26
  // Выравнивание внешнего ряда (свечи IMOEXF, дневные цены LQDT) по шкале датасета с переносом последней цены
23
27
  export function alignSeries(timeline, rows) {
@@ -40,7 +44,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
40
44
  const bpd = Math.max(1, Math.round(ds.barsPerDay || 94));
41
45
  const stepBars = plan === 'owner' || plan === 'vip' ? Math.max(3, Math.floor(holdBars / 5)) : holdBars;
42
46
  const K = policy.K ?? 4;
43
- const dir = p.modelDirection === 'long' ? 'long' : 'longshort';
47
+ const dir = plan === 'investor' || p.modelDirection === 'long' ? 'long' : 'longshort';
44
48
  const realCost = (p.commissionPct + p.slippagePct) / 100;
45
49
  const need = 2.5 * 2 * realCost * (policy.entryEdgeMult ?? ENTRY_EDGE_MULT);
46
50
  const roundTripFeePct = 2 * (p.commissionPct + p.slippagePct);
@@ -50,9 +54,15 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
50
54
  const cooldownBars = Math.round((policy.cooldownMin ?? 30) / BAR_INTERVAL);
51
55
  const stopCooldownBars = Math.round((policy.stopCooldownMin ?? 60) / BAR_INTERVAL);
52
56
  const horizonMin = Math.max(90, holdBars * BAR_INTERVAL);
53
- const slotW = policy.slotPct ?? (plan === 'owner' ? 0.50 : plan === 'vip' ? 0.40 : plan === 'pro' ? Math.min(0.28, 1.15 / 8) : Math.min(0.24, 1.05 / 5));
54
- const convMax = plan === 'owner' ? 1.65 : plan === 'vip' ? 1.30 : 1;
55
- const convK = plan === 'owner' ? 0.30 : 0.25;
57
+ const legacySizing = policy.sizeMultT2 !== undefined || policy.sizeMultT2LongBase !== undefined;
58
+ const profile = legacySizing
59
+ ? {
60
+ slotW: policy.slotPct ?? (plan === 'owner' ? 0.50 : plan === 'vip' ? 0.40 : plan === 'pro' ? Math.min(0.28, 1.15 / 8) : Math.min(0.24, 1.05 / 5)),
61
+ convMax: plan === 'owner' ? 1.65 : plan === 'vip' ? 1.30 : 1,
62
+ convK: plan === 'owner' ? 0.30 : 0.25,
63
+ }
64
+ : planSizingProfile(plan, dir, policy);
65
+ const slotW = profile.slotW;
56
66
  const lev = Math.max(1, Math.min(MAX_LEVERAGE, Number(leverage) || 1));
57
67
  const maxGross = policy.maxGross ?? 0.99 * lev;
58
68
  const capScale = policy.capScale ?? 1;
@@ -69,7 +79,7 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
69
79
  const curve = [];
70
80
  const cooldownUntil = new Int32Array(N).fill(-1);
71
81
  const stockPnl = new Float64Array(N), stockCnt = new Float64Array(N);
72
- const stats = { stops: 0, thesis: 0, locks: 0, signalExits: 0, timeExits: 0, night: 0, rotations: 0, regime: 0, div: 0, blockedCT: 0, entries: 0, missedPassive: 0, hedgePnl: 0, hedgeCost: 0, cashYield: 0, exposureSum: 0, exposureN: 0 };
82
+ const stats = { stops: 0, thesis: 0, locks: 0, signalExits: 0, timeExits: 0, night: 0, rotations: 0, trims: 0, regime: 0, div: 0, blockedCT: 0, entries: 0, missedPassive: 0, hedgePnl: 0, hedgeCost: 0, cashYield: 0, exposureSum: 0, exposureN: 0 };
73
83
  let hedgeRub = 0;
74
84
 
75
85
  const divRule = (i, t) => {
@@ -110,6 +120,35 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
110
120
  stockPnl[i] = stockPnl[i] * 0.90 + (g - 2 * c);
111
121
  stockCnt[i] += 1;
112
122
  };
123
+ const trimPos = (i, pos, exitT, exitPx, trimAllocRub, why) => {
124
+ const frac = Math.min(1, Math.max(0, trimAllocRub / pos.alloc));
125
+ if (frac >= 0.999) {
126
+ closePos(i, pos, exitT, exitPx, why, 'rotation');
127
+ stats.rotations++;
128
+ return true;
129
+ }
130
+ const c = costOf(i);
131
+ const g = gainOf(pos, exitPx);
132
+ const sliceAlloc = pos.alloc * frac;
133
+ const sliceEntryFee = pos.entryFee * frac;
134
+ const gross = sliceAlloc * g;
135
+ const fee = (sliceAlloc + (pos.side === 'long' ? gross : -gross)) * c;
136
+ equity += gross - fee;
137
+ if (collectTrades) {
138
+ trades.push({
139
+ symbol: tokens[i].symbol, side: pos.side, entryTs: timeline[pos.entryT], exitTs: timeline[Math.min(exitT, T - 1)],
140
+ entryPrice: pos.entryPx, exitPrice: exitPx, alloc: sliceAlloc,
141
+ pnl: gross - fee - sliceEntryFee, pnlPct: ((gross - fee - sliceEntryFee) / sliceAlloc) * 100,
142
+ commission: fee + sliceEntryFee, barsHeld: (exitT - pos.entryT) * BAR_INTERVAL,
143
+ expectedPct: pos.expectedPct, stopPct: pos.stopPct, reasonExit: why, exitKind: 'trim',
144
+ dayMovePct: pos.dayMovePct, counterTrend: pos.counterTrend, tier: isT1(i) ? 1 : 2,
145
+ });
146
+ }
147
+ pos.alloc -= sliceAlloc;
148
+ pos.entryFee -= sliceEntryFee;
149
+ stats.trims++;
150
+ return false;
151
+ };
113
152
  const mtmUnreal = (t) => {
114
153
  let s = 0;
115
154
  for (const q of held.values()) s += q.alloc * gainOf(q, tokens[q.i].close[t]);
@@ -304,8 +343,84 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
304
343
  let nL = 0, nS = 0;
305
344
  for (const q of held.values()) { if (q.side === 'long') nL++; else nS++; }
306
345
  const budget = equity + mtmUnreal(t);
307
- // ротация остывшей позиции в сильного лидера (на плановом шаге)
308
- if (isStep && (candL.length || candS.length) && grossUsed(t) > budget * maxGross * 0.65) {
346
+ const pickWeight = (i) => (policy.netPickSort !== false ? Math.max(0.0001, Math.abs(edge(i)) - 2 * costOf(i)) : Math.max(0.0001, Math.abs(edge(i))));
347
+ const calcDesiredRub = (i, side, e, stopPct) => {
348
+ const tm = tierSizeMult(tokens[i].symbol, lev, side, policy);
349
+ if (!(tm > 0)) return 0;
350
+ const conv = convictionMult(Math.abs(e) / need, profile);
351
+ return targetPositionRub({
352
+ symbol: tokens[i].symbol, side,
353
+ budget: budget * capScale * tm,
354
+ slotRub: budget * slotW * tm,
355
+ conv, stopPct,
356
+ barTurnoverRub: isT1(i) ? 0 : barTurnover(i, t),
357
+ profile: legacySizing ? null : profile,
358
+ }, policy);
359
+ };
360
+
361
+ // Двухступенчатое динамическое потеснение (ротация остывшей позиции или частичное подрезание T2 под флагман T1)
362
+ let avail = Math.max(0, budget * maxGross - grossUsed(t));
363
+ if (!legacySizing && policy.crowdOut !== false && held.size > 0 && (candL.length || candS.length)) {
364
+ const rawPicks = [
365
+ ...candL.slice(0, Math.max(0, K - nL)).map((i) => [i, 'long']),
366
+ ...candS.slice(0, Math.max(0, K - nS)).map((i) => [i, 'short']),
367
+ ];
368
+ rawPicks.sort((a, b) => pickWeight(b[0]) - pickWeight(a[0]));
369
+ const minAgeBars = Math.round((policy.crowdMinMin ?? 20) / BAR_INTERVAL);
370
+ const minChunkRub = Math.max(45000, budget * (policy.crowdMinPct ?? 0.06));
371
+ const rotBuf = (policy.crowdRotBufPct ?? 0.08) / 100;
372
+ const trimBuf = (policy.crowdTrimBufPct ?? 0.12) / 100;
373
+ const desiredItems = [];
374
+ for (const [i, side] of rawPicks) {
375
+ const e = edge(i);
376
+ const sigmaBarPct = sg[i] * 100;
377
+ const ctx = dayContext(ds, i, t, sigmaBarPct);
378
+ if (counterTrendBlocked(side, ctx, policy)) continue;
379
+ const stopPct = protectiveStopPct(side, { sigmaBarPct, realizedBarPct: ctx.realizedBarPct, horizonBars: holdBars }, policy);
380
+ desiredItems.push({ i, side, e, netEdge: pickWeight(i), targetRub: calcDesiredRub(i, side, e, stopPct) });
381
+ }
382
+ const totalDesired = desiredItems.reduce((s, x) => s + x.targetRub, 0);
383
+ let shortfall = totalDesired - avail;
384
+ const bestT1 = desiredItems.find((x) => isT1(x.i));
385
+ if (shortfall >= minChunkRub && bestT1) {
386
+ const elig = [];
387
+ for (const [hi, q] of held) {
388
+ if (t + 1 - q.entryT < minAgeBars) continue;
389
+ if (!(tokens[hi].open[t + 1] > 0)) continue;
390
+ const curSigned = q.side === 'long' ? edge(hi) : -edge(hi);
391
+ const thrEntry = q.side === 'long' ? Math.max(needL * mult[hi] * tierScale(hi), minEdgeL) : Math.max(needS * mult[hi] * tierScale(hi), minEdgeS);
392
+ elig.push({ hi, q, curSigned, thrEntry, tierRank: isT1(hi) ? 1 : 0 });
393
+ }
394
+ elig.sort((a, b) => (a.tierRank - b.tierRank) || (a.curSigned - b.curSigned));
395
+ for (const el of elig) {
396
+ if (shortfall < minChunkRub * 0.5) break;
397
+ const { hi, q, curSigned, thrEntry } = el;
398
+ const pxExit = fill === 'mid' ? (tokens[hi].open[t + 1] + tokens[hi].close[t + 1]) / 2 : tokens[hi].open[t + 1];
399
+ const heldIsT1 = isT1(hi);
400
+ const cooled = curSigned < thrEntry * (heldIsT1 ? (policy.crowdCoolRatioT1 ?? 0.45) : (policy.crowdCoolRatioT2 ?? 0.80));
401
+ const netAdv = bestT1.netEdge - Math.max(0, curSigned);
402
+ if (cooled && netAdv >= rotBuf) {
403
+ if (!heldIsT1 || curSigned < thrEntry * 0.35 || q.alloc - shortfall < minChunkRub) {
404
+ closePos(hi, q, t + 1, pxExit, 'Ротация в сильный сигнал', 'rotation');
405
+ stats.rotations++;
406
+ if (q.side === 'long') nL--; else nS--;
407
+ } else {
408
+ const trimAmt = Math.min(shortfall, q.alloc - minChunkRub);
409
+ if (trimAmt >= minChunkRub) trimPos(hi, q, t + 1, pxExit, trimAmt, 'Частичное потеснение под сильный сигнал');
410
+ }
411
+ avail = Math.max(0, budget * maxGross - grossUsed(t));
412
+ shortfall = totalDesired - avail;
413
+ } else if (!cooled && !heldIsT1 && netAdv >= trimBuf && q.alloc >= minChunkRub * 1.5) {
414
+ const trimAmt = Math.min(shortfall, q.alloc - minChunkRub);
415
+ if (trimAmt >= minChunkRub) {
416
+ trimPos(hi, q, t + 1, pxExit, trimAmt, 'Частичное потеснение под сильный сигнал');
417
+ avail = Math.max(0, budget * maxGross - grossUsed(t));
418
+ shortfall = totalDesired - avail;
419
+ }
420
+ }
421
+ }
422
+ }
423
+ } else if (legacySizing && isStep && (candL.length || candS.length) && grossUsed(t) > budget * maxGross * 0.65) {
309
424
  const best = Math.max(candL.length ? edge(candL[0]) : 0, candS.length ? -edge(candS[0]) : 0);
310
425
  if (best >= need * 2.2) {
311
426
  let wi = -1, we = Infinity;
@@ -321,28 +436,42 @@ export function simulateStrategy({ ds, wf, p, policy = STRATEGY_POLICY, capital
321
436
  }
322
437
  }
323
438
  }
439
+
324
440
  const picks = [
325
441
  ...candL.slice(0, Math.max(0, K - nL)).map((i) => [i, 'long']),
326
442
  ...candS.slice(0, Math.max(0, K - nS)).map((i) => [i, 'short']),
327
443
  ];
328
- const pickWeight = (i) => (policy.netPickSort !== false ? Math.max(0.0001, Math.abs(edge(i)) - 2 * costOf(i)) : Math.max(0.0001, Math.abs(edge(i))));
329
444
  picks.sort((a, b) => pickWeight(b[0]) - pickWeight(a[0]));
330
- let avail = Math.max(0, budget * maxGross - grossUsed(t));
331
- let remW = picks.reduce((s, [i]) => s + pickWeight(i), 0);
445
+ avail = Math.max(0, budget * maxGross - grossUsed(t));
446
+ const prepared = [];
332
447
  for (const [i, side] of picks) {
333
448
  const e = edge(i);
334
- const w = pickWeight(i);
335
- const propShare = remW > 0 ? avail * (w / remW) : avail;
336
- remW -= w;
337
449
  const sigmaBarPct = sg[i] * 100;
338
450
  const ctx = dayContext(ds, i, t, sigmaBarPct);
339
451
  if (counterTrendBlocked(side, ctx, policy)) { stats.blockedCT++; continue; }
340
452
  const stopPct = protectiveStopPct(side, { sigmaBarPct, realizedBarPct: ctx.realizedBarPct, horizonBars: holdBars }, policy);
341
- const conv = Math.min(convMax, Math.max(0.85, 0.80 + convK * (Math.abs(e) / need)));
342
- const tm = tierSizeMult(tokens[i].symbol, lev, side, policy);
343
- if (!(tm > 0)) continue;
344
- const target = targetPositionRub({ symbol: tokens[i].symbol, side, budget: budget * capScale * tm, slotRub: budget * slotW * tm, conv: convMax > 1 ? conv : 1, stopPct, barTurnoverRub: isT1(i) ? 0 : barTurnover(i, t) }, policy);
345
- const alloc = Math.min(target, propShare, avail);
453
+ const targetRub = calcDesiredRub(i, side, e, stopPct);
454
+ if (!(targetRub > 0)) continue;
455
+ prepared.push({ i, side, e, weight: pickWeight(i), targetRub, stopPct, ctx });
456
+ }
457
+ const allocs = (!legacySizing && policy.waterFill !== false)
458
+ ? waterFillAllocations(prepared, avail)
459
+ : (() => {
460
+ const out = new Float64Array(prepared.length);
461
+ let a = avail;
462
+ let remW = prepared.reduce((s, x) => s + x.weight, 0);
463
+ for (let idx = 0; idx < prepared.length; idx++) {
464
+ const propShare = remW > 0 ? a * (prepared[idx].weight / remW) : a;
465
+ remW -= prepared[idx].weight;
466
+ const al = Math.min(prepared[idx].targetRub, propShare, a);
467
+ out[idx] = al;
468
+ a -= al;
469
+ }
470
+ return out;
471
+ })();
472
+ for (let idx = 0; idx < prepared.length; idx++) {
473
+ const { i, side, e, stopPct, ctx } = prepared[idx];
474
+ const alloc = Math.min(allocs[idx], avail);
346
475
  if (!(alloc > 5000)) continue;
347
476
  const tk = tokens[i];
348
477
  let entryPx = fill === 'mid' ? (tk.open[t + 1] + tk.close[t + 1]) / 2 : tk.open[t + 1];