@energy8platform/stake-math-tools 0.4.0 → 0.5.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/README.md +223 -56
- package/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/optimize-lookup.ts +174 -19
- package/src/stake-report.ts +145 -0
- package/src/tiered.ts +1428 -0
- package/src/types.ts +118 -0
- package/test/optimize-lookup.integration.test.ts +423 -0
- package/test/optimize-lookup.unit.test.ts +2 -0
package/src/optimize-lookup.ts
CHANGED
|
@@ -11,6 +11,18 @@ import { bucketize } from './bucketize.js';
|
|
|
11
11
|
import { mulberry32, computeQuotas, stratifiedSample } from './sample.js';
|
|
12
12
|
import { solveNNLS } from './nnls.js';
|
|
13
13
|
import { quantizeWeights } from './quantize.js';
|
|
14
|
+
import { buildTieredLookup } from './tiered.js';
|
|
15
|
+
import { computeStakeReport, detectHitRateGaps } from './stake-report.js';
|
|
16
|
+
|
|
17
|
+
function emitGapWarning(stakeReport: ReturnType<typeof computeStakeReport>, warnings: string[]): void {
|
|
18
|
+
const gaps = detectHitRateGaps(stakeReport.hitRateDistribution);
|
|
19
|
+
if (gaps.length > 0) {
|
|
20
|
+
const formatted = gaps.map((g) => `[${g.low}, ${g.high})`).join(', ');
|
|
21
|
+
warnings.push(
|
|
22
|
+
`hit-rate distribution has ${gaps.length} intermediate gap(s) — Stake "Gaps in the Hit Rate Table" check may fail: ${formatted}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
14
26
|
|
|
15
27
|
const DEFAULTS = {
|
|
16
28
|
requireMaxReached: true,
|
|
@@ -21,12 +33,19 @@ const DEFAULTS = {
|
|
|
21
33
|
bucketCount: 100,
|
|
22
34
|
minPerBucket: 3,
|
|
23
35
|
maxRowRtpShare: 0.05,
|
|
36
|
+
maxWeightPerRow: 10,
|
|
37
|
+
betCostCents: 100,
|
|
24
38
|
};
|
|
25
39
|
|
|
26
40
|
export function optimizeLookupTable(
|
|
27
41
|
rowsIn: Iterable<LookupRow>,
|
|
28
42
|
params: OptimizeParams,
|
|
29
43
|
): OptimizeResult {
|
|
44
|
+
const algorithm = params.algorithm ?? 'tiered';
|
|
45
|
+
if (algorithm === 'tiered') {
|
|
46
|
+
return buildTieredLookup(rowsIn, params);
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
const requireMaxReached = params.requireMaxReached ?? DEFAULTS.requireMaxReached;
|
|
31
50
|
const maxReachedFraction = params.maxReachedFraction ?? DEFAULTS.maxReachedFraction;
|
|
32
51
|
const totalWeightOut = params.totalWeightOut ?? params.nRowsOut * DEFAULTS.totalWeightOutPerRow;
|
|
@@ -35,6 +54,8 @@ export function optimizeLookupTable(
|
|
|
35
54
|
const bucketCount = params.bucketCount ?? DEFAULTS.bucketCount;
|
|
36
55
|
let minPerBucket = params.minPerBucket ?? DEFAULTS.minPerBucket;
|
|
37
56
|
const maxRowRtpShare = params.maxRowRtpShare ?? DEFAULTS.maxRowRtpShare;
|
|
57
|
+
const maxWeightPerRow = params.maxWeightPerRow ?? DEFAULTS.maxWeightPerRow;
|
|
58
|
+
const betCostCents = params.betCostCents ?? DEFAULTS.betCostCents;
|
|
38
59
|
|
|
39
60
|
const warnings: string[] = [];
|
|
40
61
|
|
|
@@ -75,6 +96,7 @@ export function optimizeLookupTable(
|
|
|
75
96
|
achieved: OptimizeAchieved;
|
|
76
97
|
toleranceMet: ToleranceMet;
|
|
77
98
|
maxRowShare: number;
|
|
99
|
+
maxWeightRatio: number;
|
|
78
100
|
lossSum: number;
|
|
79
101
|
capWarning?: string;
|
|
80
102
|
}
|
|
@@ -151,13 +173,18 @@ export function optimizeLookupTable(
|
|
|
151
173
|
muHat = newMu;
|
|
152
174
|
}
|
|
153
175
|
|
|
154
|
-
// ── Iterative RTP-share cap (Stake Engine "Within Liability Limits")
|
|
176
|
+
// ── Iterative RTP-share + per-row weight cap (Stake Engine "Within Liability Limits") ─
|
|
155
177
|
//
|
|
156
|
-
// After NNLS converges, one or a few rows may dominate the total RTP
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// the
|
|
178
|
+
// After NNLS converges, one or a few rows may dominate the total RTP, or a single
|
|
179
|
+
// row may absorb enormous weight (zero-payout or near-zero-payout filler rows used
|
|
180
|
+
// to satisfy hit-rate / total-weight constraints cheaply). Stake Engine rejects
|
|
181
|
+
// tables where a single row carries an oversized share of expected return OR
|
|
182
|
+
// oversized weight (the Expected Tail Liability check). We iteratively cap any
|
|
183
|
+
// violating row and re-solve the (smaller) NNLS problem on the remaining rows
|
|
184
|
+
// until no violator remains or the iteration budget is exhausted.
|
|
185
|
+
const maxAllowedWeight = Number.isFinite(maxWeightPerRow)
|
|
186
|
+
? maxWeightPerRow * (totalWeightOut / candidates.length)
|
|
187
|
+
: Infinity;
|
|
161
188
|
const fixedWeight = new Map<number, number>(); // candidate index → fixed weight
|
|
162
189
|
let capIters = 0;
|
|
163
190
|
const maxCapIters = 50;
|
|
@@ -170,28 +197,35 @@ export function optimizeLookupTable(
|
|
|
170
197
|
const w = fixedWeight.has(i) ? fixedWeight.get(i)! : weights[i];
|
|
171
198
|
totalWP += w * candidates[i].payoutCents;
|
|
172
199
|
}
|
|
173
|
-
if (totalWP <= 0) {
|
|
174
|
-
capConverged = true;
|
|
175
|
-
break;
|
|
176
|
-
}
|
|
177
200
|
|
|
178
|
-
// Find violators (only among non-fixed rows)
|
|
201
|
+
// Find violators (only among non-fixed rows). We check BOTH the RTP-share
|
|
202
|
+
// cap and the absolute per-row weight cap; either constraint suffices.
|
|
179
203
|
const violators: number[] = [];
|
|
180
204
|
for (let i = 0; i < candidates.length; i++) {
|
|
181
205
|
if (fixedWeight.has(i)) continue;
|
|
182
206
|
const w = weights[i];
|
|
183
|
-
const
|
|
184
|
-
|
|
207
|
+
const p = candidates[i].payoutCents;
|
|
208
|
+
const exceedsRtpShare =
|
|
209
|
+
totalWP > 0 && (w * p) / totalWP > maxRowRtpShare;
|
|
210
|
+
const exceedsWeight = w > maxAllowedWeight;
|
|
211
|
+
if (exceedsRtpShare || exceedsWeight) violators.push(i);
|
|
185
212
|
}
|
|
186
213
|
if (violators.length === 0) {
|
|
187
214
|
capConverged = true;
|
|
188
215
|
break;
|
|
189
216
|
}
|
|
190
217
|
|
|
191
|
-
// Cap each violator at
|
|
218
|
+
// Cap each violator at the TIGHTEST applicable bound: RTP-share-derived
|
|
219
|
+
// limit (only meaningful for nonzero-payout rows) intersected with the
|
|
220
|
+
// absolute weight cap. Truncate to integer.
|
|
192
221
|
for (const i of violators) {
|
|
193
222
|
const p = candidates[i].payoutCents;
|
|
194
|
-
|
|
223
|
+
let cap = maxAllowedWeight;
|
|
224
|
+
if (p > 0 && totalWP > 0) {
|
|
225
|
+
const rtpCap = (maxRowRtpShare * totalWP) / p;
|
|
226
|
+
if (rtpCap < cap) cap = rtpCap;
|
|
227
|
+
}
|
|
228
|
+
const cappedW = Math.max(1, Math.floor(cap));
|
|
195
229
|
fixedWeight.set(i, cappedW);
|
|
196
230
|
}
|
|
197
231
|
|
|
@@ -260,11 +294,98 @@ export function optimizeLookupTable(
|
|
|
260
294
|
|
|
261
295
|
const capWarning =
|
|
262
296
|
!capConverged && fixedWeight.size > 0
|
|
263
|
-
? `maxRowRtpShare cap could not converge in ${maxCapIters} iterations`
|
|
297
|
+
? `maxRowRtpShare / maxWeightPerRow cap could not converge in ${maxCapIters} iterations`
|
|
264
298
|
: undefined;
|
|
265
299
|
|
|
266
300
|
// Quantize
|
|
267
301
|
const quantized = quantizeWeights(weights, totalWeightOut);
|
|
302
|
+
|
|
303
|
+
// Post-quantize weight-cap enforcement: largest-remainder quantization can
|
|
304
|
+
// redistribute integer mass onto any row, potentially pushing capped rows
|
|
305
|
+
// (or previously-uncapped rows) above maxAllowedWeight. Walk the array
|
|
306
|
+
// greedily: peel excess off over-cap rows and pour it onto rows below cap.
|
|
307
|
+
//
|
|
308
|
+
// Recipient preference order:
|
|
309
|
+
// 1. Zero-payout rows (safe — don't disturb RTP-share cap), ordered by
|
|
310
|
+
// smallest current weight first (preserve shape).
|
|
311
|
+
// 2. Non-zero-payout rows, ordered by largest RTP-share headroom first
|
|
312
|
+
// (i.e., lowest current rtpShare / payout ratio) so we minimize the
|
|
313
|
+
// risk of pushing a row past maxRowRtpShare.
|
|
314
|
+
if (Number.isFinite(maxAllowedWeight)) {
|
|
315
|
+
const intCap = Math.max(1, Math.floor(maxAllowedWeight));
|
|
316
|
+
let totalExcess = 0;
|
|
317
|
+
for (let i = 0; i < quantized.length; i++) {
|
|
318
|
+
if (quantized[i] > intCap) {
|
|
319
|
+
totalExcess += quantized[i] - intCap;
|
|
320
|
+
quantized[i] = intCap;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (totalExcess > 0) {
|
|
324
|
+
// Recompute current totalWP for per-row RTP-share bookkeeping. We need
|
|
325
|
+
// an upper bound on what totalWP could become after redistribution:
|
|
326
|
+
// pouring excess onto non-zero rows can only grow totalWP. Use the
|
|
327
|
+
// pre-redistribution snapshot (conservative — gives smaller rtpCapWP)
|
|
328
|
+
// and apply a safety margin of 95% to leave headroom for quantization
|
|
329
|
+
// and totalWP drift during pouring.
|
|
330
|
+
let curTotalWP = 0;
|
|
331
|
+
for (let i = 0; i < quantized.length; i++) {
|
|
332
|
+
curTotalWP += quantized[i] * candidates[i].payoutCents;
|
|
333
|
+
}
|
|
334
|
+
const rtpCapWP = 0.95 * maxRowRtpShare * Math.max(curTotalWP, 1);
|
|
335
|
+
|
|
336
|
+
// Bucket 1: zero-payout rows.
|
|
337
|
+
const zeroRecipients: number[] = [];
|
|
338
|
+
// Bucket 2: non-zero-payout rows with RTP-share headroom.
|
|
339
|
+
const nonZeroRecipients: number[] = [];
|
|
340
|
+
for (let i = 0; i < quantized.length; i++) {
|
|
341
|
+
if (quantized[i] >= intCap) continue;
|
|
342
|
+
if (candidates[i].payoutCents === 0) {
|
|
343
|
+
zeroRecipients.push(i);
|
|
344
|
+
} else {
|
|
345
|
+
nonZeroRecipients.push(i);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
zeroRecipients.sort((a, b) => quantized[a] - quantized[b]);
|
|
349
|
+
// Sort non-zero recipients by current w·p ascending (most headroom first).
|
|
350
|
+
nonZeroRecipients.sort(
|
|
351
|
+
(a, b) =>
|
|
352
|
+
quantized[a] * candidates[a].payoutCents -
|
|
353
|
+
quantized[b] * candidates[b].payoutCents,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const pour = (recipients: number[], respectRtpCap: boolean): void => {
|
|
357
|
+
for (const r of recipients) {
|
|
358
|
+
if (totalExcess === 0) return;
|
|
359
|
+
const headroom = intCap - quantized[r];
|
|
360
|
+
if (headroom <= 0) continue;
|
|
361
|
+
let give = Math.min(headroom, totalExcess);
|
|
362
|
+
if (respectRtpCap) {
|
|
363
|
+
const p = candidates[r].payoutCents;
|
|
364
|
+
if (p > 0) {
|
|
365
|
+
const curWP = quantized[r] * p;
|
|
366
|
+
const maxAddWP = rtpCapWP - curWP;
|
|
367
|
+
if (maxAddWP <= 0) continue;
|
|
368
|
+
const maxAddW = Math.floor(maxAddWP / p);
|
|
369
|
+
if (maxAddW <= 0) continue;
|
|
370
|
+
give = Math.min(give, maxAddW);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
quantized[r] += give;
|
|
374
|
+
totalExcess -= give;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
pour(zeroRecipients, false);
|
|
379
|
+
pour(nonZeroRecipients, true);
|
|
380
|
+
// If excess remains, fall back to any below-cap row (cap was infeasible
|
|
381
|
+
// for this nRowsOut / totalWeightOut combination). toleranceMet.weightCap
|
|
382
|
+
// computed below reflects the actual result.
|
|
383
|
+
if (totalExcess > 0) {
|
|
384
|
+
pour(nonZeroRecipients, false);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
268
389
|
const outRows: LookupRow[] = candidates.map((r, i) => ({
|
|
269
390
|
sim: r.sim,
|
|
270
391
|
weight: quantized[i],
|
|
@@ -284,6 +405,14 @@ export function optimizeLookupTable(
|
|
|
284
405
|
}
|
|
285
406
|
}
|
|
286
407
|
|
|
408
|
+
// Compute max single-row weight ratio (as a multiple of uniform prior).
|
|
409
|
+
const uniformPrior = totalWeightOut / outRows.length;
|
|
410
|
+
let maxWeightObs = 0;
|
|
411
|
+
for (const r of outRows) {
|
|
412
|
+
if (r.weight > maxWeightObs) maxWeightObs = r.weight;
|
|
413
|
+
}
|
|
414
|
+
const maxWeightRatio = uniformPrior > 0 ? maxWeightObs / uniformPrior : 0;
|
|
415
|
+
|
|
287
416
|
const toleranceMet: ToleranceMet = {
|
|
288
417
|
rtp: Math.abs(achieved.rtp - params.targetRTP) <= params.toleranceRTP,
|
|
289
418
|
cv: Math.abs(achieved.cv - params.targetCV) <= params.toleranceCV,
|
|
@@ -292,6 +421,7 @@ export function optimizeLookupTable(
|
|
|
292
421
|
!requireMaxReached ||
|
|
293
422
|
outRows.some((r) => isNearMax(r.payoutCents, params.capMaxWin, maxReachedFraction)),
|
|
294
423
|
rtpConcentration: maxRowShare <= maxRowRtpShare,
|
|
424
|
+
weightCap: maxWeightRatio <= maxWeightPerRow + 1e-6,
|
|
295
425
|
};
|
|
296
426
|
|
|
297
427
|
// Loss for "best so far" tracking — Σ tolerance-normalized squared misses
|
|
@@ -300,11 +430,20 @@ export function optimizeLookupTable(
|
|
|
300
430
|
Math.pow((achieved.cv - params.targetCV) / params.toleranceCV, 2) +
|
|
301
431
|
Math.pow((achieved.hitRate - params.targetHitRate) / params.toleranceHitRate, 2) +
|
|
302
432
|
(toleranceMet.maxReached ? 0 : 1000) +
|
|
303
|
-
(toleranceMet.rtpConcentration ? 0 : 1000)
|
|
433
|
+
(toleranceMet.rtpConcentration ? 0 : 1000) +
|
|
434
|
+
(toleranceMet.weightCap ? 0 : 1000);
|
|
304
435
|
if (!Number.isFinite(lossSum)) lossSum = Infinity;
|
|
305
436
|
|
|
306
437
|
if (!best || lossSum < best.lossSum) {
|
|
307
|
-
best = {
|
|
438
|
+
best = {
|
|
439
|
+
rows: outRows,
|
|
440
|
+
achieved,
|
|
441
|
+
toleranceMet,
|
|
442
|
+
maxRowShare,
|
|
443
|
+
maxWeightRatio,
|
|
444
|
+
lossSum,
|
|
445
|
+
capWarning,
|
|
446
|
+
};
|
|
308
447
|
}
|
|
309
448
|
|
|
310
449
|
if (
|
|
@@ -312,16 +451,22 @@ export function optimizeLookupTable(
|
|
|
312
451
|
toleranceMet.cv &&
|
|
313
452
|
toleranceMet.hitRate &&
|
|
314
453
|
toleranceMet.maxReached &&
|
|
315
|
-
toleranceMet.rtpConcentration
|
|
454
|
+
toleranceMet.rtpConcentration &&
|
|
455
|
+
toleranceMet.weightCap
|
|
316
456
|
) {
|
|
317
457
|
const iterWarnings = warnings.slice();
|
|
318
458
|
if (capWarning) iterWarnings.push(capWarning);
|
|
459
|
+
const successReport = computeStakeReport(outRows, achieved, betCostCents);
|
|
460
|
+
emitGapWarning(successReport, iterWarnings);
|
|
319
461
|
return {
|
|
320
462
|
rows: outRows,
|
|
321
463
|
achieved,
|
|
322
464
|
toleranceMet,
|
|
323
465
|
maxRowRtpShare: maxRowShare,
|
|
466
|
+
maxWeightRatio,
|
|
467
|
+
refinement: { rtpSwaps: 0, cvSwaps: 0, gapFillSwaps: 0, gapsUnfillable: 0, diversifySwaps: 0 },
|
|
324
468
|
warnings: iterWarnings,
|
|
469
|
+
stakeReport: successReport,
|
|
325
470
|
};
|
|
326
471
|
}
|
|
327
472
|
}
|
|
@@ -352,13 +497,23 @@ export function optimizeLookupTable(
|
|
|
352
497
|
`maxRowRtpShare exceeded: ${(best.maxRowShare * 100).toFixed(2)}% > ${(maxRowRtpShare * 100).toFixed(2)}%`,
|
|
353
498
|
);
|
|
354
499
|
}
|
|
500
|
+
if (!best.toleranceMet.weightCap) {
|
|
501
|
+
warnings.push(
|
|
502
|
+
`maxWeightPerRow exceeded: max weight ratio ${best.maxWeightRatio.toFixed(2)} > ${maxWeightPerRow} × uniform prior`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
355
505
|
if (best.capWarning) warnings.push(best.capWarning);
|
|
356
506
|
|
|
507
|
+
const bestReport = computeStakeReport(best.rows, best.achieved, betCostCents);
|
|
508
|
+
emitGapWarning(bestReport, warnings);
|
|
357
509
|
return {
|
|
358
510
|
rows: best.rows,
|
|
359
511
|
achieved: best.achieved,
|
|
360
512
|
toleranceMet: best.toleranceMet,
|
|
361
513
|
maxRowRtpShare: best.maxRowShare,
|
|
514
|
+
maxWeightRatio: best.maxWeightRatio,
|
|
515
|
+
refinement: { rtpSwaps: 0, cvSwaps: 0, gapFillSwaps: 0, gapsUnfillable: 0, diversifySwaps: 0 },
|
|
362
516
|
warnings,
|
|
517
|
+
stakeReport: bestReport,
|
|
363
518
|
};
|
|
364
519
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { LookupRow, OptimizeAchieved, StakeReport, TopKShare, HitRateBucket } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stake's hit-rate distribution table boundaries (payout multipliers).
|
|
5
|
+
* Mirrors the ranges shown in Stake Engine's publish UI under
|
|
6
|
+
* "Hit-Rate Ranges". Stake flags any intermediate empty range as a gap.
|
|
7
|
+
*
|
|
8
|
+
* Note: Stake displays the first range as `[0, 0.1)` (closed-open) — this
|
|
9
|
+
* captures zero-payout rows. All other ranges are `[low, high)` here for
|
|
10
|
+
* consistency; the last entry is `[20000, ∞)`.
|
|
11
|
+
*/
|
|
12
|
+
export const HIT_RATE_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
13
|
+
[0, 0.1],
|
|
14
|
+
[0.1, 1],
|
|
15
|
+
[1, 2],
|
|
16
|
+
[2, 5],
|
|
17
|
+
[5, 10],
|
|
18
|
+
[10, 20],
|
|
19
|
+
[20, 50],
|
|
20
|
+
[50, 100],
|
|
21
|
+
[100, 200],
|
|
22
|
+
[200, 500],
|
|
23
|
+
[500, 1000],
|
|
24
|
+
[1000, 2000],
|
|
25
|
+
[2000, 5000],
|
|
26
|
+
[5000, 10000],
|
|
27
|
+
[10000, 20000],
|
|
28
|
+
[20000, Infinity],
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compute the full Stake-compatible report from a finalized lookup table.
|
|
33
|
+
* Single source of truth for both tier-based and NNLS-based outputs.
|
|
34
|
+
*/
|
|
35
|
+
export function computeStakeReport(
|
|
36
|
+
outRows: ReadonlyArray<LookupRow>,
|
|
37
|
+
achieved: OptimizeAchieved,
|
|
38
|
+
betCostCents: number,
|
|
39
|
+
): StakeReport {
|
|
40
|
+
const threshold5K = 5000 * betCostCents;
|
|
41
|
+
const threshold10K = 10000 * betCostCents;
|
|
42
|
+
|
|
43
|
+
let w5K = 0n;
|
|
44
|
+
let w10K = 0n;
|
|
45
|
+
let wTotal = 0n;
|
|
46
|
+
const uniquePayouts = new Set<number>();
|
|
47
|
+
for (const r of outRows) {
|
|
48
|
+
const w = BigInt(r.weight);
|
|
49
|
+
wTotal += w;
|
|
50
|
+
if (r.payoutCents >= threshold5K) w5K += w;
|
|
51
|
+
if (r.payoutCents >= threshold10K) w10K += w;
|
|
52
|
+
uniquePayouts.add(r.payoutCents);
|
|
53
|
+
}
|
|
54
|
+
const prob5K = wTotal > 0n ? Number(w5K) / Number(wTotal) : 0;
|
|
55
|
+
const prob10K = wTotal > 0n ? Number(w10K) / Number(wTotal) : 0;
|
|
56
|
+
|
|
57
|
+
// Top-K cumulative RTP shares (by w·payout descending)
|
|
58
|
+
const wpEntries = outRows.map((r) => r.weight * r.payoutCents);
|
|
59
|
+
let totalWP = 0;
|
|
60
|
+
for (const v of wpEntries) totalWP += v;
|
|
61
|
+
const sortedWP = wpEntries.slice().sort((a, b) => b - a);
|
|
62
|
+
const topKShare: TopKShare[] = [];
|
|
63
|
+
const Ks = [1, 5, 10, 100];
|
|
64
|
+
let cum = 0;
|
|
65
|
+
let kIdx = 0;
|
|
66
|
+
for (let i = 0; i < sortedWP.length; i++) {
|
|
67
|
+
cum += sortedWP[i];
|
|
68
|
+
while (kIdx < Ks.length && i + 1 === Ks[kIdx]) {
|
|
69
|
+
topKShare.push({ k: Ks[kIdx], share: totalWP > 0 ? cum / totalWP : 0 });
|
|
70
|
+
kIdx++;
|
|
71
|
+
}
|
|
72
|
+
if (kIdx >= Ks.length) break;
|
|
73
|
+
}
|
|
74
|
+
while (kIdx < Ks.length) {
|
|
75
|
+
topKShare.push({ k: Ks[kIdx], share: totalWP > 0 ? cum / totalWP : 0 });
|
|
76
|
+
kIdx++;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Hit-rate distribution table.
|
|
80
|
+
// pm (payout multiplier) = payoutCents / betCostCents. Range [low, high).
|
|
81
|
+
const counts = new Array<number>(HIT_RATE_RANGES.length).fill(0);
|
|
82
|
+
const weights = new Array<bigint>(HIT_RATE_RANGES.length).fill(0n);
|
|
83
|
+
for (const r of outRows) {
|
|
84
|
+
const pm = r.payoutCents / betCostCents;
|
|
85
|
+
for (let i = 0; i < HIT_RATE_RANGES.length; i++) {
|
|
86
|
+
const [low, high] = HIT_RATE_RANGES[i];
|
|
87
|
+
if (pm >= low && pm < high) {
|
|
88
|
+
counts[i]++;
|
|
89
|
+
weights[i] += BigInt(r.weight);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const totalWeightNum = Number(wTotal);
|
|
95
|
+
const hitRateDistribution: HitRateBucket[] = HIT_RATE_RANGES.map(([low, high], i) => ({
|
|
96
|
+
low,
|
|
97
|
+
high,
|
|
98
|
+
count: counts[i],
|
|
99
|
+
effectiveHitRate: totalWeightNum > 0 ? Number(weights[i]) / totalWeightNum : 0,
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
payoutMultMax: achieved.maxPayout / betCostCents,
|
|
104
|
+
baseStd: (achieved.cv * achieved.rtp * 100) / betCostCents,
|
|
105
|
+
prob5K,
|
|
106
|
+
prob10K,
|
|
107
|
+
topKShare,
|
|
108
|
+
hitRateDistribution,
|
|
109
|
+
uniqueEvents: uniquePayouts.size,
|
|
110
|
+
betCostCents,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns the [low, high) ranges that are EMPTY but lie BETWEEN two non-empty
|
|
116
|
+
* ranges. These are the "intermediate gaps" Stake's "Gaps in the Hit Rate
|
|
117
|
+
* Table" check flags. Empty ranges above the highest non-empty range are
|
|
118
|
+
* natural (the source distribution doesn't reach that far) and are not gaps.
|
|
119
|
+
*/
|
|
120
|
+
export function detectHitRateGaps(
|
|
121
|
+
hitRateDistribution: ReadonlyArray<{ low: number; high: number; count: number }>,
|
|
122
|
+
): Array<{ low: number; high: number }> {
|
|
123
|
+
// Find the index of the last non-empty range.
|
|
124
|
+
let lastNonEmpty = -1;
|
|
125
|
+
for (let i = hitRateDistribution.length - 1; i >= 0; i--) {
|
|
126
|
+
if (hitRateDistribution[i].count > 0) {
|
|
127
|
+
lastNonEmpty = i;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (lastNonEmpty < 0) return [];
|
|
132
|
+
|
|
133
|
+
const gaps: Array<{ low: number; high: number }> = [];
|
|
134
|
+
let seenNonEmpty = false;
|
|
135
|
+
for (let i = 0; i <= lastNonEmpty; i++) {
|
|
136
|
+
const b = hitRateDistribution[i];
|
|
137
|
+
if (b.count > 0) {
|
|
138
|
+
seenNonEmpty = true;
|
|
139
|
+
} else if (seenNonEmpty) {
|
|
140
|
+
gaps.push({ low: b.low, high: b.high });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return gaps;
|
|
144
|
+
}
|
|
145
|
+
|