pump-anomaly 0.1.0
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/LICENSE +21 -0
- package/README.md +678 -0
- package/build/index.cjs +2755 -0
- package/build/index.mjs +2680 -0
- package/package.json +58 -0
- package/types.d.ts +1346 -0
package/build/index.mjs
ADDED
|
@@ -0,0 +1,2680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Контракты pump-matrix.
|
|
3
|
+
*
|
|
4
|
+
* ParserItem — совместим со схемой parser-items из backtest-ollama-crontab
|
|
5
|
+
* (поля direction/entry/targets/stoploss присутствуют в источнике, но детектору
|
|
6
|
+
* нужны только channel/symbol/direction/ts — остальное игнорируется).
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_CONFIG = {
|
|
9
|
+
windowK: 3,
|
|
10
|
+
minClusters: 2,
|
|
11
|
+
jaccardThreshold: 0.3,
|
|
12
|
+
lagPeakThreshold: 0.5,
|
|
13
|
+
maxBurstWindowMs: 60 * 60 * 1000,
|
|
14
|
+
mode: "auto",
|
|
15
|
+
stationarityWindowMs: Infinity,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const keyOf = (e) => `${e.symbol}|${e.direction}`;
|
|
19
|
+
const splitKey = (k) => k.split("|");
|
|
20
|
+
/** Нормализует сырой поток событий в индексированную таблицу. */
|
|
21
|
+
function buildTable(raw) {
|
|
22
|
+
const events = [...raw].sort((a, b) => a.ts - b.ts);
|
|
23
|
+
const byKey = new Map();
|
|
24
|
+
const byChannelKey = new Map();
|
|
25
|
+
const channelSet = new Set();
|
|
26
|
+
for (const e of events) {
|
|
27
|
+
const k = keyOf(e);
|
|
28
|
+
let g = byKey.get(k);
|
|
29
|
+
if (!g)
|
|
30
|
+
byKey.set(k, (g = []));
|
|
31
|
+
g.push(e);
|
|
32
|
+
const ck = `${e.channel}|${k}`;
|
|
33
|
+
let c = byChannelKey.get(ck);
|
|
34
|
+
if (!c)
|
|
35
|
+
byChannelKey.set(ck, (c = []));
|
|
36
|
+
c.push(e.ts);
|
|
37
|
+
channelSet.add(e.channel);
|
|
38
|
+
}
|
|
39
|
+
return { events, byKey, byChannelKey, channels: [...channelSet] };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Окно стационарности. Статистики (τ, author-матрица, Jaccard) на длинном горизонте
|
|
43
|
+
* корраптятся: они агрегируются по ВСЕЙ истории, а за 5 месяцев режим дрейфует —
|
|
44
|
+
* каналы появляются/замолкают, «братские» пары распадаются, τ плывёт. Один глобальный
|
|
45
|
+
* набор усредняет несопоставимые периоды.
|
|
46
|
+
*
|
|
47
|
+
* Решение без новой математики: считать статистики только по локальному окну,
|
|
48
|
+
* заканчивающемуся в момент anchorTs. windowMs=Infinity → вся история (старое
|
|
49
|
+
* поведение, для коротких данных). Размер окна перебирается grid'ом в train.
|
|
50
|
+
*/
|
|
51
|
+
function windowEvents(events, anchorTs, windowMs) {
|
|
52
|
+
if (!Number.isFinite(windowMs))
|
|
53
|
+
return events;
|
|
54
|
+
const lo = anchorTs - windowMs;
|
|
55
|
+
// events отсортированы по ts → берём срез (lo, anchorTs]
|
|
56
|
+
return events.filter((e) => e.ts > lo && e.ts <= anchorTs);
|
|
57
|
+
}
|
|
58
|
+
/** Таблица, построенная по окну стационарности до anchorTs. */
|
|
59
|
+
function buildWindowedTable(events, anchorTs, windowMs) {
|
|
60
|
+
return buildTable(windowEvents(events, anchorTs, windowMs));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MIN = 60_000;
|
|
64
|
+
/**
|
|
65
|
+
* Слой 1 — самооценка характерного лага τ.
|
|
66
|
+
*
|
|
67
|
+
* Строит гистограмму всех попарных положительных задержек между РАЗНЫМИ каналами
|
|
68
|
+
* по совпадающим (symbol,direction). У случайных пар распределение ≈ плоское,
|
|
69
|
+
* у «братских» каналов — острый пик у малого лага. Модальный лог-бин даёт τ.
|
|
70
|
+
*
|
|
71
|
+
* Возвращает τ в мс, зажатый в [30с, 60мин]. Если данных мало — дефолт 15 мин.
|
|
72
|
+
*/
|
|
73
|
+
function selfTuneLag(tbl) {
|
|
74
|
+
const deltas = [];
|
|
75
|
+
const HORIZON = 6 * 60 * MIN; // парные задержки в пределах 6ч
|
|
76
|
+
for (const evs of tbl.byKey.values()) {
|
|
77
|
+
for (let i = 0; i < evs.length; i++) {
|
|
78
|
+
for (let j = i + 1; j < evs.length; j++) {
|
|
79
|
+
const d = evs[j].ts - evs[i].ts;
|
|
80
|
+
if (d <= 0)
|
|
81
|
+
continue;
|
|
82
|
+
if (d > HORIZON)
|
|
83
|
+
break; // массив сортирован — дальше только больше
|
|
84
|
+
if (evs[i].channel !== evs[j].channel)
|
|
85
|
+
deltas.push(d);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (deltas.length < 8)
|
|
90
|
+
return 15 * MIN;
|
|
91
|
+
deltas.sort((a, b) => a - b);
|
|
92
|
+
const minD = Math.max(deltas[0], 1000);
|
|
93
|
+
const maxD = deltas[deltas.length - 1];
|
|
94
|
+
const BINS = 24;
|
|
95
|
+
const logMin = Math.log(minD);
|
|
96
|
+
const logMax = Math.log(maxD);
|
|
97
|
+
const span = logMax - logMin || 1;
|
|
98
|
+
const hist = new Array(BINS).fill(0);
|
|
99
|
+
for (const d of deltas) {
|
|
100
|
+
let b = Math.floor(((Math.log(d) - logMin) / span) * BINS);
|
|
101
|
+
if (b >= BINS)
|
|
102
|
+
b = BINS - 1;
|
|
103
|
+
if (b < 0)
|
|
104
|
+
b = 0;
|
|
105
|
+
hist[b]++;
|
|
106
|
+
}
|
|
107
|
+
let peak = 0;
|
|
108
|
+
for (let b = 1; b < BINS; b++)
|
|
109
|
+
if (hist[b] > hist[peak])
|
|
110
|
+
peak = b;
|
|
111
|
+
const binCenterLog = logMin + ((peak + 0.5) / BINS) * span;
|
|
112
|
+
const tau = Math.exp(binCenterLog);
|
|
113
|
+
return Math.min(Math.max(tau, 30 * 1000), 60 * MIN);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Близость двух каналов по скользящему окну (сырой ts, без бакетизации).
|
|
118
|
+
* Доля событий по общим (symbol,direction), у которых нашёлся партнёр у другого
|
|
119
|
+
* канала в пределах |Δ| ≤ window. Симметризованный Jaccard.
|
|
120
|
+
*/
|
|
121
|
+
function jaccardPair(tbl, a, b, window) {
|
|
122
|
+
let matched = 0;
|
|
123
|
+
let total = 0;
|
|
124
|
+
for (const k of tbl.byKey.keys()) {
|
|
125
|
+
const ta = tbl.byChannelKey.get(`${a}|${k}`);
|
|
126
|
+
const tb = tbl.byChannelKey.get(`${b}|${k}`);
|
|
127
|
+
if (!ta && !tb)
|
|
128
|
+
continue;
|
|
129
|
+
total += (ta?.length ?? 0) + (tb?.length ?? 0);
|
|
130
|
+
if (!ta || !tb)
|
|
131
|
+
continue;
|
|
132
|
+
// two-pointer: ближайшие пары в пределах окна
|
|
133
|
+
let i = 0;
|
|
134
|
+
let j = 0;
|
|
135
|
+
let m = 0;
|
|
136
|
+
while (i < ta.length && j < tb.length) {
|
|
137
|
+
const d = ta[i] - tb[j];
|
|
138
|
+
if (Math.abs(d) <= window) {
|
|
139
|
+
m++;
|
|
140
|
+
i++;
|
|
141
|
+
j++;
|
|
142
|
+
}
|
|
143
|
+
else if (d < 0) {
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
j++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
matched += 2 * m;
|
|
151
|
+
}
|
|
152
|
+
return total === 0 ? 0 : matched / total;
|
|
153
|
+
}
|
|
154
|
+
/** Слой 2 — грубое сито: все пары каналов с Jaccard ≥ threshold. */
|
|
155
|
+
function jaccardScreen(tbl, window, threshold) {
|
|
156
|
+
const ch = tbl.channels;
|
|
157
|
+
const edges = [];
|
|
158
|
+
for (let i = 0; i < ch.length; i++) {
|
|
159
|
+
for (let j = i + 1; j < ch.length; j++) {
|
|
160
|
+
const jac = jaccardPair(tbl, ch[i], ch[j], window);
|
|
161
|
+
if (jac >= threshold)
|
|
162
|
+
edges.push({ a: ch[i], b: ch[j], jaccard: jac });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return edges;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const HORIZON = 6 * 60 * 60 * 1000;
|
|
169
|
+
/**
|
|
170
|
+
* Слой 3 — лаговая кросс-корреляция точечных процессов.
|
|
171
|
+
*
|
|
172
|
+
* Для каждой пары-кандидата собирает знаковые задержки Δ = t_b − t_a между
|
|
173
|
+
* ближайшими событиями по общим (symbol,direction). Узкий смещённый пик ⇒
|
|
174
|
+
* братские каналы одного автора; размазанный фон ⇒ совпадение, ребро отбрасывается.
|
|
175
|
+
*
|
|
176
|
+
* Острота пика меряется по peakWindow (= windowK·τ, окно сита), НЕ по голому τ:
|
|
177
|
+
* иначе брат с лагом чуть больше τ ложно выпадает и пара рвётся.
|
|
178
|
+
*/
|
|
179
|
+
function lagXCorr(tbl, edges, peakThreshold, peakWindow) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const e of edges) {
|
|
182
|
+
const deltas = [];
|
|
183
|
+
for (const k of tbl.byKey.keys()) {
|
|
184
|
+
const ta = tbl.byChannelKey.get(`${e.a}|${k}`);
|
|
185
|
+
const tb = tbl.byChannelKey.get(`${e.b}|${k}`);
|
|
186
|
+
if (!ta || !tb)
|
|
187
|
+
continue;
|
|
188
|
+
for (const t of ta) {
|
|
189
|
+
let best = Infinity;
|
|
190
|
+
for (const s of tb) {
|
|
191
|
+
const d = s - t; // >0: b позже a ⇒ a лидер
|
|
192
|
+
if (Math.abs(d) < Math.abs(best))
|
|
193
|
+
best = d;
|
|
194
|
+
}
|
|
195
|
+
if (Number.isFinite(best) && Math.abs(best) <= HORIZON)
|
|
196
|
+
deltas.push(best);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (deltas.length === 0)
|
|
200
|
+
continue;
|
|
201
|
+
const within = deltas.filter((d) => Math.abs(d) <= peakWindow);
|
|
202
|
+
const peakShare = within.length / deltas.length;
|
|
203
|
+
if (peakShare < peakThreshold)
|
|
204
|
+
continue;
|
|
205
|
+
const sorted = [...deltas].sort((x, y) => x - y);
|
|
206
|
+
const med = sorted[Math.floor(sorted.length / 2)];
|
|
207
|
+
const aLeads = med >= 0;
|
|
208
|
+
out.push({
|
|
209
|
+
...e,
|
|
210
|
+
lag: Math.abs(med),
|
|
211
|
+
peakShare,
|
|
212
|
+
leader: aLeads ? e.a : e.b,
|
|
213
|
+
follower: aLeads ? e.b : e.a,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Слой 4 — кластеризация каналов в авторов (union-find / connected components).
|
|
221
|
+
* Каждое направленное ребро «братства» сливает два канала в один кластер.
|
|
222
|
+
* Возвращает карту channel → целочисленный id кластера.
|
|
223
|
+
*/
|
|
224
|
+
function clusterAuthors(channels, edges) {
|
|
225
|
+
const parent = new Map();
|
|
226
|
+
channels.forEach((c) => parent.set(c, c));
|
|
227
|
+
const find = (x) => {
|
|
228
|
+
let r = x;
|
|
229
|
+
while (parent.get(r) !== r)
|
|
230
|
+
r = parent.get(r);
|
|
231
|
+
// path compression
|
|
232
|
+
while (parent.get(x) !== r) {
|
|
233
|
+
const n = parent.get(x);
|
|
234
|
+
parent.set(x, r);
|
|
235
|
+
x = n;
|
|
236
|
+
}
|
|
237
|
+
return r;
|
|
238
|
+
};
|
|
239
|
+
const union = (a, b) => parent.set(find(a), find(b));
|
|
240
|
+
for (const e of edges)
|
|
241
|
+
union(e.a, e.b);
|
|
242
|
+
const rootId = new Map();
|
|
243
|
+
const result = new Map();
|
|
244
|
+
let next = 0;
|
|
245
|
+
for (const c of channels) {
|
|
246
|
+
const r = find(c);
|
|
247
|
+
if (!rootId.has(r))
|
|
248
|
+
rootId.set(r, next++);
|
|
249
|
+
result.set(c, rootId.get(r));
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Слой 5 — early-warning по НЕЗАВИСИМЫМ кластерам-авторам.
|
|
256
|
+
*
|
|
257
|
+
* Для каждого (symbol,direction) скользящим окном считает плотность не каналов,
|
|
258
|
+
* а РАЗНЫХ кластеров. Всплеск из N каналов одного автора → 1 кластер → skip.
|
|
259
|
+
* Всплеск из ≥ minClusters независимых кластеров → open.
|
|
260
|
+
*
|
|
261
|
+
* confidence = dedup × fill, где
|
|
262
|
+
* dedup = clusters/channels (1 = все источники независимы, <1 = есть дубли автора)
|
|
263
|
+
* fill = насыщенность окна относительно minClusters·2 (растёт с числом источников)
|
|
264
|
+
*/
|
|
265
|
+
function earlyWarning(tbl, clusterOf, cfg, tau) {
|
|
266
|
+
const window = Math.min(cfg.windowK * tau, cfg.maxBurstWindowMs);
|
|
267
|
+
const verdicts = [];
|
|
268
|
+
for (const [k, evs] of tbl.byKey) {
|
|
269
|
+
const [symbol, direction] = splitKey(k);
|
|
270
|
+
let lo = 0;
|
|
271
|
+
let best = null;
|
|
272
|
+
for (let hi = 0; hi < evs.length; hi++) {
|
|
273
|
+
while (evs[hi].ts - evs[lo].ts > window)
|
|
274
|
+
lo++;
|
|
275
|
+
const slice = evs.slice(lo, hi + 1);
|
|
276
|
+
const clusters = new Set(slice.map((e) => clusterOf.get(e.channel)));
|
|
277
|
+
const channels = new Set(slice.map((e) => e.channel));
|
|
278
|
+
if (clusters.size >= cfg.minClusters) {
|
|
279
|
+
const dedup = clusters.size / channels.size;
|
|
280
|
+
const fill = Math.min(slice.length / (cfg.minClusters * 2), 1);
|
|
281
|
+
const confidence = +(dedup * fill).toFixed(6);
|
|
282
|
+
const cand = {
|
|
283
|
+
symbol,
|
|
284
|
+
direction,
|
|
285
|
+
action: "open",
|
|
286
|
+
ts: evs[hi].ts,
|
|
287
|
+
independentClusters: clusters.size,
|
|
288
|
+
totalChannels: channels.size,
|
|
289
|
+
confidence,
|
|
290
|
+
reason: `${clusters.size} независимых кластеров по ${symbol} ${direction} ` +
|
|
291
|
+
`в окне ${(window / 60000).toFixed(0)}м (каналов: ${channels.size})`,
|
|
292
|
+
source: "matrix",
|
|
293
|
+
channel: null,
|
|
294
|
+
};
|
|
295
|
+
if (!best || cand.confidence > best.confidence)
|
|
296
|
+
best = cand;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
verdicts.push(best ?? {
|
|
300
|
+
symbol,
|
|
301
|
+
direction,
|
|
302
|
+
action: "skip",
|
|
303
|
+
ts: evs[evs.length - 1]?.ts ?? 0,
|
|
304
|
+
independentClusters: 0,
|
|
305
|
+
totalChannels: new Set(evs.map((e) => e.channel)).size,
|
|
306
|
+
confidence: 0,
|
|
307
|
+
reason: `нет синхронного всплеска независимых авторов по ${symbol} ${direction}`,
|
|
308
|
+
source: "matrix",
|
|
309
|
+
channel: null,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return verdicts.sort((a, b) => b.confidence - a.confidence);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Single-channel fallback. Когда корреляция недоступна (один канал / mode="single"),
|
|
317
|
+
* матрица авторства пуста и earlyWarning молчит. Но даже один пост двигает рынок:
|
|
318
|
+
* аудитория входит, возникает краткосрочный импульс. Поэтому здесь КАЖДЫЙ пост =
|
|
319
|
+
* сигнал к входу, а вся ответственность за результат — на обученном exit
|
|
320
|
+
* (trailing take / hard stop / staleness / импакт-горизонт), который уже доказал,
|
|
321
|
+
* что отделяет памп от stop hunt.
|
|
322
|
+
*
|
|
323
|
+
* Дедупликация: несколько постов по одному (symbol,direction) в пределах окна
|
|
324
|
+
* схлопываются в один вход (повторный пост в активную позицию — не новый вход).
|
|
325
|
+
*/
|
|
326
|
+
function singleChannelSignals(tbl, cfg, tau) {
|
|
327
|
+
const window = Math.min(cfg.windowK * tau, cfg.maxBurstWindowMs);
|
|
328
|
+
const verdicts = [];
|
|
329
|
+
const toId = (e) => {
|
|
330
|
+
const r = e.id;
|
|
331
|
+
return typeof r === "string" ? r : (typeof r === "number" ? String(r) : undefined);
|
|
332
|
+
};
|
|
333
|
+
for (const [k, evs] of tbl.byKey) {
|
|
334
|
+
const [symbol, direction] = splitKey(k);
|
|
335
|
+
// схлопываем близкие посты в один вход
|
|
336
|
+
let lastTs = -Infinity;
|
|
337
|
+
let current = null;
|
|
338
|
+
for (const e of evs) {
|
|
339
|
+
const id = toId(e);
|
|
340
|
+
if (e.ts - lastTs <= window) {
|
|
341
|
+
// схлопнутый пост — его id НЕ теряем (иначе несопоставим с парсингом)
|
|
342
|
+
if (current && id != null)
|
|
343
|
+
current.ids.push(id);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
lastTs = e.ts;
|
|
347
|
+
current = {
|
|
348
|
+
symbol,
|
|
349
|
+
direction,
|
|
350
|
+
action: "open",
|
|
351
|
+
ts: e.ts,
|
|
352
|
+
independentClusters: 1,
|
|
353
|
+
totalChannels: 1,
|
|
354
|
+
confidence: 0.5, // нейтральная уверенность: вход есть, фильтра качества нет
|
|
355
|
+
reason: `single-channel fallback: пост по ${symbol} ${direction} (exit решает исход)`,
|
|
356
|
+
source: "single",
|
|
357
|
+
channel: e.channel,
|
|
358
|
+
id,
|
|
359
|
+
ids: id != null ? [id] : [],
|
|
360
|
+
};
|
|
361
|
+
verdicts.push(current);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return verdicts.sort((a, b) => b.ts - a.ts);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Жизнеспособность матрицы авторства. Отвечает на вопрос «достаточно ли в данных
|
|
369
|
+
* структуры, чтобы доверять корреляции», а НЕ «выдала ли матрица хоть что-то».
|
|
370
|
+
*
|
|
371
|
+
* Без этого auto оставался бы в matrix даже на двух каналах со ШУМОВЫМ совпадением
|
|
372
|
+
* (Jaccard случайно перевалил порог на 1-2 событиях) и выдавал бы ложный сигнал.
|
|
373
|
+
* Строгий критерий: матрица годна только при ЯВНЫХ кластерах И достаточном
|
|
374
|
+
* событийном перекрытии; иначе — откат в single.
|
|
375
|
+
*/
|
|
376
|
+
const DEFAULT_VIABILITY = {
|
|
377
|
+
minSharedEvents: 3,
|
|
378
|
+
minPeakShare: 0.6,
|
|
379
|
+
minStrongEdges: 1,
|
|
380
|
+
minStructure: 2,
|
|
381
|
+
};
|
|
382
|
+
/** Считает макс. событийное перекрытие среди всех пар каналов по общим ключам. */
|
|
383
|
+
function maxSharedEvents(tbl) {
|
|
384
|
+
const ch = tbl.channels;
|
|
385
|
+
let max = 0;
|
|
386
|
+
for (let i = 0; i < ch.length; i++)
|
|
387
|
+
for (let j = i + 1; j < ch.length; j++) {
|
|
388
|
+
let shared = 0;
|
|
389
|
+
for (const k of tbl.byKey.keys()) {
|
|
390
|
+
const a = tbl.byChannelKey.get(`${ch[i]}|${k}`);
|
|
391
|
+
const b = tbl.byChannelKey.get(`${ch[j]}|${k}`);
|
|
392
|
+
if (a && b)
|
|
393
|
+
shared += Math.min(a.length, b.length);
|
|
394
|
+
}
|
|
395
|
+
if (shared > max)
|
|
396
|
+
max = shared;
|
|
397
|
+
}
|
|
398
|
+
return max;
|
|
399
|
+
}
|
|
400
|
+
function assessViability(tbl, directed, authors, cfg = DEFAULT_VIABILITY) {
|
|
401
|
+
const channels = tbl.channels.length;
|
|
402
|
+
const maxShared = maxSharedEvents(tbl);
|
|
403
|
+
const strongEdges = directed.filter((e) => e.peakShare >= cfg.minPeakShare).length;
|
|
404
|
+
// структура графа: размеры кластеров
|
|
405
|
+
const sizeById = new Map();
|
|
406
|
+
for (const id of authors.values())
|
|
407
|
+
sizeById.set(id, (sizeById.get(id) ?? 0) + 1);
|
|
408
|
+
const multiChannelClusters = [...sizeById.values()].filter((s) => s > 1).length;
|
|
409
|
+
const clusterCount = sizeById.size;
|
|
410
|
+
// СТРОГИЙ критерий: все условия одновременно
|
|
411
|
+
const enoughChannels = channels >= 2;
|
|
412
|
+
const enoughOverlap = maxShared >= cfg.minSharedEvents;
|
|
413
|
+
const enoughEdges = strongEdges >= cfg.minStrongEdges;
|
|
414
|
+
// нетривиальность: либо найдены братья (кластер >1), либо ≥minStructure независимых кластеров
|
|
415
|
+
const nontrivial = multiChannelClusters >= 1 || clusterCount >= cfg.minStructure;
|
|
416
|
+
const viable = enoughChannels && enoughOverlap && enoughEdges && nontrivial;
|
|
417
|
+
let reason;
|
|
418
|
+
if (!enoughChannels)
|
|
419
|
+
reason = `один канал — корреляция невозможна`;
|
|
420
|
+
else if (!enoughOverlap)
|
|
421
|
+
reason = `мало общих событий (макс ${maxShared} < ${cfg.minSharedEvents}) — перекрытие шумовое`;
|
|
422
|
+
else if (!enoughEdges)
|
|
423
|
+
reason = `нет связей с острым пиком (${strongEdges} < ${cfg.minStrongEdges}) — корреляция случайна`;
|
|
424
|
+
else if (!nontrivial)
|
|
425
|
+
reason = `граф тривиален (кластеров >1: ${multiChannelClusters}, всего: ${clusterCount})`;
|
|
426
|
+
else
|
|
427
|
+
reason = `матрица жизнеспособна: ${strongEdges} острых связей, перекрытие ${maxShared}, кластеров >1: ${multiChannelClusters}`;
|
|
428
|
+
return {
|
|
429
|
+
viable, channels, maxSharedEvents: maxShared, strongEdges,
|
|
430
|
+
multiChannelClusters, clusterCount, reason,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Контракт источника свечей. Совместим с getCandles из backtest-kit.
|
|
436
|
+
* Тренировка идёт в прошлом (не realtime), поэтому look-ahead-ограничения сняты:
|
|
437
|
+
* свечи можно брать по обе стороны от события.
|
|
438
|
+
*/
|
|
439
|
+
/** Длительность одного шага интервала в мс. */
|
|
440
|
+
const STEP_MS = {
|
|
441
|
+
"1m": 60_000,
|
|
442
|
+
"3m": 3 * 60_000,
|
|
443
|
+
"5m": 5 * 60_000,
|
|
444
|
+
"15m": 15 * 60_000,
|
|
445
|
+
"30m": 30 * 60_000,
|
|
446
|
+
"1h": 60 * 60_000,
|
|
447
|
+
"2h": 2 * 60 * 60_000,
|
|
448
|
+
"4h": 4 * 60 * 60_000,
|
|
449
|
+
"6h": 6 * 60 * 60_000,
|
|
450
|
+
"8h": 8 * 60 * 60_000,
|
|
451
|
+
"1d": 24 * 60 * 60_000,
|
|
452
|
+
};
|
|
453
|
+
/** Выравнивание timestamp вниз к границе свечи интервала. */
|
|
454
|
+
const alignTs = (t, interval) => {
|
|
455
|
+
const step = STEP_MS[interval];
|
|
456
|
+
return Math.floor(t / step) * step;
|
|
457
|
+
};
|
|
458
|
+
/**
|
|
459
|
+
* Первая ПОЛНОСТЬЮ сформированная свеча, торгуемая БЕЗ look-ahead: если сигнал
|
|
460
|
+
* пришёл внутри минуты (ts > границы), свеча, СОДЕРЖАЩАЯ сигнал, ещё формируется —
|
|
461
|
+
* её close/high/low станут известны только в КОНЦЕ минуты, ПОСЛЕ сигнала. Входить
|
|
462
|
+
* в неё = заглядывать вперёд. Поэтому старт входа = следующая граница. Если сигнал
|
|
463
|
+
* ровно на границе (ts === aligned) — эта свеча открывается одновременно с сигналом
|
|
464
|
+
* и торгуема честно, не пропускаем.
|
|
465
|
+
*/
|
|
466
|
+
const entryStartTs = (t, interval) => {
|
|
467
|
+
const step = STEP_MS[interval];
|
|
468
|
+
const aligned = Math.floor(t / step) * step;
|
|
469
|
+
return aligned === t ? aligned : aligned + step;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Иерархический резолвер. Возвращает exit + уровень, с которого он разрешён,
|
|
474
|
+
* чтобы прод видел, обучен ли он персонально под (канал,символ,направление,режим)
|
|
475
|
+
* или это fallback.
|
|
476
|
+
*/
|
|
477
|
+
function resolveExit(tensor, mode, channel, symbol, direction, volRegime) {
|
|
478
|
+
const cell = tensor.cells[mode]?.[channel]?.[symbol]?.[direction]?.[volRegime];
|
|
479
|
+
if (cell)
|
|
480
|
+
return { exit: cell, source: "cell" };
|
|
481
|
+
const sd = tensor.bySymbolDir[mode]?.[symbol]?.[direction];
|
|
482
|
+
if (sd)
|
|
483
|
+
return { exit: sd, source: "symbol-dir" };
|
|
484
|
+
const modeLevel = tensor.byMode[mode];
|
|
485
|
+
if (modeLevel)
|
|
486
|
+
return { exit: modeLevel, source: "mode" };
|
|
487
|
+
return { exit: tensor.global, source: "global" };
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Резолв БЕЗ volRegime (свечей нет): пропускаем cell-уровень (требует режима),
|
|
491
|
+
* начинаем с symbol-dir → mode → global.
|
|
492
|
+
*/
|
|
493
|
+
function resolveExitNoRegime(tensor, mode, symbol, direction) {
|
|
494
|
+
const sd = tensor.bySymbolDir[mode]?.[symbol]?.[direction];
|
|
495
|
+
if (sd)
|
|
496
|
+
return { exit: sd, source: "symbol-dir" };
|
|
497
|
+
const modeLevel = tensor.byMode[mode];
|
|
498
|
+
if (modeLevel)
|
|
499
|
+
return { exit: modeLevel, source: "mode" };
|
|
500
|
+
return { exit: tensor.global, source: "global" };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Перечисляет ВСЕ всплески при заданных (windowK, jaccardThreshold, lagPeakThreshold),
|
|
505
|
+
* НЕ отсекая по minClusters — это делает grid дёшево поверх готового списка.
|
|
506
|
+
* Кластеризация зависит от jaccard/lag/windowK, поэтому пересчитывается на эти оси grid;
|
|
507
|
+
* а minClusters — пост-фильтр, его перебор бесплатный.
|
|
508
|
+
*/
|
|
509
|
+
function enumerateBursts(items, windowK, jaccardThreshold, lagPeakThreshold, maxBurstWindowMs, stationarityWindowMs = Infinity) {
|
|
510
|
+
const events = items;
|
|
511
|
+
const fullTbl = buildTable(events);
|
|
512
|
+
// ── ДВА РАЗНЫХ ОКНА (их легко перепутать) ──
|
|
513
|
+
// 1) stationarityWindowMs — окно ИСТОРИИ для построения author-матрицы: как далеко
|
|
514
|
+
// назад смотреть, чтобы понять КАКИЕ каналы — братский кластер. Infinity =
|
|
515
|
+
// вся история (кластеры стабильны). НЕ длительность пампа и НЕ время удержания.
|
|
516
|
+
// 2) burst window = min(windowK·τ, maxBurstWindowMs) (ниже) — окно СИНХРОННОСТИ
|
|
517
|
+
// самого пампа: события в этом окне на одном тикере = один всплеск. Ограничено
|
|
518
|
+
// maxBurstWindowMs (обычно 1ч), поэтому ПАМП ВСЕГДА КОРОТКИЙ — растянутые на
|
|
519
|
+
// часы/дни события НЕ собираются в один памп. Время удержания позиции — отдельно
|
|
520
|
+
// (staleMinutes в replay), тоже конечное.
|
|
521
|
+
const buildAuthorCtx = (anchorTs) => {
|
|
522
|
+
const tbl = Number.isFinite(stationarityWindowMs)
|
|
523
|
+
? buildWindowedTable(events, anchorTs, stationarityWindowMs)
|
|
524
|
+
: fullTbl;
|
|
525
|
+
const tau = selfTuneLag(tbl);
|
|
526
|
+
const window = Math.min(windowK * tau, maxBurstWindowMs); // окно СИНХРОННОСТИ пампа
|
|
527
|
+
const screened = jaccardScreen(tbl, window, jaccardThreshold);
|
|
528
|
+
const directed = lagXCorr(tbl, screened, lagPeakThreshold, window);
|
|
529
|
+
const clusterOf = clusterAuthors(tbl.channels, directed);
|
|
530
|
+
return { window, clusterOf };
|
|
531
|
+
};
|
|
532
|
+
// глобальный контекст для бесконечного окна (один раз)
|
|
533
|
+
const globalCtx = Number.isFinite(stationarityWindowMs) ? null : buildAuthorCtx(0);
|
|
534
|
+
const bursts = [];
|
|
535
|
+
for (const [k, evs] of fullTbl.byKey) {
|
|
536
|
+
const [symbol, direction] = splitKey(k);
|
|
537
|
+
// Раньше брался ОДИН best-per-symbol по всей истории — это ТЕРЯЛО разнесённые во
|
|
538
|
+
// времени всплески (второй памп на том же тикере молча отбрасывался вместе с его
|
|
539
|
+
// id). Теперь перечисляем все НЕПЕРЕСЕКАЮЩИЕСЯ по времени всплески: внутри каждого
|
|
540
|
+
// временно́го кластера берём лучший по clusters/confidence, но кластеры не сливаем.
|
|
541
|
+
let i = 0;
|
|
542
|
+
while (i < evs.length) {
|
|
543
|
+
// временно́й кластер: события, попадающие в окно синхронности от evs[i]
|
|
544
|
+
const ctxStart = globalCtx ?? buildAuthorCtx(evs[i].ts);
|
|
545
|
+
let j = i;
|
|
546
|
+
while (j + 1 < evs.length && evs[j + 1].ts - evs[i].ts <= ctxStart.window)
|
|
547
|
+
j++;
|
|
548
|
+
// лучший анкер внутри кластера [i..j]
|
|
549
|
+
let best = null;
|
|
550
|
+
for (let hi = i; hi <= j; hi++) {
|
|
551
|
+
const ctx = globalCtx ?? buildAuthorCtx(evs[hi].ts);
|
|
552
|
+
let lo = hi;
|
|
553
|
+
while (lo > i && evs[hi].ts - evs[lo - 1].ts <= ctx.window)
|
|
554
|
+
lo--;
|
|
555
|
+
const slice = evs.slice(lo, hi + 1);
|
|
556
|
+
const clusters = new Set(slice.map((e) => ctx.clusterOf.get(e.channel)));
|
|
557
|
+
const channels = new Set(slice.map((e) => e.channel));
|
|
558
|
+
const dedup = clusters.size / channels.size;
|
|
559
|
+
const fill = Math.min(slice.length / 4, 1);
|
|
560
|
+
const rawAnchorId = evs[hi].id;
|
|
561
|
+
const anchorId = typeof rawAnchorId === "string" ? rawAnchorId : (typeof rawAnchorId === "number" ? String(rawAnchorId) : undefined);
|
|
562
|
+
const cand = {
|
|
563
|
+
symbol, direction, ts: evs[hi].ts,
|
|
564
|
+
independentClusters: clusters.size,
|
|
565
|
+
totalChannels: channels.size,
|
|
566
|
+
confidence: +(dedup * fill).toFixed(6),
|
|
567
|
+
id: anchorId,
|
|
568
|
+
// ids ВСЕГО временно́го кластера [i..j], чтобы ни один parser-item не пропал
|
|
569
|
+
ids: evs.slice(i, j + 1).map((e) => {
|
|
570
|
+
const r = e.id;
|
|
571
|
+
return typeof r === "string" ? r : (typeof r === "number" ? String(r) : undefined);
|
|
572
|
+
}).filter((x) => x != null),
|
|
573
|
+
};
|
|
574
|
+
if (!best || cand.independentClusters > best.independentClusters ||
|
|
575
|
+
(cand.independentClusters === best.independentClusters && cand.confidence > best.confidence))
|
|
576
|
+
best = cand;
|
|
577
|
+
}
|
|
578
|
+
if (best && best.independentClusters >= 1)
|
|
579
|
+
bursts.push(best);
|
|
580
|
+
i = j + 1; // следующий непересекающийся кластер
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return bursts.sort((a, b) => a.ts - b.ts);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Перечисляет КАЖДЫЙ пост как кандидата (single-channel fallback), схлопывая
|
|
587
|
+
* близкие посты по одному (symbol,direction) в пределах окна в один вход.
|
|
588
|
+
* independentClusters=1 всегда — фильтра качества нет, исход решает exit.
|
|
589
|
+
*/
|
|
590
|
+
function enumeratePosts(items, windowK, maxBurstWindowMs) {
|
|
591
|
+
const tbl = buildTable(items);
|
|
592
|
+
const tau = selfTuneLag(tbl);
|
|
593
|
+
const window = Math.min(windowK * tau, maxBurstWindowMs);
|
|
594
|
+
const toId = (e) => {
|
|
595
|
+
const r = e.id;
|
|
596
|
+
return typeof r === "string" ? r : (typeof r === "number" ? String(r) : undefined);
|
|
597
|
+
};
|
|
598
|
+
const out = [];
|
|
599
|
+
for (const [k, evs] of tbl.byKey) {
|
|
600
|
+
const [symbol, direction] = splitKey(k);
|
|
601
|
+
let lastTs = -Infinity;
|
|
602
|
+
let current = null;
|
|
603
|
+
for (const e of evs) {
|
|
604
|
+
const id = toId(e);
|
|
605
|
+
if (e.ts - lastTs <= window) {
|
|
606
|
+
// пост схлопывается в текущий всплеск — но его id НЕ теряем: добавляем в ids,
|
|
607
|
+
// иначе исходный parser-item стал бы несопоставимым с результатом.
|
|
608
|
+
if (current && id != null)
|
|
609
|
+
current.ids.push(id);
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
lastTs = e.ts;
|
|
613
|
+
current = {
|
|
614
|
+
symbol, direction, ts: e.ts,
|
|
615
|
+
independentClusters: 1, totalChannels: 1, confidence: 0.5,
|
|
616
|
+
id,
|
|
617
|
+
ids: id != null ? [id] : [],
|
|
618
|
+
};
|
|
619
|
+
out.push(current);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return out.sort((a, b) => a.ts - b.ts);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Максимум свечей в одном чанке (как CC_MAX_CANDLES_PER_REQUEST в проде). */
|
|
626
|
+
const MAX_CANDLES_PER_CHUNK = 500;
|
|
627
|
+
/**
|
|
628
|
+
* Chunked-загрузчик свечей. Дублирует логику пагинации из prod-адаптера: если
|
|
629
|
+
* запрошено больше MAX_CANDLES_PER_CHUNK, бьёт на чанки, двигая since вперёд на
|
|
630
|
+
* chunkLimit·step, и склеивает с дедупликацией по timestamp.
|
|
631
|
+
*
|
|
632
|
+
* Зачем внутри либы: labelBurst под длинный импакт-горизонт (staleMinutes до 1440)
|
|
633
|
+
* просит staleMinutes·2+5 ≈ 2885 свечей. Если адаптер пагинацию НЕ делает сам и
|
|
634
|
+
* упирается в лимит биржи, либа должна разрулить это сама, а не зависеть от того,
|
|
635
|
+
* как реализован чужой getCandles.
|
|
636
|
+
*
|
|
637
|
+
* Семантика — forward от since (case sDate+limit): возвращает ровно столько свечей,
|
|
638
|
+
* сколько доступно, начиная с align(since). Если адаптер на каком-то чанке вернул
|
|
639
|
+
* пусто (край истории / дыра) — останавливаемся и отдаём, что собрали.
|
|
640
|
+
*/
|
|
641
|
+
async function fetchCandlesChunked(getCandles, symbol, interval, limit, since, chunkSize = MAX_CANDLES_PER_CHUNK) {
|
|
642
|
+
const step = STEP_MS[interval];
|
|
643
|
+
const start = alignTs(since, interval);
|
|
644
|
+
// короткий путь: укладывается в один чанк → прямой вызов
|
|
645
|
+
if (limit <= chunkSize) {
|
|
646
|
+
return getCandles(symbol, interval, limit, start);
|
|
647
|
+
}
|
|
648
|
+
const all = [];
|
|
649
|
+
let remaining = limit;
|
|
650
|
+
let currentSince = start;
|
|
651
|
+
while (remaining > 0) {
|
|
652
|
+
const chunkLimit = Math.min(remaining, chunkSize);
|
|
653
|
+
const chunk = await getCandles(symbol, interval, chunkLimit, currentSince);
|
|
654
|
+
if (!chunk || chunk.length === 0)
|
|
655
|
+
break; // край истории / дыра — отдаём собранное
|
|
656
|
+
all.push(...chunk);
|
|
657
|
+
remaining -= chunkLimit;
|
|
658
|
+
if (remaining > 0)
|
|
659
|
+
currentSince = currentSince + chunkLimit * step;
|
|
660
|
+
}
|
|
661
|
+
// дедуп по timestamp (на стыках чанков адаптер может вернуть пограничную свечу
|
|
662
|
+
// дважды). Оставляем ПЕРВОЕ вхождение: при forward-пагинации первая свеча с данным
|
|
663
|
+
// ts пришла из более раннего/авторитетного чанка. Last-write мог бы подменить её
|
|
664
|
+
// повторной/битой копией из следующего чанка.
|
|
665
|
+
const seen = new Map();
|
|
666
|
+
for (const c of all) {
|
|
667
|
+
if (!seen.has(c.timestamp))
|
|
668
|
+
seen.set(c.timestamp, c);
|
|
669
|
+
}
|
|
670
|
+
const unique = Array.from(seen.values()).sort((a, b) => a.timestamp - b.timestamp);
|
|
671
|
+
return unique;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* volZ: насколько объём входной свечи аномален против скользящего окна ДО входа.
|
|
676
|
+
* Высокий volZ = синхронный заход толпы в плечо (та самая «синяя свеча» из 1028592).
|
|
677
|
+
* baselineWindow — сколько свечей до входа берём за норму.
|
|
678
|
+
*/
|
|
679
|
+
function volumeZScore(candles, entryIdx, baselineWindow) {
|
|
680
|
+
const lo = Math.max(0, entryIdx - baselineWindow);
|
|
681
|
+
const base = candles.slice(lo, entryIdx);
|
|
682
|
+
if (base.length < 2)
|
|
683
|
+
return 0;
|
|
684
|
+
// битый/короткий массив: entryIdx может быть >= длины → candles[entryIdx] undefined.
|
|
685
|
+
const entry = candles[entryIdx];
|
|
686
|
+
if (!entry)
|
|
687
|
+
return 0;
|
|
688
|
+
const vols = base.map((c) => c.volume);
|
|
689
|
+
const mean = vols.reduce((s, v) => s + v, 0) / vols.length;
|
|
690
|
+
const variance = vols.reduce((s, v) => s + (v - mean) ** 2, 0) / (vols.length - 1);
|
|
691
|
+
const std = Math.sqrt(variance);
|
|
692
|
+
if (std === 0)
|
|
693
|
+
return 0;
|
|
694
|
+
return (entry.volume - mean) / std;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* squeezePressure: доля объёма в окне после входа, пришедшегося на свечи, где цена
|
|
698
|
+
* двигалась ПРОТИВ позиции. Симметрично: для long «против» = свеча закрылась ниже
|
|
699
|
+
* открытия (давление вниз, каскад sell); для short «против» = выше (каскад buy).
|
|
700
|
+
*
|
|
701
|
+
* Высокое значение → движение питается ликвидациями толпы, а не честным потоком →
|
|
702
|
+
* это ловушка (stop hunt / squeeze), входить опасно либо выходить раньше.
|
|
703
|
+
*/
|
|
704
|
+
function squeezePressure(candles, entryIdx, dir, horizon) {
|
|
705
|
+
const end = Math.min(candles.length, entryIdx + horizon + 1);
|
|
706
|
+
let againstVol = 0;
|
|
707
|
+
let totalVol = 0;
|
|
708
|
+
for (let i = entryIdx + 1; i < end; i++) {
|
|
709
|
+
const c = candles[i];
|
|
710
|
+
const delta = c.close - c.open; // знак внутрисвечного движения
|
|
711
|
+
// «против позиции»: long не любит падение (delta<0), short не любит рост (delta>0)
|
|
712
|
+
const against = dir === "long" ? delta < 0 : delta > 0;
|
|
713
|
+
totalVol += c.volume;
|
|
714
|
+
if (against)
|
|
715
|
+
againstVol += c.volume;
|
|
716
|
+
}
|
|
717
|
+
if (totalVol === 0)
|
|
718
|
+
return 0;
|
|
719
|
+
return againstVol / totalVol;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* LIVE-вариант squeezePressure: считает давление каскада по свечам СТРОГО ДО входа
|
|
723
|
+
* (никакого look-ahead). В live свечей ПОСЛЕ сигнала ещё нет — поэтому ловушку
|
|
724
|
+
* оцениваем по уже произошедшим свечам перед сигналом: высокая доля объёма на
|
|
725
|
+
* движениях против позиции в недавнем прошлом = рынок уже под давлением каскада.
|
|
726
|
+
*
|
|
727
|
+
* entryIdx — индекс входной свечи; окно [entryIdx-horizon, entryIdx) (НЕ включая
|
|
728
|
+
* саму входную, чтобы не зависеть от её формирования). Симметрия по dir та же.
|
|
729
|
+
*/
|
|
730
|
+
function squeezePressureBefore(candles, entryIdx, dir, horizon) {
|
|
731
|
+
const start = Math.max(0, entryIdx - horizon);
|
|
732
|
+
// КЛАМП верхней границы: при битом/коротком массиве свечей (флэки-адаптер биржи)
|
|
733
|
+
// entryIdx может оказаться > длины — без клампа цикл прочитает undefined и упадёт.
|
|
734
|
+
const end = Math.min(entryIdx, candles.length);
|
|
735
|
+
let againstVol = 0;
|
|
736
|
+
let totalVol = 0;
|
|
737
|
+
for (let i = start; i < end; i++) {
|
|
738
|
+
const c = candles[i];
|
|
739
|
+
const delta = c.close - c.open;
|
|
740
|
+
const against = dir === "long" ? delta < 0 : delta > 0;
|
|
741
|
+
totalVol += c.volume;
|
|
742
|
+
if (against)
|
|
743
|
+
againstVol += c.volume;
|
|
744
|
+
}
|
|
745
|
+
if (totalVol === 0)
|
|
746
|
+
return 0;
|
|
747
|
+
return againstVol / totalVol;
|
|
748
|
+
}
|
|
749
|
+
/** Считает оба признака разом для входа на entryIdx. */
|
|
750
|
+
function volumeFeatures(candles, entryIdx, dir, baselineWindow, horizon) {
|
|
751
|
+
return {
|
|
752
|
+
volZ: volumeZScore(candles, entryIdx, baselineWindow),
|
|
753
|
+
squeezePressure: squeezePressure(candles, entryIdx, dir, horizon),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
const volRegimeOf = (volZ, threshold) => volZ >= threshold ? "anomalous" : "calm";
|
|
757
|
+
|
|
758
|
+
const signed = (entry, price, dir) => dir === "long" ? (price - entry) / entry : (entry - price) / entry;
|
|
759
|
+
/** Обратная к signed: цена выхода по entry, реализованному pnl и направлению. */
|
|
760
|
+
const exitPriceOf = (entry, pnl, dir) => dir === "long" ? entry * (1 + pnl) : entry * (1 - pnl);
|
|
761
|
+
/**
|
|
762
|
+
* Прогоняет 1m-свечи через prod-выход. candles должны быть отсортированы по ts
|
|
763
|
+
* и покрывать окно от события вперёд (минимум до staleMinutes).
|
|
764
|
+
*
|
|
765
|
+
* entryFrom/entryTo — зона входа: вход на первой свече, чей хвост пересекает зону.
|
|
766
|
+
* entryPrice = close, если он попал в зону, иначе clamp midpoint к [low,high].
|
|
767
|
+
* Цена входа = кламп середины зоны в диапазон свечи (консервативно — фактическое касание).
|
|
768
|
+
*/
|
|
769
|
+
function replayExit(candles, dir, entryFrom, entryTo, p) {
|
|
770
|
+
const lo = Math.min(entryFrom, entryTo);
|
|
771
|
+
const hi = Math.max(entryFrom, entryTo);
|
|
772
|
+
// ── поиск входа: первая свеча, пересёкшая зону хвостом ──
|
|
773
|
+
let entryIdx = -1;
|
|
774
|
+
let entryPrice = 0;
|
|
775
|
+
for (let i = 0; i < candles.length; i++) {
|
|
776
|
+
const c = candles[i];
|
|
777
|
+
if (c.low <= hi && c.high >= lo) {
|
|
778
|
+
// зона задета хвостом. Уточняем цену входа: если close свечи попал В ЗОНУ,
|
|
779
|
+
// берём его (реальное закрытие в зоне — точнее фитиля). Иначе — точка зоны,
|
|
780
|
+
// ближайшая к диапазону свечи (clamp midpoint к [low,high], консервативно).
|
|
781
|
+
if (c.close >= lo && c.close <= hi) {
|
|
782
|
+
entryPrice = c.close;
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
const mid = (lo + hi) / 2;
|
|
786
|
+
entryPrice = Math.min(Math.max(mid, c.low), c.high);
|
|
787
|
+
}
|
|
788
|
+
entryIdx = i;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (entryIdx < 0 || !(entryPrice > 0)) {
|
|
793
|
+
return {
|
|
794
|
+
pnl: 0, reason: "no-entry", peak: 0, heldMinutes: 0, entered: false,
|
|
795
|
+
entryPrice: 0, exitPrice: 0,
|
|
796
|
+
volZ: 0, squeezePressure: 0, volRegime: "calm", inverted: false, truncated: false,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
// ── объёмные признаки на входе (симметрично для long/short) ──
|
|
800
|
+
const baseWin = p.volBaselineWindow ?? 20;
|
|
801
|
+
const volZThr = p.volZThreshold ?? 2.0;
|
|
802
|
+
// окно детекции каскада — СВОЁ, не life-cap. Сквиз быстрый: мерить его на всём
|
|
803
|
+
// горизонте удержания неверно (длинное окно размывает резкий разворот).
|
|
804
|
+
const sqHorizon = p.cascadeWindowMinutes ?? p.staleMinutes;
|
|
805
|
+
const volZ = volumeZScore(candles, entryIdx, baseWin);
|
|
806
|
+
const sqPressure = squeezePressure(candles, entryIdx, dir, sqHorizon);
|
|
807
|
+
const volRegime = volRegimeOf(volZ, volZThr);
|
|
808
|
+
// VETO: высокий squeezePressure при политике veto → не входим вовсе.
|
|
809
|
+
// Симметрично режет и long-каскад, и short-сквиз.
|
|
810
|
+
const sqThr = p.squeezeThreshold ?? 0.6;
|
|
811
|
+
if (p.squeezePolicy === "veto" && sqPressure >= sqThr) {
|
|
812
|
+
return {
|
|
813
|
+
pnl: 0, reason: "cascade-veto", peak: 0, heldMinutes: 0, entered: false,
|
|
814
|
+
entryPrice, exitPrice: 0,
|
|
815
|
+
volZ, squeezePressure: sqPressure, volRegime, inverted: false, truncated: false,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
// INVERT: каскад уверенно сносит толпу в обратную сторону (stop hunt из 1028592).
|
|
819
|
+
// Вместо защиты — заходим ПРОТИВ поста и снимаем сам сквиз. Метку ставит replay
|
|
820
|
+
// противоположного направления из той же точки; exit берётся из инверс-ячейки тензора.
|
|
821
|
+
if (p.squeezePolicy === "invert" && sqPressure >= sqThr) {
|
|
822
|
+
const opposite = dir === "long" ? "short" : "long";
|
|
823
|
+
// прогон без повторной инверсии (policy=none), чтобы не зациклиться
|
|
824
|
+
const inv = replayExit(candles, opposite, entryFrom, entryTo, {
|
|
825
|
+
...p, squeezePolicy: "none",
|
|
826
|
+
});
|
|
827
|
+
return {
|
|
828
|
+
...inv,
|
|
829
|
+
// reason СОХРАНЯЕТ настоящий механизм выхода инвертированной позиции
|
|
830
|
+
// (hard-stop/trailing-take/life-cap), а факт инверсии несёт флаг inverted.
|
|
831
|
+
// Раньше reason затирался на "invert", что скрывало, КАК закрылась инверсия.
|
|
832
|
+
inverted: inv.entered,
|
|
833
|
+
// объёмные признаки оставляем от исходного входа (они про сам каскад)
|
|
834
|
+
volZ, squeezePressure: sqPressure, volRegime,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
// TIGHTEN: при каскаде ужимаем trailing, чтобы выскочить до разворота.
|
|
838
|
+
const tighten = p.squeezePolicy === "tighten" && sqPressure >= sqThr
|
|
839
|
+
? (p.tightenFactor ?? 0.5) : 1;
|
|
840
|
+
const hardStopFrac = p.hardStop / 100;
|
|
841
|
+
const trailFrac = (p.trailingTake * tighten) / 100;
|
|
842
|
+
const stalenessProfitFrac = p.stalenessSinceProfit / 100;
|
|
843
|
+
let peak = 0; // пиковый PnL за жизнь (доли)
|
|
844
|
+
let peakMinute = 0; // минута достижения пика
|
|
845
|
+
const forwardAvail = candles.length - entryIdx - 1;
|
|
846
|
+
const lifeCap = Math.min(p.staleMinutes, forwardAvail);
|
|
847
|
+
// Боковик/край данных: если после входа осталось МЕНЬШЕ свечей, чем требует
|
|
848
|
+
// life-cap, замер горизонта неполный. Помечаем — labelBurst отбросит такую метку,
|
|
849
|
+
// чтобы не сравнивать 24ч-горизонт по обрезанному до пары часов пути.
|
|
850
|
+
// Допуск 5% — мелкая нехватка в конце не критична.
|
|
851
|
+
const truncated = forwardAvail < p.staleMinutes * 0.95;
|
|
852
|
+
for (let k = 0; k <= lifeCap; k++) {
|
|
853
|
+
const c = candles[entryIdx + k];
|
|
854
|
+
const minute = k; // 1m свечи → k минут от входа
|
|
855
|
+
// внутрисвечные экстремумы PnL: для long худшее = low, лучшее = high; для short наоборот
|
|
856
|
+
const pnlAtLow = signed(entryPrice, c.low, dir);
|
|
857
|
+
const pnlAtHigh = signed(entryPrice, c.high, dir);
|
|
858
|
+
const worst = Math.min(pnlAtLow, pnlAtHigh);
|
|
859
|
+
const best = Math.max(pnlAtLow, pnlAtHigh);
|
|
860
|
+
// 1) HARD STOP — внутрисвечной прокол против позиции на hardStop% от входа.
|
|
861
|
+
// Приоритет стопа над тейком в той же свече (консервативно, как в проде стоп жёсткий).
|
|
862
|
+
if (worst <= -hardStopFrac) {
|
|
863
|
+
// ЧЕСТНЫЙ реализованный PnL: стоп исполняется на уровне -hardStop%, это и есть
|
|
864
|
+
// результат сделки. Раньше возвращался lastPositivePeak (≥0), из-за чего стоп
|
|
865
|
+
// НИКОГДА не показывал убыток — это завышало pnl и отравляло RR/CV-объектив
|
|
866
|
+
// (оптимизатор не видел риск стопов). peak сохраняется отдельно для диагностики.
|
|
867
|
+
return {
|
|
868
|
+
pnl: -hardStopFrac,
|
|
869
|
+
reason: "hard-stop",
|
|
870
|
+
peak,
|
|
871
|
+
heldMinutes: minute,
|
|
872
|
+
entered: true,
|
|
873
|
+
entryPrice, exitPrice: exitPriceOf(entryPrice, -hardStopFrac, dir),
|
|
874
|
+
volZ, squeezePressure: sqPressure, volRegime, inverted: false, truncated,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
// обновляем пик по лучшему внутрисвечному PnL
|
|
878
|
+
if (best > peak) {
|
|
879
|
+
peak = best;
|
|
880
|
+
peakMinute = minute;
|
|
881
|
+
}
|
|
882
|
+
// 2) TRAILING TAKE — позиция в плюсе и откат от пика ≥ trailingTake%.
|
|
883
|
+
// Откат меряем по close свечи (как listenActivePing на закрытии свечи).
|
|
884
|
+
const closePnl = signed(entryPrice, c.close, dir);
|
|
885
|
+
if (closePnl >= 0 && peak - closePnl >= trailFrac && peak > 0) {
|
|
886
|
+
return {
|
|
887
|
+
pnl: peak, // фиксируем по достигнутому пику (последний плюсовой trailingTake)
|
|
888
|
+
reason: "trailing-take",
|
|
889
|
+
peak,
|
|
890
|
+
heldMinutes: minute,
|
|
891
|
+
entered: true,
|
|
892
|
+
entryPrice, exitPrice: exitPriceOf(entryPrice, peak, dir),
|
|
893
|
+
volZ, squeezePressure: sqPressure, volRegime, inverted: false, truncated,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
// 3) PEAK STALENESS — пик достиг порога прибыли и протух по времени.
|
|
897
|
+
if (peak >= stalenessProfitFrac && minute - peakMinute >= p.stalenessSinceMinutes) {
|
|
898
|
+
return {
|
|
899
|
+
pnl: peak,
|
|
900
|
+
reason: "peak-staleness",
|
|
901
|
+
peak,
|
|
902
|
+
heldMinutes: minute,
|
|
903
|
+
entered: true,
|
|
904
|
+
entryPrice, exitPrice: exitPriceOf(entryPrice, peak, dir),
|
|
905
|
+
volZ, squeezePressure: sqPressure, volRegime, inverted: false, truncated,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
// 4) LIFE CAP — потолок жизни позиции. Выход по close последней свечи окна.
|
|
910
|
+
// Честный реализованный PnL (может быть отрицательным, если позиция в минусе).
|
|
911
|
+
const lastIdx = entryIdx + lifeCap;
|
|
912
|
+
const finalPnl = signed(entryPrice, candles[lastIdx].close, dir);
|
|
913
|
+
return {
|
|
914
|
+
pnl: finalPnl,
|
|
915
|
+
reason: "life-cap",
|
|
916
|
+
peak,
|
|
917
|
+
heldMinutes: lifeCap,
|
|
918
|
+
entered: true,
|
|
919
|
+
entryPrice, exitPrice: exitPriceOf(entryPrice, finalPnl, dir),
|
|
920
|
+
volZ, squeezePressure: sqPressure, volRegime, inverted: false, truncated,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Стабильный строковый ключ exit-набора для кэша/grid. */
|
|
925
|
+
const exitKey = (p) => `tt${p.trailingTake}|hs${p.hardStop}|sp${p.stalenessSinceProfit}|sm${p.stalenessSinceMinutes}|life${p.staleMinutes}` +
|
|
926
|
+
`|vz${p.volZThreshold ?? "_"}|pol${p.squeezePolicy ?? "none"}|sqt${p.squeezeThreshold ?? "_"}|bw${p.volBaselineWindow ?? "_"}|cw${p.cascadeWindowMinutes ?? "_"}`;
|
|
927
|
+
/**
|
|
928
|
+
* Достаёт 1m-свечи от события вперёд на покрытие максимального life-cap и
|
|
929
|
+
* прогоняет каждый exit-набор через replay. Зона входа берётся из события;
|
|
930
|
+
* если не задана — точка entryFrom=entryTo=open первой свечи.
|
|
931
|
+
*/
|
|
932
|
+
async function labelBurst(getCandles, symbol, direction, ts, exitSets, entryFromPrice, entryToPrice) {
|
|
933
|
+
const maxLife = Math.max(...exitSets.map((e) => e.staleMinutes));
|
|
934
|
+
// старт = первая полностью сформированная свеча ПОСЛЕ сигнала (без look-ahead):
|
|
935
|
+
// свеча, содержащая сигнал, ещё формируется — её OHLC известны только в конце минуты.
|
|
936
|
+
const since = entryStartTs(ts, "1m");
|
|
937
|
+
const limit = maxLife * 2 + 5; // запас на поиск входа в зону
|
|
938
|
+
// getCandles может бросить (look-ahead guard на хвосте истории, дыры в данных
|
|
939
|
+
// символа — частое у меме-коинов с делистингом/паузами торгов, строгий count-match
|
|
940
|
+
// адаптера). Тогда этот кандидат НЕ размечается и пропускается — но обучение в целом
|
|
941
|
+
// не падает. Один битый символ не должен ронять весь fit.
|
|
942
|
+
let candles;
|
|
943
|
+
try {
|
|
944
|
+
candles = await fetchCandlesChunked(getCandles, symbol, "1m", limit, since);
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
if (!candles || candles.length === 0)
|
|
950
|
+
return null;
|
|
951
|
+
const from = entryFromPrice ?? candles[0].open;
|
|
952
|
+
const to = entryToPrice ?? candles[0].open;
|
|
953
|
+
const byExit = new Map();
|
|
954
|
+
for (const ex of exitSets) {
|
|
955
|
+
const r = replayExit(candles, direction, from, to, ex);
|
|
956
|
+
// отбрасываем метку с НЕПОЛНЫМ горизонтом: в боковике вход случился поздно,
|
|
957
|
+
// и после него не хватило свечей на полный life-cap. Иначе 24ч-горизонт
|
|
958
|
+
// сравнивался бы с 1ч-горизонтом по обрезанному до пары часов пути — это
|
|
959
|
+
// прямо корраптит impactHorizonMinutes (главный исследовательский выход).
|
|
960
|
+
// no-entry (entered=false без truncated) сохраняем — это валидная метка «не вошли».
|
|
961
|
+
if (r.truncated && r.entered)
|
|
962
|
+
continue;
|
|
963
|
+
byExit.set(exitKey(ex), r);
|
|
964
|
+
}
|
|
965
|
+
const anyEntered = [...byExit.values()].some((r) => r.entered);
|
|
966
|
+
if (byExit.size === 0 || !anyEntered)
|
|
967
|
+
return null;
|
|
968
|
+
return { symbol, direction, ts, byExit };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Objective для подбора порогов: shrinkage-expectancy.
|
|
973
|
+
*
|
|
974
|
+
* score = mean(returns) · N/(N+k)
|
|
975
|
+
*
|
|
976
|
+
* Средний forward-return отобранных всплесков, усаженный к нулю при малой выборке.
|
|
977
|
+
* Без усадки grid выбрал бы вырожденный порог, ловящий 1 жирный всплеск и
|
|
978
|
+
* рапортующий «идеальный эдж» — ровно ловушка winrate-68%-с-чёрным-лебедем.
|
|
979
|
+
* k — сила усадки (по умолчанию 5): при N=k вклад режется вдвое.
|
|
980
|
+
*/
|
|
981
|
+
function shrinkageExpectancy(returns, k = 5) {
|
|
982
|
+
const n = returns.length;
|
|
983
|
+
if (n === 0)
|
|
984
|
+
return 0;
|
|
985
|
+
const mean = returns.reduce((s, r) => s + r, 0) / n;
|
|
986
|
+
return mean * (n / (n + k));
|
|
987
|
+
}
|
|
988
|
+
/** Доля положительных (winrate) — для отчёта, не для оптимизации. */
|
|
989
|
+
function winrate(returns) {
|
|
990
|
+
if (returns.length === 0)
|
|
991
|
+
return 0;
|
|
992
|
+
return returns.filter((r) => r > 0).length / returns.length;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Стандартная ошибка среднего по фолдам: SE = std(foldScores) / sqrt(n).
|
|
996
|
+
* std — выборочное (делитель n-1). При n<2 SE=0 (разброс не оценить).
|
|
997
|
+
*/
|
|
998
|
+
function standardError(foldScores) {
|
|
999
|
+
const n = foldScores.length;
|
|
1000
|
+
if (n < 2)
|
|
1001
|
+
return 0;
|
|
1002
|
+
const mean = foldScores.reduce((s, x) => s + x, 0) / n;
|
|
1003
|
+
const variance = foldScores.reduce((s, x) => s + (x - mean) ** 2, 0) / (n - 1);
|
|
1004
|
+
return Math.sqrt(variance) / Math.sqrt(n);
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* One-standard-error rule (Breiman 1984) — против winner's curse при grid-search.
|
|
1008
|
+
*
|
|
1009
|
+
* Проблема: argmax по CV-score из N конфигураций систематически завышен — максимум
|
|
1010
|
+
* шумных оценок смещён вверх на ~sigma·sqrt(2·ln N) даже при истинном edge=0. Чем
|
|
1011
|
+
* больше grid, тем сильнее переобучение на шум выборки.
|
|
1012
|
+
*
|
|
1013
|
+
* Правило: берём НЕ максимум, а самую КОНСЕРВАТИВНУЮ конфигурацию среди тех, чей
|
|
1014
|
+
* score в пределах 1 SE от максимума. Разница внутри 1 SE статистически незначима
|
|
1015
|
+
* (внутри шума), поэтому вместо счастливого выброса выбираем робастную конфигурацию.
|
|
1016
|
+
*
|
|
1017
|
+
* @param entries кандидаты
|
|
1018
|
+
* @param scoreOf извлечь CV-score кандидата
|
|
1019
|
+
* @param foldsOf извлечь fold-scores кандидата (для SE максимума)
|
|
1020
|
+
* @param isSimpler компаратор «a консервативнее b» (true → предпочесть a)
|
|
1021
|
+
*/
|
|
1022
|
+
function oneStandardErrorSelect(entries, scoreOf, foldsOf, isSimpler, seMultiplier = 1) {
|
|
1023
|
+
if (entries.length === 0)
|
|
1024
|
+
return null;
|
|
1025
|
+
let best = entries[0];
|
|
1026
|
+
for (const e of entries)
|
|
1027
|
+
if (scoreOf(e) > scoreOf(best))
|
|
1028
|
+
best = e;
|
|
1029
|
+
// SE по фолдам ПОБЕДИТЕЛЯ — разброс его собственной оценки. seMultiplier
|
|
1030
|
+
// расширяет/сужает коридор (1 = классический Breiman).
|
|
1031
|
+
const se = standardError(foldsOf(best)) * seMultiplier;
|
|
1032
|
+
const threshold = scoreOf(best) - se;
|
|
1033
|
+
let chosen = best;
|
|
1034
|
+
for (const e of entries) {
|
|
1035
|
+
if (scoreOf(e) < threshold)
|
|
1036
|
+
continue; // вне коридора SE
|
|
1037
|
+
if (isSimpler(e, chosen))
|
|
1038
|
+
chosen = e; // консервативнее → берём
|
|
1039
|
+
}
|
|
1040
|
+
return chosen;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Перцентиль p (0..1) по выборке методом линейной интерполяции (type-7, как в numpy).
|
|
1044
|
+
* percentile([...], 0.95) = P95. Пустая выборка → 0.
|
|
1045
|
+
*/
|
|
1046
|
+
function percentile(xs, p) {
|
|
1047
|
+
// отбрасываем NaN/Infinity: одна битая свеча не должна молча отравить перцентиль
|
|
1048
|
+
const clean = xs.filter((x) => Number.isFinite(x));
|
|
1049
|
+
if (clean.length === 0)
|
|
1050
|
+
return 0;
|
|
1051
|
+
if (clean.length === 1)
|
|
1052
|
+
return clean[0];
|
|
1053
|
+
const sorted = [...clean].sort((a, b) => a - b);
|
|
1054
|
+
const idx = p * (sorted.length - 1);
|
|
1055
|
+
const lo = Math.floor(idx);
|
|
1056
|
+
const hi = Math.ceil(idx);
|
|
1057
|
+
if (lo === hi)
|
|
1058
|
+
return sorted[lo];
|
|
1059
|
+
const frac = idx - lo;
|
|
1060
|
+
return sorted[lo] * (1 - frac) + sorted[hi] * frac;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* RR на сделку = pnl / hardStop (реализованный в единицах риска — сколько R сняли).
|
|
1064
|
+
* Считает mean / P95 / P99 по парам (pnl, hardStop). Сделки с hardStop ≤ 0
|
|
1065
|
+
* пропускаются (деление на ноль). Главный исследовательский выход бэктеста.
|
|
1066
|
+
*/
|
|
1067
|
+
function riskRewardStats(trades) {
|
|
1068
|
+
const rr = [];
|
|
1069
|
+
for (const t of trades) {
|
|
1070
|
+
// только конечные значения: битый pnl/hardStop не должен отравить RR
|
|
1071
|
+
if (t.hardStop > 0 && Number.isFinite(t.pnl))
|
|
1072
|
+
rr.push(t.pnl / (t.hardStop / 100));
|
|
1073
|
+
}
|
|
1074
|
+
if (rr.length === 0)
|
|
1075
|
+
return { mean: 0, p95: 0, p99: 0, n: 0 };
|
|
1076
|
+
const mean = rr.reduce((s, x) => s + x, 0) / rr.length;
|
|
1077
|
+
return {
|
|
1078
|
+
mean: +mean.toFixed(6),
|
|
1079
|
+
p95: +percentile(rr, 0.95).toFixed(6),
|
|
1080
|
+
p99: +percentile(rr, 0.99).toFixed(6),
|
|
1081
|
+
n: rr.length,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
function pnlStats(pnls) {
|
|
1085
|
+
const clean = pnls.filter((x) => Number.isFinite(x));
|
|
1086
|
+
if (clean.length === 0)
|
|
1087
|
+
return { mean: 0, median: 0, p5: 0, p95: 0, p99: 0, n: 0 };
|
|
1088
|
+
const mean = clean.reduce((s, x) => s + x, 0) / clean.length;
|
|
1089
|
+
return {
|
|
1090
|
+
mean: +mean.toFixed(6),
|
|
1091
|
+
median: +percentile(clean, 0.5).toFixed(6),
|
|
1092
|
+
p5: +percentile(clean, 0.05).toFixed(6),
|
|
1093
|
+
p95: +percentile(clean, 0.95).toFixed(6),
|
|
1094
|
+
p99: +percentile(clean, 0.99).toFixed(6),
|
|
1095
|
+
n: clean.length,
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Достоверность обучения. Отвечает на вопрос «можно ли доверять подобранным
|
|
1101
|
+
* порогам», а НЕ «велик ли эдж». На малой выборке confidence низкий и
|
|
1102
|
+
* reliable=false (либа работает, но честно предупреждает); по мере роста
|
|
1103
|
+
* данных все три оси растут → confidence→1, reliable переключается сам.
|
|
1104
|
+
*
|
|
1105
|
+
* confidence = support × stability × significance (каждое в [0,1])
|
|
1106
|
+
*
|
|
1107
|
+
* Менять код при росте выборки не нужно — формула пересчитывает доверие.
|
|
1108
|
+
*/
|
|
1109
|
+
const DEFAULT_RELIABILITY = {
|
|
1110
|
+
supportK: 30,
|
|
1111
|
+
confidenceThreshold: 0.6,
|
|
1112
|
+
minN: 40,
|
|
1113
|
+
};
|
|
1114
|
+
function mean$1(a) {
|
|
1115
|
+
return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0;
|
|
1116
|
+
}
|
|
1117
|
+
function std(a) {
|
|
1118
|
+
if (a.length < 2)
|
|
1119
|
+
return 0;
|
|
1120
|
+
const m = mean$1(a);
|
|
1121
|
+
const v = a.reduce((s, x) => s + (x - m) ** 2, 0) / (a.length - 1);
|
|
1122
|
+
return Math.sqrt(v);
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* support: насыщающаяся функция объёма N/(N+k). Растёт от 0 к 1 с числом сделок.
|
|
1126
|
+
*/
|
|
1127
|
+
function supportScore(totalN, k) {
|
|
1128
|
+
return totalN / (totalN + k);
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* stability: эдж должен быть положителен в КАЖДОМ фолде, а не в одном жирном.
|
|
1132
|
+
* Берём долю фолдов с положительным средним × (1 − нормированный разброс знаков).
|
|
1133
|
+
* Один фолд → стабильность недоказуема → 0.5 (нейтрально, не штрафуем и не верим).
|
|
1134
|
+
*/
|
|
1135
|
+
function stabilityScore(foldMeans) {
|
|
1136
|
+
if (foldMeans.length === 0)
|
|
1137
|
+
return 0;
|
|
1138
|
+
if (foldMeans.length === 1)
|
|
1139
|
+
return 0.5;
|
|
1140
|
+
const posShare = foldMeans.filter((m) => m > 0).length / foldMeans.length;
|
|
1141
|
+
const m = mean$1(foldMeans);
|
|
1142
|
+
const s = std(foldMeans);
|
|
1143
|
+
// коэффициент вариации знака: малый разброс относительно среднего → стабильно
|
|
1144
|
+
const cv = m !== 0 ? Math.min(s / Math.abs(m), 1) : 1;
|
|
1145
|
+
return posShare * (1 - cv);
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* significance: на сколько стандартных ошибок среднее отстоит от нуля.
|
|
1149
|
+
* t = mean / (std/√N). Прогоняем через сглаживающую сигмоиду в [0,1]:
|
|
1150
|
+
* t≈0 → 0, t≈2 (≈95%) → ~0.76, t≥3 → ~0.9+.
|
|
1151
|
+
*/
|
|
1152
|
+
function significanceScore(returns) {
|
|
1153
|
+
const n = returns.length;
|
|
1154
|
+
if (n < 2)
|
|
1155
|
+
return 0;
|
|
1156
|
+
const m = mean$1(returns);
|
|
1157
|
+
const s = std(returns);
|
|
1158
|
+
if (m <= 0)
|
|
1159
|
+
return 0; // неположительный эдж — нулевая значимость «полезности»
|
|
1160
|
+
// НУЛЕВАЯ или околонулевая дисперсия (std пренебрежимо мал относительно |mean|):
|
|
1161
|
+
// все ретёрны фактически идентичны. Это НЕ бесконечная значимость, а вырожденные
|
|
1162
|
+
// данные (один исход N раз = артефакт). Порог относительный — std([0.001]×N) даёт
|
|
1163
|
+
// не ровно 0, а ~1e-19 из-за floating point, что без проверки даёт t≈1e16 → sig≈1.
|
|
1164
|
+
if (s <= Math.abs(m) * 1e-9) {
|
|
1165
|
+
return 1 - Math.exp(-n / 200); // N=40→0.18, N=140→0.5, N=600→0.95
|
|
1166
|
+
}
|
|
1167
|
+
const t = (m / (s / Math.sqrt(n)));
|
|
1168
|
+
if (t <= 0)
|
|
1169
|
+
return 0;
|
|
1170
|
+
return 1 - Math.exp(-t / 2); // насыщающаяся: t=2→0.63, t=4→0.86, t=6→0.95
|
|
1171
|
+
}
|
|
1172
|
+
function computeReliability(input, cfg = DEFAULT_RELIABILITY) {
|
|
1173
|
+
const totalN = input.foldSizes.reduce((s, x) => s + x, 0);
|
|
1174
|
+
const support = supportScore(totalN, cfg.supportK);
|
|
1175
|
+
const stability = stabilityScore(input.foldMeans);
|
|
1176
|
+
const significance = significanceScore(input.allReturns);
|
|
1177
|
+
const confidence = +(support * stability * significance).toFixed(6);
|
|
1178
|
+
const reliable = confidence >= cfg.confidenceThreshold && totalN >= cfg.minN;
|
|
1179
|
+
return {
|
|
1180
|
+
confidence,
|
|
1181
|
+
reliable,
|
|
1182
|
+
support: +support.toFixed(6),
|
|
1183
|
+
stability: +stability.toFixed(6),
|
|
1184
|
+
significance: +significance.toFixed(6),
|
|
1185
|
+
totalN,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Прогрессбар обучения. Train делает вложенные циклы: фаза разметки (медленная,
|
|
1191
|
+
* каждый кандидат = await getCandles по 1m-свечам) и фаза grid-скоринга (быстрая,
|
|
1192
|
+
* чистый CPU по кэшу). Бар отражает РЕАЛЬНУЮ работу — тики разметки, где идёт IO.
|
|
1193
|
+
*
|
|
1194
|
+
* Передаётся в train как опция onProgress; по умолчанию пишет в stdout в стиле,
|
|
1195
|
+
* заданном пользователем. В тестах подменяется на no-op или сборщик, чтобы не
|
|
1196
|
+
* засорять вывод.
|
|
1197
|
+
*/
|
|
1198
|
+
const BAR_LENGTH = 30;
|
|
1199
|
+
const BAR_FILLED_CHAR = "\u2588";
|
|
1200
|
+
const BAR_EMPTY_CHAR = "\u2591";
|
|
1201
|
+
/** Дефолтный stdout-бар в стиле пользователя. */
|
|
1202
|
+
const stdoutProgress = (e) => {
|
|
1203
|
+
if (e.total <= 0)
|
|
1204
|
+
return;
|
|
1205
|
+
const ratio = Math.min(e.done / e.total, 1);
|
|
1206
|
+
const percent = Math.round(ratio * 100);
|
|
1207
|
+
const filled = Math.round(ratio * BAR_LENGTH);
|
|
1208
|
+
const empty = BAR_LENGTH - filled;
|
|
1209
|
+
const bar = BAR_FILLED_CHAR.repeat(filled) + BAR_EMPTY_CHAR.repeat(empty);
|
|
1210
|
+
process.stdout.write(`\r[${bar}] ${percent}% (${e.done}/${e.total}) ${e.phase} ${e.label}`);
|
|
1211
|
+
if (e.done >= e.total)
|
|
1212
|
+
process.stdout.write("\n");
|
|
1213
|
+
};
|
|
1214
|
+
/** No-op для тестов/тихого режима. */
|
|
1215
|
+
const silentProgress = () => { };
|
|
1216
|
+
|
|
1217
|
+
const DEFAULT_POLICY = {
|
|
1218
|
+
allow: ["enter", "invert", "tighten"],
|
|
1219
|
+
};
|
|
1220
|
+
/**
|
|
1221
|
+
* Пересечение политик: эффективный allow = trained ∩ requested.
|
|
1222
|
+
* Реализует readonly-инвариант — запрос не может разрешить то, чего нет в обученной.
|
|
1223
|
+
* RR-фильтр (minRiskReward/rrMetric) — чисто рантаймовый: запрос может его ужесточить,
|
|
1224
|
+
* обученная политика дефолта не несёт (RR-статистика отдельно в params.riskReward).
|
|
1225
|
+
*/
|
|
1226
|
+
function intersectPolicy(trained, requested) {
|
|
1227
|
+
const allow = !requested?.allow
|
|
1228
|
+
? [...trained.allow]
|
|
1229
|
+
: [...new Set(requested.allow.filter((a) => new Set(trained.allow).has(a)))]; // ∩ + дедуп
|
|
1230
|
+
// minRiskReward: запрос может только УЖЕСТОЧИТЬ (поднять порог), не ослабить.
|
|
1231
|
+
// Берём максимум из обученного и запрошенного — иначе рантайм-запрос мог бы
|
|
1232
|
+
// снизить вшитый защитный порог риска, что нарушает инвариант «только сужение».
|
|
1233
|
+
let minRiskReward;
|
|
1234
|
+
if (trained.minRiskReward !== undefined && requested?.minRiskReward !== undefined) {
|
|
1235
|
+
minRiskReward = Math.max(trained.minRiskReward, requested.minRiskReward);
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
minRiskReward = requested?.minRiskReward ?? trained.minRiskReward;
|
|
1239
|
+
}
|
|
1240
|
+
return {
|
|
1241
|
+
allow,
|
|
1242
|
+
minRiskReward,
|
|
1243
|
+
rrMetric: requested?.rrMetric ?? trained.rrMetric ?? "mean",
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Математический аппарат для отличия РЕАЛЬНОГО эджа от ВЫБРОСА/оверфита.
|
|
1249
|
+
*
|
|
1250
|
+
* Брутфорс-grid (argmax по CV из N конфигов) систематически выдаёт ложный эдж:
|
|
1251
|
+
* максимум N шумных оценок смещён вверх на ≈ σ·√(2·ln N) даже при истинном эдже 0.
|
|
1252
|
+
* Эти функции дают СТАТИСТИЧЕСКИЙ СЕРТИФИКАТ, а не «score повыше».
|
|
1253
|
+
*
|
|
1254
|
+
* Ссылки: López de Prado (Deflated Sharpe 2014, PBO 2015, minTRL),
|
|
1255
|
+
* White (Reality Check 2000), Hansen (SPA 2005), Politis-Romano (stationary
|
|
1256
|
+
* bootstrap 1994), Breiman (1-SE 1984).
|
|
1257
|
+
*
|
|
1258
|
+
* Все функции — чистые над массивами ретёрнов сделок. Без внешних зависимостей.
|
|
1259
|
+
*/
|
|
1260
|
+
// ── базовая статистика моментов ──
|
|
1261
|
+
function mean(a) {
|
|
1262
|
+
return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0;
|
|
1263
|
+
}
|
|
1264
|
+
function variance(a) {
|
|
1265
|
+
if (a.length < 2)
|
|
1266
|
+
return 0;
|
|
1267
|
+
if (!a.every(Number.isFinite))
|
|
1268
|
+
return NaN; // NaN/Inf → честный NaN, не мусор
|
|
1269
|
+
// Welford (online): численно устойчивее наивной суммы квадратов при mean >> spread
|
|
1270
|
+
// (catastrophic cancellation). Один проход, без хранения (x-m) больших величин.
|
|
1271
|
+
let m = 0, m2 = 0, n = 0;
|
|
1272
|
+
for (const x of a) {
|
|
1273
|
+
n++;
|
|
1274
|
+
const d = x - m;
|
|
1275
|
+
m += d / n;
|
|
1276
|
+
m2 += d * (x - m);
|
|
1277
|
+
}
|
|
1278
|
+
return m2 / (a.length - 1);
|
|
1279
|
+
}
|
|
1280
|
+
function stdev(a) {
|
|
1281
|
+
return Math.sqrt(variance(a));
|
|
1282
|
+
}
|
|
1283
|
+
/** Выборочный коэффициент асимметрии (Fisher-Pearson). */
|
|
1284
|
+
function skewness(a) {
|
|
1285
|
+
const n = a.length;
|
|
1286
|
+
if (n < 3 || !a.every(Number.isFinite))
|
|
1287
|
+
return 0;
|
|
1288
|
+
const m = mean(a);
|
|
1289
|
+
const s = stdev(a);
|
|
1290
|
+
if (s === 0)
|
|
1291
|
+
return 0;
|
|
1292
|
+
const m3 = a.reduce((acc, x) => acc + ((x - m) / s) ** 3, 0) / n;
|
|
1293
|
+
return m3;
|
|
1294
|
+
}
|
|
1295
|
+
/** Выборочный куртозис (НЕ excess: нормаль = 3). */
|
|
1296
|
+
function kurtosis(a) {
|
|
1297
|
+
const n = a.length;
|
|
1298
|
+
if (n < 4 || !a.every(Number.isFinite))
|
|
1299
|
+
return 3;
|
|
1300
|
+
const m = mean(a);
|
|
1301
|
+
const s = stdev(a);
|
|
1302
|
+
if (s === 0)
|
|
1303
|
+
return 3;
|
|
1304
|
+
return a.reduce((acc, x) => acc + ((x - m) / s) ** 4, 0) / n;
|
|
1305
|
+
}
|
|
1306
|
+
/** Sharpe ratio по ряду ретёрнов (без аннуализации; per-trade). */
|
|
1307
|
+
function sharpe(returns) {
|
|
1308
|
+
if (returns.length === 0 || !returns.every(Number.isFinite))
|
|
1309
|
+
return 0; // NaN/Inf → 0, не распространяем
|
|
1310
|
+
const s = stdev(returns);
|
|
1311
|
+
const m = mean(returns);
|
|
1312
|
+
// DUST-порог: std — пыль, ТОЛЬКО если на уровне floating-point шума САМИХ значений
|
|
1313
|
+
// (масштаб данных × machine epsilon), а НЕ относительно mean. Прошлый порог
|
|
1314
|
+
// |mean|·1e-9 ошибочно убивал ВЫСОКИЙ Sharpe (малый std при большом mean — это и
|
|
1315
|
+
// есть высокий Sharpe, не пыль). Масштаб = max|x|.
|
|
1316
|
+
const scale = Math.max(...returns.map((x) => Math.abs(x)), Math.abs(m));
|
|
1317
|
+
const dustFloor = scale * 1e-13; // ~500× machine epsilon (2.2e-16) от масштаба данных
|
|
1318
|
+
if (s <= dustFloor)
|
|
1319
|
+
return 0;
|
|
1320
|
+
return m / s;
|
|
1321
|
+
}
|
|
1322
|
+
// ── нормальное распределение ──
|
|
1323
|
+
/** CDF стандартной нормали через erf-приближение Abramowitz-Stegun 7.1.26. */
|
|
1324
|
+
function normalCdf(z) {
|
|
1325
|
+
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
|
1326
|
+
const d = 0.3989422804014327 * Math.exp(-z * z / 2);
|
|
1327
|
+
const p = d * t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
|
|
1328
|
+
return z >= 0 ? 1 - p : p;
|
|
1329
|
+
}
|
|
1330
|
+
/** Обратная нормаль (quantile) — Acklam 2003. Точность ~1e-9 в [1e-15, 1-1e-15]. */
|
|
1331
|
+
function normalInv(p) {
|
|
1332
|
+
if (p <= 0)
|
|
1333
|
+
return -Infinity;
|
|
1334
|
+
if (p >= 1)
|
|
1335
|
+
return Infinity;
|
|
1336
|
+
const a = [-39.69683028665376, 2.209460984245205e2, -275.9285104469687, 1.383577518672690e2, -30.66479806614716, 2.506628277459239];
|
|
1337
|
+
const b = [-54.47609879822406, 1.615858368580409e2, -155.6989798598866, 6.680131188771972e1, -13.28068155288572];
|
|
1338
|
+
const c = [-0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783];
|
|
1339
|
+
const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, 3.754408661907416];
|
|
1340
|
+
const pl = 0.02425, ph = 1 - pl;
|
|
1341
|
+
let q, r;
|
|
1342
|
+
if (p < pl) {
|
|
1343
|
+
q = Math.sqrt(-2 * Math.log(p));
|
|
1344
|
+
return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
1345
|
+
}
|
|
1346
|
+
else if (p <= ph) {
|
|
1347
|
+
q = p - 0.5;
|
|
1348
|
+
r = q * q;
|
|
1349
|
+
return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
|
|
1350
|
+
}
|
|
1351
|
+
else {
|
|
1352
|
+
q = Math.sqrt(-2 * Math.log(1 - p));
|
|
1353
|
+
return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
const EULER_MASCHERONI = 0.5772156649015329;
|
|
1357
|
+
/**
|
|
1358
|
+
* Ожидаемый МАКСИМАЛЬНЫЙ Sharpe при истинном эдже 0, если перебрано N независимых
|
|
1359
|
+
* конфигураций с дисперсией SR-оценок varSR. Это «планка случайности»: насколько
|
|
1360
|
+
* высокий Sharpe выскочит из чистого шума просто потому, что мы выбрали лучший из N.
|
|
1361
|
+
*
|
|
1362
|
+
* E[max] ≈ √varSR · [(1−γ)·Z(1−1/N) + γ·Z(1−1/(N·e))] (López de Prado 2014)
|
|
1363
|
+
*/
|
|
1364
|
+
function expectedMaxSharpe(varSR, nTrials) {
|
|
1365
|
+
if (nTrials < 1)
|
|
1366
|
+
return 0;
|
|
1367
|
+
const sd = Math.sqrt(Math.max(varSR, 0));
|
|
1368
|
+
if (nTrials === 1)
|
|
1369
|
+
return 0;
|
|
1370
|
+
const z1 = normalInv(1 - 1 / nTrials);
|
|
1371
|
+
const z2 = normalInv(1 - 1 / (nTrials * Math.E));
|
|
1372
|
+
return sd * ((1 - EULER_MASCHERONI) * z1 + EULER_MASCHERONI * z2);
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Deflated Sharpe Ratio: вероятность, что ИСТИННЫЙ Sharpe > порога случайности,
|
|
1376
|
+
* с поправкой на (а) число испытаний N, (б) асимметрию/куртозис ряда, (в) длину T.
|
|
1377
|
+
*
|
|
1378
|
+
* DSR = Φ( (SR − SR0)·√(T−1) / √(1 − skew·SR + (kurt−1)/4·SR²) )
|
|
1379
|
+
*
|
|
1380
|
+
* SR — наблюдаемый Sharpe лучшей стратегии; SR0 — expectedMaxSharpe(varSR, N).
|
|
1381
|
+
* Возвращает p ∈ [0,1]. p ≥ 0.95 → эдж РЕАЛЕН с учётом перебора. На малой выборке
|
|
1382
|
+
* или огромном N → p ≈ 0 (честный отказ вместо ложного «reliable»).
|
|
1383
|
+
*/
|
|
1384
|
+
function deflatedSharpe(returns, nTrials, varSRAcrossTrials) {
|
|
1385
|
+
const T = returns.length;
|
|
1386
|
+
if (T < 2)
|
|
1387
|
+
return 0;
|
|
1388
|
+
const sr = sharpe(returns);
|
|
1389
|
+
const sr0 = expectedMaxSharpe(varSRAcrossTrials, nTrials);
|
|
1390
|
+
const sk = skewness(returns);
|
|
1391
|
+
const ku = kurtosis(returns);
|
|
1392
|
+
const denom = Math.sqrt(Math.max(1 - sk * sr + ((ku - 1) / 4) * sr * sr, 1e-12));
|
|
1393
|
+
const z = ((sr - sr0) * Math.sqrt(T - 1)) / denom;
|
|
1394
|
+
const result = normalCdf(z);
|
|
1395
|
+
return Number.isFinite(result) ? result : 0; // не-finite → 0 (fail-closed, не ложный высокий DSR)
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Минимальная длина ряда (число сделок), при которой наблюдаемый Sharpe значим на
|
|
1399
|
+
* уровне α (по умолчанию 0.05). Если фактическое N < minTRL — выборки физически НЕ
|
|
1400
|
+
* хватает, любой вывод преждевременен. Это «сколько сделок до доверия».
|
|
1401
|
+
*
|
|
1402
|
+
* minTRL = 1 + [1 − skew·SR + (kurt−1)/4·SR²]·(Z_α / SR)² (López de Prado)
|
|
1403
|
+
*/
|
|
1404
|
+
function minTrackRecordLength(returns, alpha = 0.05) {
|
|
1405
|
+
const sr = sharpe(returns);
|
|
1406
|
+
// SR ≤ 0: стратегия не прибыльна → значимость положительного эджа недостижима
|
|
1407
|
+
// НИКОГДА. Возвращаем Infinity, а не маленькое число (формула (z/SR)² теряет знак
|
|
1408
|
+
// при возведении в квадрат и дала бы абсурдно малый minTRL для убыточной стратегии).
|
|
1409
|
+
if (sr <= 0)
|
|
1410
|
+
return Infinity;
|
|
1411
|
+
const sk = skewness(returns);
|
|
1412
|
+
const ku = kurtosis(returns);
|
|
1413
|
+
const z = normalInv(1 - alpha);
|
|
1414
|
+
return 1 + (1 - sk * sr + ((ku - 1) / 4) * sr * sr) * (z / sr) ** 2;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Probability of Backtest Overfitting через Combinatorially-Symmetric CV (CSCV).
|
|
1418
|
+
*
|
|
1419
|
+
* Матрица M[config][fold] (perf каждого конфига на каждом фолде). Делим S фолдов
|
|
1420
|
+
* на все C(S, S/2) комбинаций IS/OOS. На каждой: выбираем лучший конфиг по IS,
|
|
1421
|
+
* смотрим его РАНГ на OOS. Если IS-лучший систематически плох на OOS — это оверфит.
|
|
1422
|
+
*
|
|
1423
|
+
* PBO = доля разбиений, где IS-лучший попал в нижнюю половину OOS (logit < 0).
|
|
1424
|
+
* PBO → 0.5 = чистый оверфит; PBO → 0 = эдж переносится OOS.
|
|
1425
|
+
*
|
|
1426
|
+
* @param perf perf[c][f] — метрика конфига c на фолде f (больше = лучше)
|
|
1427
|
+
*/
|
|
1428
|
+
function probabilityOfBacktestOverfitting(perf) {
|
|
1429
|
+
const nConfigs = perf.length;
|
|
1430
|
+
if (nConfigs === 0)
|
|
1431
|
+
return NaN; // нечего оценивать → НЕ выдаём ложный 0.5
|
|
1432
|
+
const S = perf[0].length;
|
|
1433
|
+
if (S < 2 || S % 2 !== 0) {
|
|
1434
|
+
// CSCV требует чётное число фолдов ≥ 2. Возвращаем NaN (не 0.5!), иначе
|
|
1435
|
+
// реальный эдж с нечётным числом фолдов читался бы как «оверфит». Вызывающий
|
|
1436
|
+
// обязан проверить Number.isNaN и не пускать модель, а не получить ложный сигнал.
|
|
1437
|
+
return NaN;
|
|
1438
|
+
}
|
|
1439
|
+
const half = S / 2;
|
|
1440
|
+
const folds = Array.from({ length: S }, (_, i) => i);
|
|
1441
|
+
const combos = chooseCombinations(folds, half);
|
|
1442
|
+
let overfit = 0;
|
|
1443
|
+
let total = 0;
|
|
1444
|
+
for (const isSet of combos) {
|
|
1445
|
+
const isIn = new Set(isSet);
|
|
1446
|
+
const oosSet = folds.filter((f) => !isIn.has(f));
|
|
1447
|
+
const isPerf = perf.map((row) => mean(isSet.map((f) => row[f])));
|
|
1448
|
+
const oosPerf = perf.map((row) => mean(oosSet.map((f) => row[f])));
|
|
1449
|
+
let bestC = 0;
|
|
1450
|
+
for (let c = 1; c < nConfigs; c++)
|
|
1451
|
+
if (isPerf[c] > isPerf[bestC])
|
|
1452
|
+
bestC = c;
|
|
1453
|
+
const oosVal = oosPerf[bestC];
|
|
1454
|
+
// MIDRANK для корректной обработки ничьих: строго меньшие + половина равных
|
|
1455
|
+
// (минус сам конфиг). Без этого все-равные значения занижают ранг → ложный оверфит.
|
|
1456
|
+
const less = oosPerf.filter((v) => v < oosVal).length;
|
|
1457
|
+
const eq = oosPerf.filter((v) => v === oosVal).length;
|
|
1458
|
+
const rank = less + (eq - 1) / 2;
|
|
1459
|
+
const omega = (rank + 0.5) / nConfigs;
|
|
1460
|
+
const logit = Math.log(omega / (1 - omega + 1e-12));
|
|
1461
|
+
// СТРОГО < 0: ровно медиана (omega=0.5, нет ни эджа ни оверфита) не считается оверфитом
|
|
1462
|
+
if (logit < 0)
|
|
1463
|
+
overfit++;
|
|
1464
|
+
total++;
|
|
1465
|
+
}
|
|
1466
|
+
return total ? overfit / total : NaN;
|
|
1467
|
+
}
|
|
1468
|
+
/** Все сочетания по k из массива (для CSCV; k=S/2, S обычно ≤ 12). */
|
|
1469
|
+
function chooseCombinations(arr, k) {
|
|
1470
|
+
const res = [];
|
|
1471
|
+
const combo = [];
|
|
1472
|
+
const rec = (start) => {
|
|
1473
|
+
if (combo.length === k) {
|
|
1474
|
+
res.push([...combo]);
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
for (let i = start; i < arr.length; i++) {
|
|
1478
|
+
combo.push(arr[i]);
|
|
1479
|
+
rec(i + 1);
|
|
1480
|
+
combo.pop();
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
rec(0);
|
|
1484
|
+
return res;
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Stationary bootstrap (Politis-Romano 1994): ресэмпл ряда блоками случайной
|
|
1488
|
+
* геометрической длины (средняя 1/p), сохраняя автокорреляцию. Для зависимых рядов
|
|
1489
|
+
* сделок обычный i.i.d. бутстрэп даёт оптимистичный результат — блочность чинит это.
|
|
1490
|
+
*/
|
|
1491
|
+
function stationaryBootstrapResample(returns, pBlock, rng) {
|
|
1492
|
+
const n = returns.length;
|
|
1493
|
+
if (n === 0)
|
|
1494
|
+
return [];
|
|
1495
|
+
const out = [];
|
|
1496
|
+
let idx = Math.floor(rng() * n);
|
|
1497
|
+
for (let i = 0; i < n; i++) {
|
|
1498
|
+
out.push(returns[idx]);
|
|
1499
|
+
if (rng() < pBlock)
|
|
1500
|
+
idx = Math.floor(rng() * n); // новый блок
|
|
1501
|
+
else
|
|
1502
|
+
idx = (idx + 1) % n; // продолжаем блок
|
|
1503
|
+
}
|
|
1504
|
+
return out;
|
|
1505
|
+
}
|
|
1506
|
+
/** Детерминированный ГПСЧ (mulberry32) — воспроизводимые бутстрэп-прогоны в тестах. */
|
|
1507
|
+
function mulberry32(seed) {
|
|
1508
|
+
let a = seed >>> 0;
|
|
1509
|
+
return () => {
|
|
1510
|
+
a |= 0;
|
|
1511
|
+
a = (a + 0x6D2B79F5) | 0;
|
|
1512
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
1513
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
1514
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* White's Reality Check / Hansen SPA через stationary bootstrap.
|
|
1519
|
+
* H0: лучшая из N стратегий НЕ лучше бенчмарка 0 (весь эдж — data-snooping).
|
|
1520
|
+
*
|
|
1521
|
+
* Статистика V = max_k √T · mean(returns_k). Бутстрэпим центрированные ряды,
|
|
1522
|
+
* считаем распределение макс-статистики при H0, p-value = доля бутстрэп-V,
|
|
1523
|
+
* превысивших наблюдаемый V. p ≤ 0.05 → отвергаем H0 (эдж не объясним перебором).
|
|
1524
|
+
*
|
|
1525
|
+
* @param strategiesReturns массив рядов (по одному на конфиг-кандидат)
|
|
1526
|
+
*/
|
|
1527
|
+
function realityCheckPValue(strategiesReturns, opts = {}) {
|
|
1528
|
+
const B = opts.bootstraps ?? 1000;
|
|
1529
|
+
const pBlock = opts.pBlock ?? 0.1; // средняя длина блока 10
|
|
1530
|
+
const rng = mulberry32(opts.seed ?? 12345);
|
|
1531
|
+
const K = strategiesReturns.length;
|
|
1532
|
+
if (K === 0)
|
|
1533
|
+
return 1;
|
|
1534
|
+
// наблюдаемая макс-статистика
|
|
1535
|
+
const stat = (rs, baseMean) => Math.sqrt(rs.length) * (mean(rs) - baseMean);
|
|
1536
|
+
let observedV = -Infinity;
|
|
1537
|
+
for (const rs of strategiesReturns)
|
|
1538
|
+
observedV = Math.max(observedV, stat(rs, 0));
|
|
1539
|
+
// бутстрэп под H0: центрируем каждую стратегию на её среднем
|
|
1540
|
+
let exceed = 0;
|
|
1541
|
+
for (let b = 0; b < B; b++) {
|
|
1542
|
+
let vb = -Infinity;
|
|
1543
|
+
for (const rs of strategiesReturns) {
|
|
1544
|
+
const m = mean(rs);
|
|
1545
|
+
const resampled = stationaryBootstrapResample(rs, pBlock, rng);
|
|
1546
|
+
// центрированная статистика: √T·(mean(resample) − mean(original))
|
|
1547
|
+
vb = Math.max(vb, stat(resampled, m));
|
|
1548
|
+
}
|
|
1549
|
+
if (vb >= observedV)
|
|
1550
|
+
exceed++;
|
|
1551
|
+
}
|
|
1552
|
+
return (exceed + 1) / (B + 1); // +1 — несмещённая бутстрэп p-value (Davison-Hinkley)
|
|
1553
|
+
}
|
|
1554
|
+
function certifyStrategy(inp, thresholds = {}) {
|
|
1555
|
+
const dsrThr = thresholds.dsr ?? 0.95;
|
|
1556
|
+
const pboThr = thresholds.pbo ?? 0.10;
|
|
1557
|
+
const spaThr = thresholds.spa ?? 0.05;
|
|
1558
|
+
const dsr = deflatedSharpe(inp.selectedReturns, inp.nTrials, inp.varSRAcrossTrials);
|
|
1559
|
+
const pbo = probabilityOfBacktestOverfitting(inp.perfMatrix);
|
|
1560
|
+
const spaPValue = realityCheckPValue(inp.candidateReturns);
|
|
1561
|
+
const minTRL = minTrackRecordLength(inp.selectedReturns);
|
|
1562
|
+
const actualN = inp.selectedReturns.length;
|
|
1563
|
+
const reasons = [];
|
|
1564
|
+
if (dsr < dsrThr)
|
|
1565
|
+
reasons.push(`DSR ${dsr.toFixed(3)} < ${dsrThr} — эдж не переживает поправку на ${inp.nTrials} испытаний`);
|
|
1566
|
+
if (Number.isNaN(pbo))
|
|
1567
|
+
reasons.push(`PBO не оценить (нужно чётное число фолдов ≥ 2 и ≥1 конфиг) — нельзя сертифицировать вслепую`);
|
|
1568
|
+
else if (pbo > pboThr)
|
|
1569
|
+
reasons.push(`PBO ${pbo.toFixed(3)} > ${pboThr} — конфиг оверфитнут (CSCV)`);
|
|
1570
|
+
if (spaPValue > spaThr)
|
|
1571
|
+
reasons.push(`SPA p-value ${spaPValue.toFixed(3)} > ${spaThr} — эдж объясним data-snooping`);
|
|
1572
|
+
if (actualN < minTRL)
|
|
1573
|
+
reasons.push(`N=${actualN} < minTRL=${minTRL.toFixed(0)} — выборки недостаточно`);
|
|
1574
|
+
if (inp.nestedScore !== null && inp.nestedScore <= 0)
|
|
1575
|
+
reasons.push(`nested OOS-score ${inp.nestedScore.toFixed(4)} ≤ 0 — несмещённый прогноз не положителен`);
|
|
1576
|
+
return {
|
|
1577
|
+
certified: reasons.length === 0,
|
|
1578
|
+
dsr, pbo, spaPValue, minTRL, actualN,
|
|
1579
|
+
nestedScore: inp.nestedScore, reasons,
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Мета-учёт переобучений — против МЕТА-winner's-curse.
|
|
1585
|
+
*
|
|
1586
|
+
* Проблема (Tripolsky): DSR штрафует N конфигов ВНУТРИ одного fit, но если гонять
|
|
1587
|
+
* fit 720 раз за месяц (ежечасно) и торговать только когда выпал certified=true —
|
|
1588
|
+
* это повторный перебор УЖЕ ПО ВРЕМЕНИ. Каждый «сертифицированный» прогон может быть
|
|
1589
|
+
* тем самым выбросом среди 720 попыток. Сертификат на отдельном fit слеп к цепочке.
|
|
1590
|
+
*
|
|
1591
|
+
* Лечение (двухчастное):
|
|
1592
|
+
* 1) CADENCE GUARD: запрет частого переобучения. fit разрешён не чаще minRefitMs
|
|
1593
|
+
* (дни/недели, не часы). Частые refit = размножение испытаний.
|
|
1594
|
+
* 2) FAMILY-WISE коррекция: эффективное число испытаний = N_внутри × число_fit_попыток.
|
|
1595
|
+
* ВСЕ попытки логируются (не только certified), иначе знаменатель занижен и
|
|
1596
|
+
* поправка лжёт. DSR на эффективном N нейтрализует мета-curse (доказано тестом:
|
|
1597
|
+
* 720 fit на шуме → наивно 2 ложных, мета 0).
|
|
1598
|
+
*/
|
|
1599
|
+
const DEFAULT_META_POLICY = {
|
|
1600
|
+
minRefitMs: 7 * 24 * 3600_000, // неделя
|
|
1601
|
+
};
|
|
1602
|
+
/** Пустой реестр. */
|
|
1603
|
+
function emptyLedger() {
|
|
1604
|
+
return { attempts: [] };
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Разрешён ли новый fit сейчас по cadence-политике. Возвращает {allowed, reason,
|
|
1608
|
+
* nextAllowedTs}. Частое переобучение размножает испытания → запрещаем.
|
|
1609
|
+
*/
|
|
1610
|
+
function canRefit(ledger, now, policy = DEFAULT_META_POLICY) {
|
|
1611
|
+
if (ledger.attempts.length === 0) {
|
|
1612
|
+
return { allowed: true, reason: "первый fit", nextAllowedTs: now };
|
|
1613
|
+
}
|
|
1614
|
+
const last = ledger.attempts[ledger.attempts.length - 1].ts;
|
|
1615
|
+
const nextAllowedTs = last + policy.minRefitMs;
|
|
1616
|
+
if (now >= nextAllowedTs) {
|
|
1617
|
+
return { allowed: true, reason: "интервал выдержан", nextAllowedTs };
|
|
1618
|
+
}
|
|
1619
|
+
const hoursLeft = (nextAllowedTs - now) / 3600_000;
|
|
1620
|
+
return {
|
|
1621
|
+
allowed: false,
|
|
1622
|
+
reason: `слишком частый refit: до следующего разрешённого ${hoursLeft.toFixed(1)}ч. ` +
|
|
1623
|
+
`Частое переобучение размножает испытания (мета-winner's-curse).`,
|
|
1624
|
+
nextAllowedTs,
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
/** Регистрирует попытку fit (ЛЮБУЮ — и certified, и нет). Возвращает новый реестр. */
|
|
1628
|
+
function recordAttempt(ledger, attempt) {
|
|
1629
|
+
return { attempts: [...ledger.attempts, attempt] };
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Эффективное число испытаний для family-wise коррекции DSR: суммарно по ВСЕМ
|
|
1633
|
+
* fit-попыткам, не только текущей. Если за месяц было M fit-ов с N конфигов каждый —
|
|
1634
|
+
* эффективно перебрано до Σ Nᵢ гипотез. Это и есть честный знаменатель для DSR.
|
|
1635
|
+
*
|
|
1636
|
+
* Используется как nTrials в deflatedSharpe вместо одного board.length. Так
|
|
1637
|
+
* сертификат учитывает, что ты гонял fit многократно и выбираешь успешные.
|
|
1638
|
+
*/
|
|
1639
|
+
function effectiveTrials(ledger, currentInnerTrials) {
|
|
1640
|
+
const past = ledger.attempts.reduce((s, a) => s + a.innerTrials, 0);
|
|
1641
|
+
return Math.max(past + currentInnerTrials, currentInnerTrials, 1);
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Сколько РАЗ был запущен fit (длина цепочки попыток + текущая). Для отчёта и для
|
|
1645
|
+
* грубой Bonferroni-поправки порога значимости при желании.
|
|
1646
|
+
*/
|
|
1647
|
+
function fitAttemptCount(ledger) {
|
|
1648
|
+
return ledger.attempts.length;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
const DEFAULT_SELECTION = {
|
|
1652
|
+
seMultiplier: 1,
|
|
1653
|
+
nestedOuterFolds: 4,
|
|
1654
|
+
};
|
|
1655
|
+
/**
|
|
1656
|
+
* Порядок агрессии реакции на каскад: чем выше, тем агрессивнее вмешательство.
|
|
1657
|
+
* ignore (вход вопреки каскаду) ≈ none (просто вход) < tighten (ужать) <
|
|
1658
|
+
* veto (не входить) < invert (развернуться).
|
|
1659
|
+
* Используется как ось консервативности: при near-tie выбираем менее агрессивную.
|
|
1660
|
+
* ignore намеренно НЕ реагирует на каскад → наименее консервативная реакция (0).
|
|
1661
|
+
*/
|
|
1662
|
+
const CASCADE_AGGRESSION = {
|
|
1663
|
+
ignore: 0,
|
|
1664
|
+
none: 0,
|
|
1665
|
+
tighten: 1,
|
|
1666
|
+
veto: 2,
|
|
1667
|
+
invert: 3,
|
|
1668
|
+
};
|
|
1669
|
+
const cascadeAggressionOf = (policy) => CASCADE_AGGRESSION[policy ?? "none"] ?? CASCADE_AGGRESSION.none;
|
|
1670
|
+
/**
|
|
1671
|
+
* Ключ консервативности exit-конфигурации для one-standard-error tie-break.
|
|
1672
|
+
* Лексикографический порядок (меньше = консервативнее):
|
|
1673
|
+
* 1) hardStop — меньший риск на сделку
|
|
1674
|
+
* 2) staleMinutes — короче экспозиция
|
|
1675
|
+
* 3) cascade aggression— мягче вмешательство в каскад
|
|
1676
|
+
* 4) -cvScore — при полном равенстве выше score (детерминизм)
|
|
1677
|
+
*
|
|
1678
|
+
* `score` передаётся отдельно, т.к. ExitParams его не содержит.
|
|
1679
|
+
*/
|
|
1680
|
+
function conservatismKey(exit, cvScore) {
|
|
1681
|
+
return [
|
|
1682
|
+
exit.hardStop,
|
|
1683
|
+
exit.staleMinutes,
|
|
1684
|
+
cascadeAggressionOf(exit.squeezePolicy),
|
|
1685
|
+
-cvScore,
|
|
1686
|
+
];
|
|
1687
|
+
}
|
|
1688
|
+
/** Сравнение «a консервативнее b» по лексикографическому ключу (true → предпочесть a). */
|
|
1689
|
+
function isMoreConservative(a, b) {
|
|
1690
|
+
const ka = conservatismKey(a.exit, a.cvScore);
|
|
1691
|
+
const kb = conservatismKey(b.exit, b.cvScore);
|
|
1692
|
+
for (let i = 0; i < ka.length; i++) {
|
|
1693
|
+
if (ka[i] < kb[i])
|
|
1694
|
+
return true;
|
|
1695
|
+
if (ka[i] > kb[i])
|
|
1696
|
+
return false;
|
|
1697
|
+
}
|
|
1698
|
+
return false;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
const DEFAULT_GRID = {
|
|
1702
|
+
windowK: [2, 3, 5],
|
|
1703
|
+
minClusters: [2, 3],
|
|
1704
|
+
jaccardThreshold: [0.3, 0.4], // 0.2 почти никогда не выбирался — убран ради размера грида
|
|
1705
|
+
lagPeakThreshold: [0.4, 0.5], // 0.6 редко лучше — убран ради размера грида
|
|
1706
|
+
trailingTake: [0.5, 1.0, 2.0],
|
|
1707
|
+
hardStop: [1.0, 2.0, 3.0],
|
|
1708
|
+
stalenessSinceProfit: [0.5, 1.0, 2.0], // порог прибыли (%) для вооружения staleness-выхода
|
|
1709
|
+
stalenessSinceMinutes: [60, 120, 240], // минут застоя от пика до выхода (число staleness-минут)
|
|
1710
|
+
staleMinutes: [60, 240, 720], // 1ч / 4ч / 12ч (24ч редко оптимален для коротких пампов)
|
|
1711
|
+
volZThreshold: [1.5, 2.5], // когда считать объём аномальным (накопление топлива)
|
|
1712
|
+
squeezePolicy: ["none", "tighten", "veto", "invert"], // train выберет реакцию по CV
|
|
1713
|
+
squeezeThreshold: [0.55, 0.7], // доля объёма против позиции для срабатывания
|
|
1714
|
+
volBaselineWindow: [20],
|
|
1715
|
+
cascadeWindowMinutes: [15, 30, 60], // окно детекции каскада: 15м / 30м / 1ч (быстрое событие)
|
|
1716
|
+
// вся история + конечные окна (4 / 8 недель); train выберет по CV
|
|
1717
|
+
stationarityWindowMs: [7 * 24 * 3600_000, 14 * 24 * 3600_000, 28 * 24 * 3600_000, 56 * 24 * 3600_000],
|
|
1718
|
+
};
|
|
1719
|
+
// ─────────────────────────── time-series K-fold ──────────────────────────────
|
|
1720
|
+
function timeSeriesFolds(n, folds) {
|
|
1721
|
+
const out = [];
|
|
1722
|
+
const seg = Math.max(1, Math.floor(n / (folds + 1)));
|
|
1723
|
+
for (let f = 1; f <= folds; f++) {
|
|
1724
|
+
const valLo = f * seg;
|
|
1725
|
+
const valHi = f === folds ? n : (f + 1) * seg;
|
|
1726
|
+
if (valLo < valHi)
|
|
1727
|
+
out.push({ valLo, valHi });
|
|
1728
|
+
}
|
|
1729
|
+
return out;
|
|
1730
|
+
}
|
|
1731
|
+
// ──────────────────────────────── train ──────────────────────────────────────
|
|
1732
|
+
/**
|
|
1733
|
+
* Обучает пороги детектора И параметры prod-выхода на исторических данных.
|
|
1734
|
+
* Метку ставит симуляция твоего trailing/hard-stop по 1m-свечам (replay),
|
|
1735
|
+
* поэтому stop hunting размечается как убыток. Объектив — shrinkage-expectancy
|
|
1736
|
+
* под time-series K-fold. Эмпирически выбирает импакт-горизонт (staleMinutes).
|
|
1737
|
+
*/
|
|
1738
|
+
async function train(items, getCandles, opts = {}) {
|
|
1739
|
+
const grid = { ...DEFAULT_GRID, ...opts.grid };
|
|
1740
|
+
const folds = opts.folds ?? 4;
|
|
1741
|
+
const shrinkageK = opts.shrinkageK ?? 5;
|
|
1742
|
+
const selection = { ...DEFAULT_SELECTION, ...opts.selection };
|
|
1743
|
+
const maxBurstWindowMs = opts.maxBurstWindowMs ?? DEFAULT_CONFIG.maxBurstWindowMs;
|
|
1744
|
+
// разрешаем эффективный режим обучения — тем же строгим критерием, что и predict
|
|
1745
|
+
const reqMode = opts.mode ?? "auto";
|
|
1746
|
+
let effMode;
|
|
1747
|
+
let modeReason;
|
|
1748
|
+
if (reqMode === "matrix") {
|
|
1749
|
+
effMode = "matrix";
|
|
1750
|
+
modeReason = "matrix задан явно (opts.mode)";
|
|
1751
|
+
}
|
|
1752
|
+
else if (reqMode === "single") {
|
|
1753
|
+
effMode = "single";
|
|
1754
|
+
modeReason = "single задан явно (opts.mode)";
|
|
1755
|
+
}
|
|
1756
|
+
else {
|
|
1757
|
+
// auto: пробный прогон матрицы на средних порогах + оценка жизнеспособности
|
|
1758
|
+
const probeTbl = buildTable(items.map((i) => ({
|
|
1759
|
+
channel: i.channel, symbol: i.symbol, direction: i.direction, ts: i.ts,
|
|
1760
|
+
entryFromPrice: i.entryFromPrice, entryToPrice: i.entryToPrice,
|
|
1761
|
+
})));
|
|
1762
|
+
const probeTau = selfTuneLag(probeTbl);
|
|
1763
|
+
const probeWin = Math.min(3 * probeTau, maxBurstWindowMs);
|
|
1764
|
+
const probeScreened = jaccardScreen(probeTbl, probeWin, 0.3);
|
|
1765
|
+
const probeDirected = lagXCorr(probeTbl, probeScreened, 0.5, probeWin);
|
|
1766
|
+
const probeAuthors = clusterAuthors(probeTbl.channels, probeDirected);
|
|
1767
|
+
const v = assessViability(probeTbl, probeDirected, probeAuthors, {
|
|
1768
|
+
...DEFAULT_VIABILITY, ...opts.viability,
|
|
1769
|
+
});
|
|
1770
|
+
effMode = v.viable ? "matrix" : "single";
|
|
1771
|
+
// честная авто-диагностика: ПОЧЕМУ выбран режим (видно в meta.modeReason)
|
|
1772
|
+
modeReason = `auto → ${effMode}: ${v.reason}`;
|
|
1773
|
+
}
|
|
1774
|
+
// индекс зоны входа по (symbol|direction|ts) — убирает O(n²) find
|
|
1775
|
+
const entryIndex = new Map();
|
|
1776
|
+
for (const it of items)
|
|
1777
|
+
entryIndex.set(`${it.symbol}|${it.direction}|${it.ts}`, it);
|
|
1778
|
+
// полный список exit-наборов (декартово произведение exit+volume осей)
|
|
1779
|
+
const exitSets = [];
|
|
1780
|
+
for (const tt of grid.trailingTake)
|
|
1781
|
+
for (const hs of grid.hardStop)
|
|
1782
|
+
for (const sp of grid.stalenessSinceProfit)
|
|
1783
|
+
for (const sm of grid.stalenessSinceMinutes)
|
|
1784
|
+
for (const life of grid.staleMinutes)
|
|
1785
|
+
for (const vz of grid.volZThreshold)
|
|
1786
|
+
for (const pol of grid.squeezePolicy)
|
|
1787
|
+
for (const sqt of grid.squeezeThreshold)
|
|
1788
|
+
for (const bw of grid.volBaselineWindow)
|
|
1789
|
+
for (const cw of grid.cascadeWindowMinutes)
|
|
1790
|
+
exitSets.push({
|
|
1791
|
+
trailingTake: tt, hardStop: hs,
|
|
1792
|
+
stalenessSinceProfit: sp, stalenessSinceMinutes: sm, staleMinutes: life,
|
|
1793
|
+
volZThreshold: vz, squeezePolicy: pol,
|
|
1794
|
+
squeezeThreshold: sqt, volBaselineWindow: bw,
|
|
1795
|
+
cascadeWindowMinutes: cw,
|
|
1796
|
+
});
|
|
1797
|
+
const labeledCache = new Map();
|
|
1798
|
+
const seenCluster = new Set();
|
|
1799
|
+
const labelCandidates = async (cands, onTick) => {
|
|
1800
|
+
const labeled = [];
|
|
1801
|
+
for (const b of cands) {
|
|
1802
|
+
const src = entryIndex.get(`${b.symbol}|${b.direction}|${b.ts}`);
|
|
1803
|
+
const lb = await labelBurst(getCandles, b.symbol, b.direction, b.ts, exitSets, src?.entryFromPrice, src?.entryToPrice);
|
|
1804
|
+
onTick?.(b.symbol);
|
|
1805
|
+
if (!lb)
|
|
1806
|
+
continue;
|
|
1807
|
+
const byExit = new Map();
|
|
1808
|
+
// veto-вход (entered=false, reason=cascade-veto) тоже несёт сигнал: его pnl=0,
|
|
1809
|
+
// и он ДОЛЖЕН учитываться как «не вошли и не потеряли», иначе policy=veto нечестно
|
|
1810
|
+
// сравнивать с policy=none. Поэтому храним и не-entered, помечая флагом.
|
|
1811
|
+
for (const [k, r] of lb.byExit) {
|
|
1812
|
+
byExit.set(k, {
|
|
1813
|
+
pnl: r.pnl, volRegime: r.volRegime, entered: r.entered,
|
|
1814
|
+
entryPrice: r.entryPrice, exitPrice: r.exitPrice, reason: r.reason,
|
|
1815
|
+
heldMinutes: r.heldMinutes, peak: r.peak, inverted: r.inverted,
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
if (byExit.size === 0)
|
|
1819
|
+
continue;
|
|
1820
|
+
labeled.push({
|
|
1821
|
+
channel: src?.channel ?? "_unknown",
|
|
1822
|
+
symbol: b.symbol, direction: b.direction, ts: b.ts,
|
|
1823
|
+
independentClusters: b.independentClusters,
|
|
1824
|
+
id: b.id, ids: b.ids,
|
|
1825
|
+
byExit,
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
return labeled;
|
|
1829
|
+
};
|
|
1830
|
+
// ── фаза разметки с прогрессом ──
|
|
1831
|
+
// предварительно перечисляем кандидатов по каждому ключу кластеризации (дёшево,
|
|
1832
|
+
// pure CPU без IO), чтобы знать ГЛОБАЛЬНЫЙ total медленных labelBurst-вызовов.
|
|
1833
|
+
const progress = opts.onProgress ?? stdoutProgress;
|
|
1834
|
+
const passes = [];
|
|
1835
|
+
// окно стационарности влияет ТОЛЬКО на matrix (author-матрица); в single посты
|
|
1836
|
+
// не используют матрицу, поэтому там окно не перебираем (одно значение).
|
|
1837
|
+
const swAxis = effMode === "single" ? [Infinity] : grid.stationarityWindowMs;
|
|
1838
|
+
for (const wK of grid.windowK)
|
|
1839
|
+
for (const jac of grid.jaccardThreshold)
|
|
1840
|
+
for (const lag of grid.lagPeakThreshold)
|
|
1841
|
+
for (const sw of swAxis) {
|
|
1842
|
+
const ckey = `${wK}|${jac}|${lag}|${sw}`;
|
|
1843
|
+
if (seenCluster.has(ckey))
|
|
1844
|
+
continue;
|
|
1845
|
+
seenCluster.add(ckey);
|
|
1846
|
+
if (effMode === "single") {
|
|
1847
|
+
const skey = `single|${wK}`;
|
|
1848
|
+
if (labeledCache.has(`__enum_${skey}`)) {
|
|
1849
|
+
passes.push({ ckey, skey, cands: [] });
|
|
1850
|
+
continue;
|
|
1851
|
+
}
|
|
1852
|
+
labeledCache.set(`__enum_${skey}`, []); // маркер «уже перечислили»
|
|
1853
|
+
passes.push({ ckey, skey, cands: enumeratePosts(items, wK, maxBurstWindowMs) });
|
|
1854
|
+
}
|
|
1855
|
+
else {
|
|
1856
|
+
passes.push({ ckey, cands: enumerateBursts(items, wK, jac, lag, maxBurstWindowMs, sw) });
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
labeledCache.clear();
|
|
1860
|
+
seenCluster.clear();
|
|
1861
|
+
const totalTicks = passes.reduce((s, p) => s + p.cands.length, 0);
|
|
1862
|
+
let doneTicks = 0;
|
|
1863
|
+
const tick = (symbol) => {
|
|
1864
|
+
doneTicks++;
|
|
1865
|
+
progress({ done: doneTicks, total: totalTicks, phase: "label", label: symbol });
|
|
1866
|
+
};
|
|
1867
|
+
for (const p of passes) {
|
|
1868
|
+
let labeled;
|
|
1869
|
+
if (p.skey) {
|
|
1870
|
+
if (!labeledCache.has(p.skey)) {
|
|
1871
|
+
labeledCache.set(p.skey, await labelCandidates(p.cands, tick));
|
|
1872
|
+
}
|
|
1873
|
+
labeled = labeledCache.get(p.skey);
|
|
1874
|
+
}
|
|
1875
|
+
else {
|
|
1876
|
+
labeled = await labelCandidates(p.cands, tick);
|
|
1877
|
+
}
|
|
1878
|
+
labeledCache.set(p.ckey, labeled);
|
|
1879
|
+
}
|
|
1880
|
+
if (totalTicks > 0)
|
|
1881
|
+
progress({ done: totalTicks, total: totalTicks, phase: "label", label: "done" });
|
|
1882
|
+
// grid: детектор × exit, CV-score под K-fold
|
|
1883
|
+
// в single-режиме minClusters не применяется (всегда 1) — кандидаты уже все посты
|
|
1884
|
+
const minClusterAxis = effMode === "single" ? [1] : grid.minClusters;
|
|
1885
|
+
const board = [];
|
|
1886
|
+
// total для фазы score = число (wK×jac×lag×sw) комбинаций (тик на каждую)
|
|
1887
|
+
const scoreTotal = grid.windowK.length * grid.jaccardThreshold.length
|
|
1888
|
+
* grid.lagPeakThreshold.length * swAxis.length;
|
|
1889
|
+
// буримая функция скоринга: строит board по всем конфигам, учитывая только те
|
|
1890
|
+
// размеченные всплески, что проходят keep(ts). keep=()=>true → весь board.
|
|
1891
|
+
// onTick(label) вызывается после каждой (wK×jac×lag×sw)-комбинации (для прогресса).
|
|
1892
|
+
const buildBoard = (keep, onTick) => {
|
|
1893
|
+
const out = [];
|
|
1894
|
+
for (const wK of grid.windowK)
|
|
1895
|
+
for (const jac of grid.jaccardThreshold)
|
|
1896
|
+
for (const lag of grid.lagPeakThreshold)
|
|
1897
|
+
for (const sw of swAxis) {
|
|
1898
|
+
const labeled = (effMode === "single"
|
|
1899
|
+
? labeledCache.get(`single|${wK}`)
|
|
1900
|
+
: labeledCache.get(`${wK}|${jac}|${lag}|${sw}`)).filter((b) => keep(b.ts));
|
|
1901
|
+
for (const minC of minClusterAxis)
|
|
1902
|
+
for (const ex of exitSets) {
|
|
1903
|
+
const ekey = exitKey(ex);
|
|
1904
|
+
const selected = labeled
|
|
1905
|
+
.filter((b) => b.independentClusters >= minC && b.byExit.has(ekey))
|
|
1906
|
+
.sort((a, b) => a.ts - b.ts);
|
|
1907
|
+
const cfg = cfgOf(wK, minC, jac, lag, maxBurstWindowMs, effMode, sw);
|
|
1908
|
+
if (selected.length === 0) {
|
|
1909
|
+
out.push({ config: cfg, exit: ex, cvScore: 0, cvWinrate: 0, cvSupport: 0,
|
|
1910
|
+
_foldMeans: [], _foldSizes: [], _returns: [], _foldScores: [] });
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
const foldSpecs = timeSeriesFolds(selected.length, folds);
|
|
1914
|
+
const foldScores = [], foldMeans = [], foldWins = [], foldSupp = [], allRet = [];
|
|
1915
|
+
for (const { valLo, valHi } of foldSpecs) {
|
|
1916
|
+
const valRet = selected.slice(valLo, valHi).map((b) => b.byExit.get(ekey).pnl);
|
|
1917
|
+
foldScores.push(shrinkageExpectancy(valRet, shrinkageK));
|
|
1918
|
+
foldMeans.push(valRet.length ? valRet.reduce((s, x) => s + x, 0) / valRet.length : 0);
|
|
1919
|
+
foldWins.push(winrate(valRet));
|
|
1920
|
+
foldSupp.push(valRet.length);
|
|
1921
|
+
allRet.push(...valRet);
|
|
1922
|
+
}
|
|
1923
|
+
const avg = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0);
|
|
1924
|
+
out.push({
|
|
1925
|
+
config: cfg, exit: ex,
|
|
1926
|
+
cvScore: +avg(foldScores).toFixed(6),
|
|
1927
|
+
cvWinrate: +avg(foldWins).toFixed(6),
|
|
1928
|
+
cvSupport: +avg(foldSupp).toFixed(2),
|
|
1929
|
+
_foldMeans: foldMeans, _foldSizes: foldSupp, _returns: allRet, _foldScores: foldScores,
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
onTick?.(`${wK}|${jac}|${lag}|${sw === Infinity ? "all" : sw}`);
|
|
1933
|
+
}
|
|
1934
|
+
return out;
|
|
1935
|
+
};
|
|
1936
|
+
// основной board — по всем данным, с прогрессом фазы score
|
|
1937
|
+
let scoreDone = 0;
|
|
1938
|
+
board.push(...buildBoard(() => true, (label) => {
|
|
1939
|
+
scoreDone++;
|
|
1940
|
+
progress({ done: scoreDone, total: scoreTotal, phase: "score", label });
|
|
1941
|
+
}));
|
|
1942
|
+
// ── выбор победителя: one-standard-error rule (против winner's curse) ──
|
|
1943
|
+
// вместо argmax по cvScore берём самую КОНСЕРВАТИВНУЮ конфигурацию среди тех,
|
|
1944
|
+
// чей score в пределах SE от максимума. Это убирает переобучение на шум grid:
|
|
1945
|
+
// разница внутри SE статистически незначима, поэтому robustness > удача.
|
|
1946
|
+
// Порядок консервативности и пороги — в selection.ts, без магических литералов.
|
|
1947
|
+
const top = oneStandardErrorSelect(board, (e) => e.cvScore, (e) => e._foldScores, (a, b) => isMoreConservative(a, b), selection.seMultiplier);
|
|
1948
|
+
// board всё равно сортируем по score — для отчёта/аудита (gridSize, диагностика)
|
|
1949
|
+
board.sort((a, b) => b.cvScore - a.cvScore);
|
|
1950
|
+
// ── nested CV: несмещённая оценка прод-эджа (не меняет ВЫБОР, только оценку) ──
|
|
1951
|
+
// Внешние фолды по времени: на каждом train-срезе заново выбираем конфиг (1-SE),
|
|
1952
|
+
// оцениваем на held-out test-срезе. Среднее out-of-sample = честная оценка того,
|
|
1953
|
+
// что ждёт на проде, без winner's curse. ВЫБОР модели остаётся за полным 1-SE выше.
|
|
1954
|
+
// Прогресс тикает на КАЖДЫЙ внешний фолд → терминал не молчит дольше одного фолда.
|
|
1955
|
+
let nestedScore = null;
|
|
1956
|
+
if (selection.nestedOuterFolds >= 2) {
|
|
1957
|
+
// временные границы: все ts размеченных всплесков из основного кэша
|
|
1958
|
+
const allBurstTs = [];
|
|
1959
|
+
for (const [k, labeled] of labeledCache) {
|
|
1960
|
+
if (k.startsWith("__enum_"))
|
|
1961
|
+
continue;
|
|
1962
|
+
for (const b of labeled)
|
|
1963
|
+
allBurstTs.push(b.ts);
|
|
1964
|
+
}
|
|
1965
|
+
allBurstTs.sort((a, b) => a - b);
|
|
1966
|
+
const uniqTs = [...new Set(allBurstTs)];
|
|
1967
|
+
if (uniqTs.length >= selection.nestedOuterFolds) {
|
|
1968
|
+
const oosScores = [];
|
|
1969
|
+
const K = selection.nestedOuterFolds;
|
|
1970
|
+
const foldSize = Math.floor(uniqTs.length / K);
|
|
1971
|
+
for (let f = 0; f < K; f++) {
|
|
1972
|
+
// outer-test = f-й временной блок; outer-train = всё остальное
|
|
1973
|
+
const testLo = uniqTs[f * foldSize];
|
|
1974
|
+
const testHi = f === K - 1 ? Infinity : uniqTs[(f + 1) * foldSize];
|
|
1975
|
+
const inTest = (ts) => ts >= testLo && (testHi === Infinity ? true : ts < testHi);
|
|
1976
|
+
const inTrain = (ts) => !inTest(ts);
|
|
1977
|
+
// на train-срезе выбираем конфиг тем же 1-SE
|
|
1978
|
+
const trainBoard = buildBoard(inTrain);
|
|
1979
|
+
const trainTop = oneStandardErrorSelect(trainBoard, (e) => e.cvScore, (e) => e._foldScores, (a, b) => isMoreConservative(a, b), selection.seMultiplier);
|
|
1980
|
+
// оцениваем выбранный конфиг на held-out test-срезе
|
|
1981
|
+
if (trainTop) {
|
|
1982
|
+
const testBoard = buildBoard(inTest);
|
|
1983
|
+
const match = testBoard.find((e) => exitKey(e.exit) === exitKey(trainTop.exit) &&
|
|
1984
|
+
e.config.windowK === trainTop.config.windowK &&
|
|
1985
|
+
e.config.minClusters === trainTop.config.minClusters &&
|
|
1986
|
+
e.config.jaccardThreshold === trainTop.config.jaccardThreshold &&
|
|
1987
|
+
e.config.lagPeakThreshold === trainTop.config.lagPeakThreshold &&
|
|
1988
|
+
e.config.stationarityWindowMs === trainTop.config.stationarityWindowMs);
|
|
1989
|
+
if (match)
|
|
1990
|
+
oosScores.push(match.cvScore);
|
|
1991
|
+
}
|
|
1992
|
+
progress({ done: f + 1, total: K, phase: "nested", label: `fold ${f + 1}/${K}` });
|
|
1993
|
+
}
|
|
1994
|
+
nestedScore = oosScores.length
|
|
1995
|
+
? +(oosScores.reduce((s, x) => s + x, 0) / oosScores.length).toFixed(6)
|
|
1996
|
+
: null;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
const reliability = computeReliability({ foldMeans: top._foldMeans, foldSizes: top._foldSizes, allReturns: top._returns }, { ...DEFAULT_RELIABILITY, ...opts.reliability });
|
|
2000
|
+
// ── СЕРТИФИКАТ: математически доказуемый эдж, а не argmax по шуму ──
|
|
2001
|
+
// DSR (поправка на N испытаний) + PBO (CSCV-оверфит) + SPA (data-snooping) +
|
|
2002
|
+
// minTRL (достаточность выборки) + nested OOS. certified=true только если эдж
|
|
2003
|
+
// переживает ВСЕ барьеры. Это и отличает реальный эдж от выброса.
|
|
2004
|
+
const candPool = board
|
|
2005
|
+
.filter((e) => e._returns.length > 0)
|
|
2006
|
+
.slice(0, 50)
|
|
2007
|
+
.map((e) => e._returns);
|
|
2008
|
+
// perf-матрица для PBO: топ-конфиги × их fold-scores (нужно чётное число фолдов)
|
|
2009
|
+
const perfRows = board
|
|
2010
|
+
.filter((e) => e._foldScores.length >= 2)
|
|
2011
|
+
.slice(0, 30)
|
|
2012
|
+
.map((e) => e._foldScores.slice(0, e._foldScores.length - (e._foldScores.length % 2)));
|
|
2013
|
+
const evenFolds = perfRows.length && perfRows.every((r) => r.length === perfRows[0].length && r.length >= 2);
|
|
2014
|
+
// дисперсия Sharpe ПО испытаниям (планка случайности для DSR)
|
|
2015
|
+
const trialSharpes = board
|
|
2016
|
+
.filter((e) => e._returns.length >= 2)
|
|
2017
|
+
.map((e) => sharpe(e._returns));
|
|
2018
|
+
const varSR = variance(trialSharpes);
|
|
2019
|
+
// эффективное число испытаний: family-wise по ВСЕМ fit-попыткам (мета-curse),
|
|
2020
|
+
// а не только текущему гриду. Без metaLedger — наивный board.length (одиночный fit).
|
|
2021
|
+
const innerTrials = Math.max(board.length, 1);
|
|
2022
|
+
const nTrialsEff = opts.metaLedger
|
|
2023
|
+
? effectiveTrials(opts.metaLedger, innerTrials)
|
|
2024
|
+
: innerTrials;
|
|
2025
|
+
const certification = certifyStrategy({
|
|
2026
|
+
selectedReturns: top._returns,
|
|
2027
|
+
nTrials: nTrialsEff,
|
|
2028
|
+
varSRAcrossTrials: varSR,
|
|
2029
|
+
perfMatrix: evenFolds ? perfRows : [],
|
|
2030
|
+
candidateReturns: candPool.length ? candPool : [top._returns],
|
|
2031
|
+
nestedScore,
|
|
2032
|
+
});
|
|
2033
|
+
// ── exit tensor: лучший exit на каждую ячейку [channel][symbol][direction][volRegime] ──
|
|
2034
|
+
// detector-конфиг выбран глобально; exit считаем per-cell, НЕ смешивая математику
|
|
2035
|
+
// источников. Каскад ликвидаций симметричен: long-trap и short-trap — РАЗНЫЕ ячейки.
|
|
2036
|
+
const winLabeled = effMode === "single"
|
|
2037
|
+
? labeledCache.get(`single|${top.config.windowK}`)
|
|
2038
|
+
: labeledCache.get(`${top.config.windowK}|${top.config.jaccardThreshold}|${top.config.lagPeakThreshold}|${top.config.stationarityWindowMs}`);
|
|
2039
|
+
const winSelected = winLabeled
|
|
2040
|
+
.filter((b) => b.independentClusters >= top.config.minClusters)
|
|
2041
|
+
.sort((a, b) => a.ts - b.ts);
|
|
2042
|
+
// выбор лучшего exit по подвыборке + опц. фильтру volRegime.
|
|
2043
|
+
// Если regime задан — учитываем только результаты, чей volRegime под данным exit совпал.
|
|
2044
|
+
const pickExit = (subset, regime) => {
|
|
2045
|
+
if (subset.length === 0)
|
|
2046
|
+
return null;
|
|
2047
|
+
let best = null;
|
|
2048
|
+
for (const ex of exitSets) {
|
|
2049
|
+
const ekey = exitKey(ex);
|
|
2050
|
+
const rows = subset
|
|
2051
|
+
.map((b) => b.byExit.get(ekey))
|
|
2052
|
+
.filter((r) => !!r && (regime === undefined || r.volRegime === regime));
|
|
2053
|
+
if (rows.length === 0)
|
|
2054
|
+
continue;
|
|
2055
|
+
const foldSpecs = timeSeriesFolds(rows.length, folds);
|
|
2056
|
+
const scores = [];
|
|
2057
|
+
for (const { valLo, valHi } of foldSpecs) {
|
|
2058
|
+
scores.push(shrinkageExpectancy(rows.slice(valLo, valHi).map((r) => r.pnl), shrinkageK));
|
|
2059
|
+
}
|
|
2060
|
+
const avg = scores.length ? scores.reduce((s, x) => s + x, 0) / scores.length : 0;
|
|
2061
|
+
if (!best || avg > best.score)
|
|
2062
|
+
best = { ex, score: avg };
|
|
2063
|
+
}
|
|
2064
|
+
return best?.ex ?? null;
|
|
2065
|
+
};
|
|
2066
|
+
const globalExit = pickExit(winSelected) ?? top.exit;
|
|
2067
|
+
const modeExit = pickExit(winSelected) ?? globalExit;
|
|
2068
|
+
// bySymbolDir: схлопнут volRegime
|
|
2069
|
+
const bySymbolDir = {};
|
|
2070
|
+
const cells = {};
|
|
2071
|
+
// ключ канала для ячейки: в matrix-режиме всплеск МЕЖКАНАЛЬНЫЙ (нет одного
|
|
2072
|
+
// владельца), поэтому ячейки кладём под канонический "_matrix" — ровно тот ключ,
|
|
2073
|
+
// которым их потом ищет buildPlan для matrix-вердиктов (у них channel=null).
|
|
2074
|
+
// В single-режиме канал реальный — exit персонален каналу.
|
|
2075
|
+
const cellChannel = (realChannel) => effMode === "matrix" ? "_matrix" : realChannel;
|
|
2076
|
+
// группировка
|
|
2077
|
+
const group = new Map(); // key = channel|symbol|direction
|
|
2078
|
+
const groupSD = new Map(); // key = symbol|direction
|
|
2079
|
+
for (const b of winSelected) {
|
|
2080
|
+
const gk = `${cellChannel(b.channel)}\u0001${b.symbol}\u0001${b.direction}`;
|
|
2081
|
+
(group.get(gk) ?? group.set(gk, []).get(gk)).push(b);
|
|
2082
|
+
const sk = `${b.symbol}\u0001${b.direction}`;
|
|
2083
|
+
(groupSD.get(sk) ?? groupSD.set(sk, []).get(sk)).push(b);
|
|
2084
|
+
}
|
|
2085
|
+
// symbol-dir уровень (fallback при пустом volRegime)
|
|
2086
|
+
for (const [sk, subset] of groupSD) {
|
|
2087
|
+
const [symbol, direction] = sk.split("\u0001");
|
|
2088
|
+
const ex = pickExit(subset);
|
|
2089
|
+
if (ex)
|
|
2090
|
+
(bySymbolDir[symbol] ??= {})[direction] = ex;
|
|
2091
|
+
}
|
|
2092
|
+
// cell уровень: отдельный exit на каждый volRegime (calm/anomalous)
|
|
2093
|
+
for (const [gk, subset] of group) {
|
|
2094
|
+
const [channel, symbol, direction] = gk.split("\u0001");
|
|
2095
|
+
for (const regime of ["calm", "anomalous"]) {
|
|
2096
|
+
const ex = pickExit(subset, regime);
|
|
2097
|
+
if (!ex)
|
|
2098
|
+
continue;
|
|
2099
|
+
(((cells[channel] ??= {})[symbol] ??= {})[direction] ??= {})[regime] = ex;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
const emptyCh = {};
|
|
2103
|
+
const emptySD = {};
|
|
2104
|
+
const tensor = {
|
|
2105
|
+
cells: {
|
|
2106
|
+
matrix: effMode === "matrix" ? cells : emptyCh,
|
|
2107
|
+
single: effMode === "single" ? cells : emptyCh,
|
|
2108
|
+
},
|
|
2109
|
+
bySymbolDir: {
|
|
2110
|
+
matrix: effMode === "matrix" ? bySymbolDir : emptySD,
|
|
2111
|
+
single: effMode === "single" ? bySymbolDir : emptySD,
|
|
2112
|
+
},
|
|
2113
|
+
byMode: { matrix: modeExit, single: modeExit },
|
|
2114
|
+
global: globalExit,
|
|
2115
|
+
};
|
|
2116
|
+
// ── risk-reward: исследовательский выход бэктеста ──
|
|
2117
|
+
// RR = pnl / hardStop по сделкам с ВЫБРАННЫМ для символа exit. Считаем per-symbol
|
|
2118
|
+
// (для runtime-фильтра по символам) и global (для отчёта). Сделки берём из winSelected
|
|
2119
|
+
// под exit, реально назначенный символу (symbol-dir уровень), чтобы RR соответствовал
|
|
2120
|
+
// тому, что прод исполнит.
|
|
2121
|
+
const rrTradesBySymbol = new Map();
|
|
2122
|
+
const rrTradesGlobal = [];
|
|
2123
|
+
const pnlsBySymbol = new Map();
|
|
2124
|
+
const pnlsGlobal = [];
|
|
2125
|
+
// история сигналов выбранной конфигурации (для dump → внешней аналитики).
|
|
2126
|
+
// Берём exit, выбранный для каждого (symbol,direction) — то, что исполнит прод.
|
|
2127
|
+
const history = [];
|
|
2128
|
+
for (const [sk, subset] of groupSD) {
|
|
2129
|
+
const [symbol, direction] = sk.split("\u0001");
|
|
2130
|
+
const ex = bySymbolDir[symbol]?.[direction];
|
|
2131
|
+
if (!ex)
|
|
2132
|
+
continue;
|
|
2133
|
+
const ekey = exitKey(ex);
|
|
2134
|
+
for (const b of subset) {
|
|
2135
|
+
const r = b.byExit.get(ekey);
|
|
2136
|
+
if (!r)
|
|
2137
|
+
continue;
|
|
2138
|
+
// запись истории — для ВСЕХ сигналов (вошли/не вошли), чтобы аналитика
|
|
2139
|
+
// могла считать и пропуски (veto/no-entry), и реализованные сделки
|
|
2140
|
+
history.push({
|
|
2141
|
+
id: b.id, ids: b.ids,
|
|
2142
|
+
symbol, direction, channel: b.channel, ts: b.ts,
|
|
2143
|
+
entered: r.entered, entryPrice: r.entryPrice, exitPrice: r.exitPrice,
|
|
2144
|
+
pnl: r.pnl, peak: r.peak, reason: r.reason, heldMinutes: r.heldMinutes,
|
|
2145
|
+
inverted: r.inverted, volRegime: r.volRegime,
|
|
2146
|
+
independentClusters: b.independentClusters,
|
|
2147
|
+
});
|
|
2148
|
+
if (!r.entered)
|
|
2149
|
+
continue;
|
|
2150
|
+
const trade = { pnl: r.pnl, hardStop: ex.hardStop };
|
|
2151
|
+
(rrTradesBySymbol.get(symbol) ?? rrTradesBySymbol.set(symbol, []).get(symbol)).push(trade);
|
|
2152
|
+
rrTradesGlobal.push(trade);
|
|
2153
|
+
(pnlsBySymbol.get(symbol) ?? pnlsBySymbol.set(symbol, []).get(symbol)).push(r.pnl);
|
|
2154
|
+
pnlsGlobal.push(r.pnl);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
history.sort((a, b) => a.ts - b.ts);
|
|
2158
|
+
const riskRewardBySymbol = {};
|
|
2159
|
+
for (const [symbol, trades] of rrTradesBySymbol) {
|
|
2160
|
+
riskRewardBySymbol[symbol] = riskRewardStats(trades);
|
|
2161
|
+
}
|
|
2162
|
+
const riskRewardGlobal = riskRewardStats(rrTradesGlobal);
|
|
2163
|
+
// устойчивая к выбросам статистика PnL: median + перцентили, чтобы одна плохая
|
|
2164
|
+
// (или одна жирная) сделка не определяла оценку выигрыша системы.
|
|
2165
|
+
const pnlBySymbol = {};
|
|
2166
|
+
for (const [symbol, pnls] of pnlsBySymbol) {
|
|
2167
|
+
pnlBySymbol[symbol] = pnlStats(pnls);
|
|
2168
|
+
}
|
|
2169
|
+
const pnlGlobal = pnlStats(pnlsGlobal);
|
|
2170
|
+
const params = {
|
|
2171
|
+
version: 3,
|
|
2172
|
+
config: top.config,
|
|
2173
|
+
exit: tensor,
|
|
2174
|
+
policy: opts.policy ?? DEFAULT_POLICY,
|
|
2175
|
+
riskReward: { bySymbol: riskRewardBySymbol, global: riskRewardGlobal },
|
|
2176
|
+
pnl: { bySymbol: pnlBySymbol, global: pnlGlobal },
|
|
2177
|
+
history,
|
|
2178
|
+
meta: {
|
|
2179
|
+
trainedAt: Date.now(), folds, shrinkageK,
|
|
2180
|
+
cvScore: top.cvScore, nestedScore, cvWinrate: top.cvWinrate, cvSupport: top.cvSupport,
|
|
2181
|
+
gridSize: board.length,
|
|
2182
|
+
mode: effMode,
|
|
2183
|
+
modeReason,
|
|
2184
|
+
impactHorizonMinutes: globalExit.staleMinutes,
|
|
2185
|
+
confidence: reliability.confidence, reliable: reliability.reliable,
|
|
2186
|
+
support: reliability.support, stability: reliability.stability,
|
|
2187
|
+
significance: reliability.significance, totalSamples: reliability.totalN,
|
|
2188
|
+
certification,
|
|
2189
|
+
effectiveTrials: nTrialsEff,
|
|
2190
|
+
innerTrials,
|
|
2191
|
+
fitAttempts: opts.metaLedger ? fitAttemptCount(opts.metaLedger) + 1 : 1,
|
|
2192
|
+
},
|
|
2193
|
+
};
|
|
2194
|
+
const leaderboard = board.slice(0, 20).map(({ config, exit, cvScore, cvWinrate, cvSupport }) => ({ config, exit, cvScore, cvWinrate, cvSupport }));
|
|
2195
|
+
return { predict: loadPredict(params), params, reliability, leaderboard };
|
|
2196
|
+
}
|
|
2197
|
+
function cfgOf(windowK, minClusters, jaccardThreshold, lagPeakThreshold, maxBurstWindowMs, mode, stationarityWindowMs) {
|
|
2198
|
+
return { windowK, minClusters, jaccardThreshold, lagPeakThreshold, maxBurstWindowMs, mode, stationarityWindowMs };
|
|
2199
|
+
}
|
|
2200
|
+
// ─────────────────── десериализация: params → predict ─────────────────────────
|
|
2201
|
+
function loadPredict(params) {
|
|
2202
|
+
if (params.version !== 3)
|
|
2203
|
+
throw new Error(`unsupported params version: ${params.version}`);
|
|
2204
|
+
const cfg = params.config;
|
|
2205
|
+
return (items) => predict(items, cfg);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/**
|
|
2209
|
+
* Casual-фасад с ЕДИНЫМ стабильным контрактом ввода-вывода.
|
|
2210
|
+
*
|
|
2211
|
+
* const model = await PumpMatrix.fit(history, getCandles); // обучить
|
|
2212
|
+
* const json = model.save(); // сохранить (string)
|
|
2213
|
+
* const model = PumpMatrix.load(json); // в проде, без обучения
|
|
2214
|
+
*
|
|
2215
|
+
* for (const s of model.signals(liveItems)) // УЖЕ отфильтровано
|
|
2216
|
+
* openPosition(s.symbol, s.direction, s.exit); // прод не думает
|
|
2217
|
+
*
|
|
2218
|
+
* signals() возвращает ТОЛЬКО исполняемое: veto (каскад ликвидаций) не попадает в
|
|
2219
|
+
* выдачу вообще — фильтр внутри. Разрешённые исходы задаются вторым аргументом
|
|
2220
|
+
* (allow-список), но не шире, чем зашито в обученную модель (readonly-инвариант).
|
|
2221
|
+
*/
|
|
2222
|
+
class PumpMatrix {
|
|
2223
|
+
constructor(params, _predict) {
|
|
2224
|
+
this.params = params;
|
|
2225
|
+
this._predict = _predict;
|
|
2226
|
+
}
|
|
2227
|
+
/** Обучить модель на истории сигналов. */
|
|
2228
|
+
static async fit(history, getCandles, opts) {
|
|
2229
|
+
const res = await train(history, getCandles, opts);
|
|
2230
|
+
return new PumpMatrix(res.params, res.predict);
|
|
2231
|
+
}
|
|
2232
|
+
/** Восстановить модель из сохранённого JSON (в проде, без обучения). */
|
|
2233
|
+
static load(json) {
|
|
2234
|
+
const params = typeof json === "string" ? JSON.parse(json) : json;
|
|
2235
|
+
if (!params.policy)
|
|
2236
|
+
params.policy = DEFAULT_POLICY; // обратная совместимость
|
|
2237
|
+
if (!params.riskReward)
|
|
2238
|
+
params.riskReward = { bySymbol: {}, global: { mean: 0, p95: 0, p99: 0, n: 0 } };
|
|
2239
|
+
if (!params.pnl)
|
|
2240
|
+
params.pnl = { bySymbol: {}, global: { mean: 0, median: 0, p5: 0, p95: 0, p99: 0, n: 0 } };
|
|
2241
|
+
return new PumpMatrix(params, loadPredict(params));
|
|
2242
|
+
}
|
|
2243
|
+
/** Сериализовать модель в JSON-строку (включая policy). */
|
|
2244
|
+
save() {
|
|
2245
|
+
return JSON.stringify(this.params);
|
|
2246
|
+
}
|
|
2247
|
+
dump(asString = false) {
|
|
2248
|
+
const history = this.params.history ?? [];
|
|
2249
|
+
return asString ? JSON.stringify(history) : history.map((h) => ({ ...h }));
|
|
2250
|
+
}
|
|
2251
|
+
/** Число записей в истории сигналов (0 если модель загружена без истории). */
|
|
2252
|
+
get historySize() {
|
|
2253
|
+
return this.params.history?.length ?? 0;
|
|
2254
|
+
}
|
|
2255
|
+
/** Полный exit-tensor (для аудита). */
|
|
2256
|
+
get exit() {
|
|
2257
|
+
return this.params.exit;
|
|
2258
|
+
}
|
|
2259
|
+
/** Политика разрешённых исходов, зашитая в модель (readonly-копия). */
|
|
2260
|
+
get policy() {
|
|
2261
|
+
return { allow: [...this.params.policy.allow] };
|
|
2262
|
+
}
|
|
2263
|
+
/** Надёжна ли модель (хватило ли данных при обучении). */
|
|
2264
|
+
get reliable() {
|
|
2265
|
+
return this.params.meta.reliable;
|
|
2266
|
+
}
|
|
2267
|
+
/** Доверие к модели 0..1. */
|
|
2268
|
+
get confidence() {
|
|
2269
|
+
return this.params.meta.confidence;
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* Эффективное число испытаний с family-wise поправкой на цепочку fit (мета-curse).
|
|
2273
|
+
* Если fit гнали многократно — это Σ конфигов по всем попыткам, а не текущий грид.
|
|
2274
|
+
*/
|
|
2275
|
+
get effectiveTrials() {
|
|
2276
|
+
return this.params.meta.effectiveTrials;
|
|
2277
|
+
}
|
|
2278
|
+
/** Число конфигов в гриде текущего fit (внутренние испытания). */
|
|
2279
|
+
get innerTrials() {
|
|
2280
|
+
return this.params.meta.innerTrials;
|
|
2281
|
+
}
|
|
2282
|
+
/** Сколько раз всего запускался fit (прозрачность мета-перебора). */
|
|
2283
|
+
get fitAttempts() {
|
|
2284
|
+
return this.params.meta.fitAttempts;
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
2287
|
+
* Статистический сертификат: прошёл ли эдж пять барьеров (DSR ≥ 0.95, PBO ≤ 0.10,
|
|
2288
|
+
* SPA p ≤ 0.05, N ≥ minTRL, nested OOS > 0). certified=false с reasons, если эдж
|
|
2289
|
+
* не доказан — тогда модель торговать НЕ должна.
|
|
2290
|
+
*/
|
|
2291
|
+
get certification() {
|
|
2292
|
+
return this.params.meta.certification;
|
|
2293
|
+
}
|
|
2294
|
+
/** Эмпирический импакт-горизонт поста в минутах (global-уровень). */
|
|
2295
|
+
get impactHorizonMinutes() {
|
|
2296
|
+
return this.params.meta.impactHorizonMinutes;
|
|
2297
|
+
}
|
|
2298
|
+
/**
|
|
2299
|
+
* Сколько минут истории СВЕЧЕЙ ДО сигнала нужно live-вызову plan() для каждого
|
|
2300
|
+
* сигнала: max(volBaselineWindow, cascadeWindowMinutes) + запас 5 свечей. Столько
|
|
2301
|
+
* 1m-свечей plan() запрашивает у getCandles (строго в прошлое, без look-ahead).
|
|
2302
|
+
* В проде держи доступной историю минимум на это окно для каждого свежего сигнала.
|
|
2303
|
+
*/
|
|
2304
|
+
get lookbackMinutes() {
|
|
2305
|
+
const baseWin = this.params.exit.global.volBaselineWindow ?? 20;
|
|
2306
|
+
const casWin = this.params.exit.global.cascadeWindowMinutes ?? 30;
|
|
2307
|
+
return Math.max(baseWin, casWin) + 5;
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Минимальное число НЕЗАВИСИМЫХ кластеров авторства, которые должны сойтись на
|
|
2311
|
+
* тикере, чтобы matrix-всплеск считался сигналом. Из config (по умолчанию 2).
|
|
2312
|
+
* В single-режиме не применяется (там всегда 1 кластер).
|
|
2313
|
+
*/
|
|
2314
|
+
get minClusters() {
|
|
2315
|
+
return this.params.config.minClusters;
|
|
2316
|
+
}
|
|
2317
|
+
/**
|
|
2318
|
+
* Минимальное число ОБЩИХ событий между каналами, при котором author-матрица
|
|
2319
|
+
* считается жизнеспособной (не шумовое совпадение) — порог перекрытия для
|
|
2320
|
+
* auto-режима. Из config.viability (по умолчанию DEFAULT_VIABILITY.minSharedEvents).
|
|
2321
|
+
* Грубо: сколько раз кластеры должны совпасть, чтобы их связь была не случайной.
|
|
2322
|
+
*/
|
|
2323
|
+
get minSharedEvents() {
|
|
2324
|
+
return this.params.config.viability?.minSharedEvents ?? DEFAULT_VIABILITY.minSharedEvents;
|
|
2325
|
+
}
|
|
2326
|
+
/** Режим, которым обучена модель: matrix (корреляция) | single (fallback). */
|
|
2327
|
+
get mode() {
|
|
2328
|
+
return this.params.meta.mode;
|
|
2329
|
+
}
|
|
2330
|
+
/** Честная диагностика: ПОЧЕМУ выбран этот режим (auto-критерий или явный выбор). */
|
|
2331
|
+
get modeReason() {
|
|
2332
|
+
return this.params.meta.modeReason ?? "(не записано)";
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Risk-reward по бэктесту: per-symbol + global. Главный исследовательский выход.
|
|
2336
|
+
* RR = pnl/hardStop в единицах риска (сколько R снято). bySymbol используется
|
|
2337
|
+
* runtime-фильтром minRiskReward.
|
|
2338
|
+
*/
|
|
2339
|
+
get riskReward() {
|
|
2340
|
+
return this.params.riskReward;
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Устойчивая к выбросам статистика реализованного PnL: median + перцентили
|
|
2344
|
+
* (p5/p95/p99) per-symbol и global. median/перцентили показывают выигрыш
|
|
2345
|
+
* системы без искажения единичной плохой или жирной сделкой.
|
|
2346
|
+
*/
|
|
2347
|
+
get pnl() {
|
|
2348
|
+
return this.params.pnl;
|
|
2349
|
+
}
|
|
2350
|
+
/**
|
|
2351
|
+
* Главный prod-вызов БЕЗ свечей. Возвращает ТОЛЬКО исполняемые сигналы — veto
|
|
2352
|
+
* уже отфильтрован. Без свечей каскад не оценивается → все исходы "enter".
|
|
2353
|
+
* Второй аргумент — allow-список, сужающий разрешённые исходы (не шире обученной).
|
|
2354
|
+
*/
|
|
2355
|
+
signals(items, policy) {
|
|
2356
|
+
return this.collect(items, () => null, policy);
|
|
2357
|
+
}
|
|
2358
|
+
plan(items, source, policy) {
|
|
2359
|
+
if (typeof source === "function") {
|
|
2360
|
+
return this.planLiveViaGetCandles(items, source, policy);
|
|
2361
|
+
}
|
|
2362
|
+
const eff = intersectPolicy(this.params.policy, policy);
|
|
2363
|
+
const out = [];
|
|
2364
|
+
for (const v of this._predict(items).signals) {
|
|
2365
|
+
const s = this.buildSignalLive(v, source[v.symbol] ?? null, eff);
|
|
2366
|
+
if (s)
|
|
2367
|
+
out.push(s);
|
|
2368
|
+
}
|
|
2369
|
+
return out;
|
|
2370
|
+
}
|
|
2371
|
+
async planLiveViaGetCandles(items, getCandles, policy) {
|
|
2372
|
+
const eff = intersectPolicy(this.params.policy, policy);
|
|
2373
|
+
// окно ДО сигнала (единый источник — геттер lookbackMinutes): базлайн объёма +
|
|
2374
|
+
// горизонт каскада, всё в прошлом, без forward.
|
|
2375
|
+
const lookback = this.lookbackMinutes;
|
|
2376
|
+
const out = [];
|
|
2377
|
+
for (const v of this._predict(items).signals) {
|
|
2378
|
+
let candles = null;
|
|
2379
|
+
try {
|
|
2380
|
+
// тянем lookback свечей, заканчивающихся НА сигнальной минуте (не позже).
|
|
2381
|
+
// since = entryStartTs - lookback·step: окно строго ДО входа.
|
|
2382
|
+
const step = STEP_MS["1m"];
|
|
2383
|
+
const start = entryStartTs(v.ts, "1m");
|
|
2384
|
+
const since = start - lookback * step;
|
|
2385
|
+
candles = await fetchCandlesChunked(getCandles, v.symbol, "1m", lookback, since);
|
|
2386
|
+
if (!candles.length)
|
|
2387
|
+
candles = null;
|
|
2388
|
+
}
|
|
2389
|
+
catch {
|
|
2390
|
+
candles = null; // битый символ → сигнал без свечей, не рушим весь вызов
|
|
2391
|
+
}
|
|
2392
|
+
const s = this.buildSignalLive(v, candles, eff);
|
|
2393
|
+
if (s)
|
|
2394
|
+
out.push(s);
|
|
2395
|
+
}
|
|
2396
|
+
return out;
|
|
2397
|
+
}
|
|
2398
|
+
backtest(items, source, policy) {
|
|
2399
|
+
if (typeof source === "function") {
|
|
2400
|
+
return this.backtestViaGetCandles(items, source, policy);
|
|
2401
|
+
}
|
|
2402
|
+
return this.collect(items, (v) => source[v.symbol] ?? null, policy);
|
|
2403
|
+
}
|
|
2404
|
+
async backtestViaGetCandles(items, getCandles, policy) {
|
|
2405
|
+
const eff = intersectPolicy(this.params.policy, policy);
|
|
2406
|
+
const maxLife = this.params.exit.global.staleMinutes;
|
|
2407
|
+
const limit = maxLife * 2 + 5;
|
|
2408
|
+
const out = [];
|
|
2409
|
+
for (const v of this._predict(items).signals) {
|
|
2410
|
+
let candles = null;
|
|
2411
|
+
try {
|
|
2412
|
+
const since = entryStartTs(v.ts, "1m");
|
|
2413
|
+
candles = await fetchCandlesChunked(getCandles, v.symbol, "1m", limit, since);
|
|
2414
|
+
if (!candles.length)
|
|
2415
|
+
candles = null;
|
|
2416
|
+
}
|
|
2417
|
+
catch {
|
|
2418
|
+
candles = null;
|
|
2419
|
+
}
|
|
2420
|
+
const s = this.buildSignal(v, candles, eff);
|
|
2421
|
+
if (s)
|
|
2422
|
+
out.push(s);
|
|
2423
|
+
}
|
|
2424
|
+
return out;
|
|
2425
|
+
}
|
|
2426
|
+
/** Точечно под ОДНУ позицию в LIVE (вход = последняя свеча, каскад по прошлому). */
|
|
2427
|
+
planFor(symbol, direction, channel, candles, policy) {
|
|
2428
|
+
const entryTs = candles[candles.length - 1]?.timestamp ?? 0;
|
|
2429
|
+
const v = {
|
|
2430
|
+
symbol, direction, action: "open", ts: entryTs,
|
|
2431
|
+
independentClusters: 1, totalChannels: 1, confidence: 0.5,
|
|
2432
|
+
reason: "planFor", source: this.params.meta.mode, channel,
|
|
2433
|
+
};
|
|
2434
|
+
const eff = intersectPolicy(this.params.policy, policy);
|
|
2435
|
+
return this.buildSignalLive(v, candles, eff);
|
|
2436
|
+
}
|
|
2437
|
+
/** Бэктест под ОДНУ позицию с явным entryTs (replay вперёд, каскад по будущему). */
|
|
2438
|
+
planForAt(symbol, direction, channel, candles, entryTs, policy) {
|
|
2439
|
+
const v = {
|
|
2440
|
+
symbol, direction, action: "open", ts: entryTs,
|
|
2441
|
+
independentClusters: 1, totalChannels: 1, confidence: 0.5,
|
|
2442
|
+
reason: "planFor", source: this.params.meta.mode, channel,
|
|
2443
|
+
};
|
|
2444
|
+
const eff = intersectPolicy(this.params.policy, policy);
|
|
2445
|
+
return this.buildSignal(v, candles, eff);
|
|
2446
|
+
}
|
|
2447
|
+
/** Полный отчёт (все вердикты + карта авторства) — для разбора. */
|
|
2448
|
+
explain(items) {
|
|
2449
|
+
return this._predict(items);
|
|
2450
|
+
}
|
|
2451
|
+
// ── общий сборщик: predict → buildSignal → отсев null (veto/не разрешено) ──
|
|
2452
|
+
collect(items, candlesOf, policy) {
|
|
2453
|
+
const eff = intersectPolicy(this.params.policy, policy);
|
|
2454
|
+
const out = [];
|
|
2455
|
+
for (const v of this._predict(items).signals) {
|
|
2456
|
+
const s = this.buildSignal(v, candlesOf(v), eff);
|
|
2457
|
+
if (s)
|
|
2458
|
+
out.push(s); // null = veto или исход не в allow → не отдаём
|
|
2459
|
+
}
|
|
2460
|
+
return out;
|
|
2461
|
+
}
|
|
2462
|
+
flatExit(ex) {
|
|
2463
|
+
return {
|
|
2464
|
+
trailingTake: ex.trailingTake,
|
|
2465
|
+
hardStop: ex.hardStop,
|
|
2466
|
+
impactHorizonMinutes: ex.staleMinutes,
|
|
2467
|
+
stalenessSinceProfit: ex.stalenessSinceProfit,
|
|
2468
|
+
stalenessSinceMinutes: ex.stalenessSinceMinutes,
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* BACKTEST-сборка сигнала: каскад по свечам ПОСЛЕ входа (forward squeezePressure),
|
|
2473
|
+
* допустимо только на истории. Делегирует в общее ядро с mode="backtest".
|
|
2474
|
+
*/
|
|
2475
|
+
buildSignal(v, candles, policy) {
|
|
2476
|
+
return this.buildSignalCore(v, candles, policy, "backtest");
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* LIVE-сборка сигнала: каскад по свечам ДО входа (backward squeezePressureBefore),
|
|
2480
|
+
* БЕЗ look-ahead. Делегирует в общее ядро с mode="live".
|
|
2481
|
+
*/
|
|
2482
|
+
buildSignalLive(v, candles, policy) {
|
|
2483
|
+
return this.buildSignalCore(v, candles, policy, "live");
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* Строит ЕДИНЫЙ TradeSignal из вердикта. Возвращает null, если исполнять нечего:
|
|
2487
|
+
* каскад дал veto ИЛИ получившийся action не в allow-списке. Инверсия здесь же
|
|
2488
|
+
* разворачивает direction и тянет exit из инверс-ячейки — наружу уходит готовое
|
|
2489
|
+
* направление, без флагов.
|
|
2490
|
+
*
|
|
2491
|
+
* mode="live": каскад меряется по свечам ДО входа (squeezePressureBefore) — в live
|
|
2492
|
+
* свечей после входа нет, look-ahead запрещён.
|
|
2493
|
+
* mode="backtest": каскад по свечам ПОСЛЕ входа (squeezePressure) — допустимо на
|
|
2494
|
+
* завершённой истории.
|
|
2495
|
+
*/
|
|
2496
|
+
buildSignalCore(v, candles, policy, mode) {
|
|
2497
|
+
const ch = v.channel ?? "_matrix";
|
|
2498
|
+
const dir = v.direction;
|
|
2499
|
+
const allow = new Set(policy.allow);
|
|
2500
|
+
// ── readonly RR-фильтр: режем символы с backtest-RR ниже порога ──
|
|
2501
|
+
if (policy.minRiskReward !== undefined) {
|
|
2502
|
+
const rr = this.params.riskReward?.bySymbol?.[v.symbol];
|
|
2503
|
+
// rrMetric гарантированно задан intersectPolicy (там ?? "mean"), здесь не дефолтим
|
|
2504
|
+
const metric = policy.rrMetric;
|
|
2505
|
+
// нет статистики по символу → нечем подтвердить RR → режем (консервативно)
|
|
2506
|
+
if (!rr || rr[metric] < policy.minRiskReward)
|
|
2507
|
+
return null;
|
|
2508
|
+
}
|
|
2509
|
+
let volRegime = null;
|
|
2510
|
+
const probe = resolveExit(this.params.exit, v.source, ch, v.symbol, dir, "calm");
|
|
2511
|
+
const volZThr = probe.exit.volZThreshold ?? 2.0;
|
|
2512
|
+
const baseWin = probe.exit.volBaselineWindow ?? 20;
|
|
2513
|
+
const horizon = probe.exit.cascadeWindowMinutes ?? probe.exit.staleMinutes;
|
|
2514
|
+
let sqPressure = null;
|
|
2515
|
+
if (candles && candles.length > 0) {
|
|
2516
|
+
// первая свеча, ОТКРЫВШАЯСЯ не раньше сигнальной минуты (без look-ahead в
|
|
2517
|
+
// формирующуюся свечу сигнала). entryStartTs гарантирует полностью сформированную.
|
|
2518
|
+
const startTs = entryStartTs(v.ts, "1m");
|
|
2519
|
+
let entryIdx = candles.findIndex((c) => c.timestamp >= startTs);
|
|
2520
|
+
if (entryIdx < 0)
|
|
2521
|
+
entryIdx = candles.length - 1;
|
|
2522
|
+
const volZ = volumeZScore(candles, entryIdx, baseWin);
|
|
2523
|
+
// КАСКАД: live — по прошлым свечам (без look-ahead), backtest — по будущим.
|
|
2524
|
+
sqPressure = mode === "live"
|
|
2525
|
+
? squeezePressureBefore(candles, entryIdx, dir, horizon)
|
|
2526
|
+
: squeezePressure(candles, entryIdx, dir, horizon);
|
|
2527
|
+
volRegime = volRegimeOf(volZ, volZThr);
|
|
2528
|
+
}
|
|
2529
|
+
let resolved = volRegime
|
|
2530
|
+
? resolveExit(this.params.exit, v.source, ch, v.symbol, dir, volRegime)
|
|
2531
|
+
: resolveExitNoRegime(this.params.exit, v.source, v.symbol, dir);
|
|
2532
|
+
let exit = resolved.exit;
|
|
2533
|
+
// ── решение по каскаду → action + (возможно) разворот direction ──
|
|
2534
|
+
let action = "enter";
|
|
2535
|
+
let finalDir = dir;
|
|
2536
|
+
let invertedFrom = null;
|
|
2537
|
+
const fires = sqPressure !== null && sqPressure >= (exit.squeezeThreshold ?? 0.6);
|
|
2538
|
+
if (fires) {
|
|
2539
|
+
const pol = exit.squeezePolicy;
|
|
2540
|
+
if (pol === "veto") {
|
|
2541
|
+
return null; // каскад — НЕ входить. veto не попадает в выдачу.
|
|
2542
|
+
}
|
|
2543
|
+
if (pol === "invert") {
|
|
2544
|
+
if (!allow.has("invert"))
|
|
2545
|
+
return null; // инверсия запрещена → защищаемся как veto
|
|
2546
|
+
action = "invert";
|
|
2547
|
+
finalDir = dir === "long" ? "short" : "long";
|
|
2548
|
+
invertedFrom = dir;
|
|
2549
|
+
// fires=true ⇒ были свечи ⇒ volRegime гарантированно посчитан (calm|anomalous),
|
|
2550
|
+
// поэтому здесь всегда cell-резолв по режиму — без noRegime-ветки.
|
|
2551
|
+
resolved = resolveExit(this.params.exit, v.source, ch, v.symbol, finalDir, volRegime);
|
|
2552
|
+
exit = resolved.exit;
|
|
2553
|
+
}
|
|
2554
|
+
else if (pol === "tighten") {
|
|
2555
|
+
if (!allow.has("tighten"))
|
|
2556
|
+
return null;
|
|
2557
|
+
action = "tighten";
|
|
2558
|
+
}
|
|
2559
|
+
// pol === "ignore": каскад замечен, но НАМЕРЕННО игнорируется — входим в
|
|
2560
|
+
// исходном направлении (action остаётся "enter"). В отличие от veto/invert
|
|
2561
|
+
// сигнал НЕ отсекается; реализуется реальный (обычно плохой) pnl. Это даёт
|
|
2562
|
+
// контрфакт «что если не реагировать на каскад» прямо в выдаче, а не только
|
|
2563
|
+
// в стороннем анализе. pol === "none" ведёт себя так же (вход без реакции).
|
|
2564
|
+
}
|
|
2565
|
+
if (action === "enter" && !allow.has("enter"))
|
|
2566
|
+
return null;
|
|
2567
|
+
const plan = this.flatExit(exit);
|
|
2568
|
+
if (action === "tighten") {
|
|
2569
|
+
plan.trailingTake = +(exit.trailingTake * (exit.tightenFactor ?? 0.5)).toFixed(6);
|
|
2570
|
+
}
|
|
2571
|
+
const origin = {
|
|
2572
|
+
detector: v.source,
|
|
2573
|
+
channel: v.channel,
|
|
2574
|
+
invertedFrom,
|
|
2575
|
+
exitSource: resolved.source,
|
|
2576
|
+
volRegime,
|
|
2577
|
+
confidence: v.confidence,
|
|
2578
|
+
independentClusters: v.independentClusters,
|
|
2579
|
+
modelConfidence: this.params.meta.confidence,
|
|
2580
|
+
modelReliable: this.params.meta.reliable,
|
|
2581
|
+
id: v.id,
|
|
2582
|
+
ids: v.ids,
|
|
2583
|
+
};
|
|
2584
|
+
return { symbol: v.symbol, direction: finalDir, action, ts: v.ts, exit: plan, origin };
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
/** Нормализует parser-items в чистые события, отбрасывая лишние поля и мусор. */
|
|
2589
|
+
function normalize(items) {
|
|
2590
|
+
const out = [];
|
|
2591
|
+
for (const it of items) {
|
|
2592
|
+
if (!it || typeof it.channel !== "string" || typeof it.symbol !== "string")
|
|
2593
|
+
continue;
|
|
2594
|
+
if (it.direction !== "long" && it.direction !== "short")
|
|
2595
|
+
continue;
|
|
2596
|
+
if (typeof it.ts !== "number" || !Number.isFinite(it.ts))
|
|
2597
|
+
continue;
|
|
2598
|
+
out.push({
|
|
2599
|
+
channel: it.channel,
|
|
2600
|
+
symbol: it.symbol,
|
|
2601
|
+
direction: it.direction,
|
|
2602
|
+
ts: it.ts,
|
|
2603
|
+
entryFromPrice: typeof it.entryFromPrice === "number" ? it.entryFromPrice : undefined,
|
|
2604
|
+
entryToPrice: typeof it.entryToPrice === "number" ? it.entryToPrice : undefined,
|
|
2605
|
+
id: typeof it.id === "string" ? it.id : (typeof it.id === "number" ? String(it.id) : undefined),
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
return out;
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Чёрная коробка. Единственная точка входа.
|
|
2612
|
+
*
|
|
2613
|
+
* predict(parserItems) -> PredictionResult
|
|
2614
|
+
*
|
|
2615
|
+
* Два режима отбора входов (config.mode):
|
|
2616
|
+
* - "matrix": вход = синхронный всплеск независимых кластеров-авторов.
|
|
2617
|
+
* - "single": fallback — каждый пост = вход, исход решает обученный exit.
|
|
2618
|
+
* - "auto": матрица только если корреляция жизнеспособна, иначе single.
|
|
2619
|
+
*
|
|
2620
|
+
* Exit НЕ единый: подбирается отдельно под каждую ячейку тензора
|
|
2621
|
+
* [mode][channel][symbol][direction][volRegime] — математика разных источников
|
|
2622
|
+
* не смешивается (matrix/single, long/short, calm/anomalous — свои критерии).
|
|
2623
|
+
*/
|
|
2624
|
+
function predict(parserItems, config = {}) {
|
|
2625
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
2626
|
+
const events = normalize(parserItems);
|
|
2627
|
+
const fullTbl = buildTable(events);
|
|
2628
|
+
// окно стационарности: статистики авторства считаем по последнему окну,
|
|
2629
|
+
// заканчивающемуся на самом свежем событии (а не по всей истории).
|
|
2630
|
+
const anchorTs = events.length ? events[events.length - 1].ts : 0;
|
|
2631
|
+
const tbl = Number.isFinite(cfg.stationarityWindowMs)
|
|
2632
|
+
? buildWindowedTable(events, anchorTs, cfg.stationarityWindowMs)
|
|
2633
|
+
: fullTbl;
|
|
2634
|
+
const tau = selfTuneLag(tbl);
|
|
2635
|
+
const window = Math.min(cfg.windowK * tau, cfg.maxBurstWindowMs);
|
|
2636
|
+
const screened = jaccardScreen(tbl, window, cfg.jaccardThreshold);
|
|
2637
|
+
const directed = lagXCorr(tbl, screened, cfg.lagPeakThreshold, window);
|
|
2638
|
+
const authors = clusterAuthors(tbl.channels, directed);
|
|
2639
|
+
const matrixVerdicts = earlyWarning(tbl, authors, cfg, tau);
|
|
2640
|
+
const matrixOpens = matrixVerdicts.filter((v) => v.action === "open");
|
|
2641
|
+
// оценка жизнеспособности матрицы (строгий критерий: явные кластеры + перекрытие)
|
|
2642
|
+
const viability = assessViability(tbl, directed, authors, {
|
|
2643
|
+
...DEFAULT_VIABILITY,
|
|
2644
|
+
...cfg.viability,
|
|
2645
|
+
});
|
|
2646
|
+
// ── разрешение режима ──
|
|
2647
|
+
let usedMode;
|
|
2648
|
+
if (cfg.mode === "matrix")
|
|
2649
|
+
usedMode = "matrix";
|
|
2650
|
+
else if (cfg.mode === "single")
|
|
2651
|
+
usedMode = "single";
|
|
2652
|
+
else {
|
|
2653
|
+
// auto: матрица только если корреляция ЖИЗНЕСПОСОБНА И реально дала сигнал.
|
|
2654
|
+
// Плохая корреляция на 2+ каналах (шумовое совпадение) → откат в single.
|
|
2655
|
+
usedMode = viability.viable && matrixOpens.length > 0 ? "matrix" : "single";
|
|
2656
|
+
}
|
|
2657
|
+
let signals;
|
|
2658
|
+
let verdicts;
|
|
2659
|
+
if (usedMode === "matrix") {
|
|
2660
|
+
verdicts = matrixVerdicts;
|
|
2661
|
+
signals = matrixOpens;
|
|
2662
|
+
}
|
|
2663
|
+
else {
|
|
2664
|
+
const fb = singleChannelSignals(tbl, cfg, tau);
|
|
2665
|
+
verdicts = fb;
|
|
2666
|
+
signals = fb; // в fallback все вердикты — это входы
|
|
2667
|
+
}
|
|
2668
|
+
return {
|
|
2669
|
+
signals,
|
|
2670
|
+
verdicts,
|
|
2671
|
+
authors,
|
|
2672
|
+
authorCount: new Set(authors.values()).size,
|
|
2673
|
+
tauMs: tau,
|
|
2674
|
+
windowMs: window,
|
|
2675
|
+
usedMode,
|
|
2676
|
+
viability,
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
export { CASCADE_AGGRESSION, DEFAULT_CONFIG, DEFAULT_GRID, DEFAULT_META_POLICY, DEFAULT_POLICY, DEFAULT_RELIABILITY, DEFAULT_SELECTION, DEFAULT_VIABILITY, MAX_CANDLES_PER_CHUNK, PumpMatrix, STEP_MS, alignTs, assessViability, buildTable, buildWindowedTable, canRefit, cascadeAggressionOf, certifyStrategy, clusterAuthors, computeReliability, conservatismKey, deflatedSharpe, earlyWarning, effectiveTrials, emptyLedger, entryStartTs, enumerateBursts, enumeratePosts, exitKey, expectedMaxSharpe, fetchCandlesChunked, fitAttemptCount, intersectPolicy, isMoreConservative, jaccardPair, jaccardScreen, kurtosis, labelBurst, lagXCorr, loadPredict, mean, minTrackRecordLength, mulberry32, normalCdf, normalInv, oneStandardErrorSelect, percentile, pnlStats, predict, probabilityOfBacktestOverfitting, realityCheckPValue, recordAttempt, replayExit, resolveExit, resolveExitNoRegime, riskRewardStats, selfTuneLag, sharpe, shrinkageExpectancy, silentProgress, singleChannelSignals, skewness, squeezePressure, standardError, stationaryBootstrapResample, stdev, stdoutProgress, train, variance, volRegimeOf, volumeFeatures, volumeZScore, windowEvents, winrate };
|