shufflecom-calculations 6.11.0 → 6.12.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.
Files changed (45) hide show
  1. package/lib/games/stonks/constants.d.ts +21 -0
  2. package/lib/games/stonks/constants.js +28 -0
  3. package/lib/games/stonks/constants.js.map +1 -0
  4. package/lib/games/stonks/rng/index.d.ts +2 -0
  5. package/lib/games/stonks/rng/index.js +8 -0
  6. package/lib/games/stonks/rng/index.js.map +1 -0
  7. package/lib/games/stonks/rng/shuffle-stonks-rng.d.ts +2 -0
  8. package/lib/games/{rollercoaster/rng/shuffle-rollercoaster-rng.js → stonks/rng/shuffle-stonks-rng.js} +5 -5
  9. package/lib/games/stonks/rng/shuffle-stonks-rng.js.map +1 -0
  10. package/lib/games/stonks/rng/stonks-price-calculator.d.ts +21 -0
  11. package/lib/games/{rollercoaster/rng/rollercoaster-price-calculator.js → stonks/rng/stonks-price-calculator.js} +12 -12
  12. package/lib/games/stonks/rng/stonks-price-calculator.js.map +1 -0
  13. package/lib/games/{rollercoaster/rollercoaster-rules.d.ts → stonks/stonks-rules.d.ts} +25 -25
  14. package/lib/games/stonks/stonks-rules.js +146 -0
  15. package/lib/games/stonks/stonks-rules.js.map +1 -0
  16. package/lib/index.d.ts +3 -3
  17. package/lib/index.js +3 -3
  18. package/lib/index.js.map +1 -1
  19. package/lib/tsconfig.tsbuildinfo +1 -1
  20. package/package.json +2 -2
  21. package/src/games/stonks/constants.ts +44 -0
  22. package/src/games/stonks/rng/index.ts +7 -0
  23. package/src/games/{rollercoaster/rng/shuffle-rollercoaster-rng.spec.ts → stonks/rng/shuffle-stonks-rng.spec.ts} +5 -5
  24. package/src/games/{rollercoaster/rng/shuffle-rollercoaster-rng.ts → stonks/rng/shuffle-stonks-rng.ts} +6 -6
  25. package/src/games/{rollercoaster/rng/rollercoaster-price-calculator.spec.ts → stonks/rng/stonks-price-calculator.spec.ts} +12 -12
  26. package/src/games/{rollercoaster/rng/rollercoaster-price-calculator.ts → stonks/rng/stonks-price-calculator.ts} +19 -19
  27. package/src/games/stonks/stonks-rules.spec.ts +348 -0
  28. package/src/games/stonks/stonks-rules.ts +217 -0
  29. package/src/index.ts +3 -3
  30. package/lib/games/rollercoaster/constants.d.ts +0 -21
  31. package/lib/games/rollercoaster/constants.js +0 -28
  32. package/lib/games/rollercoaster/constants.js.map +0 -1
  33. package/lib/games/rollercoaster/rng/index.d.ts +0 -2
  34. package/lib/games/rollercoaster/rng/index.js +0 -8
  35. package/lib/games/rollercoaster/rng/index.js.map +0 -1
  36. package/lib/games/rollercoaster/rng/rollercoaster-price-calculator.d.ts +0 -21
  37. package/lib/games/rollercoaster/rng/rollercoaster-price-calculator.js.map +0 -1
  38. package/lib/games/rollercoaster/rng/shuffle-rollercoaster-rng.d.ts +0 -2
  39. package/lib/games/rollercoaster/rng/shuffle-rollercoaster-rng.js.map +0 -1
  40. package/lib/games/rollercoaster/rollercoaster-rules.js +0 -146
  41. package/lib/games/rollercoaster/rollercoaster-rules.js.map +0 -1
  42. package/src/games/rollercoaster/constants.ts +0 -44
  43. package/src/games/rollercoaster/rng/index.ts +0 -7
  44. package/src/games/rollercoaster/rollercoaster-rules.spec.ts +0 -348
  45. package/src/games/rollercoaster/rollercoaster-rules.ts +0 -217
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shufflecom-calculations",
3
- "version": "6.11.0",
3
+ "version": "6.12.0",
4
4
  "description": "",
5
5
  "types": "lib/index.d.ts",
6
6
  "main": "lib/index.js",
@@ -19,5 +19,5 @@
19
19
  },
20
20
  "author": "",
21
21
  "license": "ISC",
22
- "gitHead": "7897f76b991a949e4196070e368a66cb6e93edb4"
22
+ "gitHead": "6656b3e0dc340fe986901432c7fdf076d838c304"
23
23
  }
@@ -0,0 +1,44 @@
1
+ import BigNumber from 'bignumber.js';
2
+
3
+ export const STONKS_MIN_TAKE_PROFIT_MULTIPLIER = BigNumber(1.05);
4
+
5
+ export const STONKS_TP_MIN_PROXIMITY_TO_ENTRY = BigNumber(0.001);
6
+ export const STONKS_TP_MIN_PROXIMITY_TO_BUST = BigNumber(0.001);
7
+
8
+ // Extra decimal precision carried internally, used only to detect bust price colliding with
9
+ // entry price at high leverage / low price.
10
+ export const STONKS_INTERNAL_PRICE_DECIMAL_PLACES = 10;
11
+
12
+ // configurable platform setting instead of a code constant.
13
+ export const STONKS_MIN_LEVERAGE = BigNumber(1);
14
+ export const STONKS_MAX_LEVERAGE = BigNumber(1000);
15
+
16
+ export const STONKS_MAX_ACTIVE_BETS_PER_ACCOUNT = 20;
17
+
18
+ export const STONKS_START_PRICE = BigNumber(1000);
19
+ export const STONKS_VOLATILITY = 0.001;
20
+
21
+ export const STONKS_PRICE_DECIMAL_PLACES = 10;
22
+
23
+ export const STONKS_TICK_DURATION_MS = 500;
24
+
25
+ export const STONKS_SET_TICK_RETRY_DELAY_MS = 400;
26
+
27
+ export const STONKS_MAX_STALE_CLIENT_TICKS = 10;
28
+
29
+ export const STONKS_ROUND_DURATION_MS = 24 * 60 * 60 * 1_000;
30
+ export const STONKS_ROUND_TICK_COUNT = STONKS_ROUND_DURATION_MS / STONKS_TICK_DURATION_MS;
31
+
32
+ // How far back stonksRecentTicks will page into history before forcing the client onto
33
+ // stonksOhlcHistory instead. PM-approved cap, not derived from any technical constraint.
34
+ export const STONKS_MAX_TICK_HISTORY_MS = 48 * 60 * 60 * 1_000;
35
+
36
+ // price is stored in cache every 500 ticks
37
+ export const STONKS_CHECKPOINT_INTERVAL_TICKS = 500;
38
+
39
+ export const STONKS_PRICE_BAR_INTERVAL_TICKS = 5_000 / STONKS_TICK_DURATION_MS;
40
+
41
+ export const STONKS_PROFIT_FEE_RATE = BigNumber(0.05);
42
+
43
+ // Keep well under 2^53: contracts cross into Redis Lua's tonumber(), which loses precision above that (unlike BigNumber here)
44
+ export const STONKS_CONTRACT_SCALE = BigNumber(10).pow(6);
@@ -0,0 +1,7 @@
1
+ export {
2
+ StonksPriceCalculator,
3
+ StonksRandomState,
4
+ StonksRngCheckpoint,
5
+ StonksRngFn,
6
+ } from './stonks-price-calculator';
7
+ export { shuffleStonksRng } from './shuffle-stonks-rng';
@@ -1,12 +1,12 @@
1
- import { RollercoasterPriceCalculator } from './rollercoaster-price-calculator';
2
- import { shuffleRollercoasterRng } from './shuffle-rollercoaster-rng';
1
+ import { StonksPriceCalculator } from './stonks-price-calculator';
2
+ import { shuffleStonksRng } from './shuffle-stonks-rng';
3
3
 
4
4
  const SERVER_SEED = '5d24abb38b513ffe8ff815e6d37b68f5e165b8dbb965f066ea92e246e2de5681';
5
5
  const BITCOIN_BLOCK_HASH = '90d543f6352a959285472a3b5a259513c38bb0baa5af3a28e59a759983da1cb2';
6
6
 
7
- describe('shuffleRollercoasterRng', () => {
8
- function buildCalculator(serverSeed = SERVER_SEED, bitcoinBlockHash = BITCOIN_BLOCK_HASH): RollercoasterPriceCalculator {
9
- return new RollercoasterPriceCalculator(shuffleRollercoasterRng(serverSeed, bitcoinBlockHash));
7
+ describe('shuffleStonksRng', () => {
8
+ function buildCalculator(serverSeed = SERVER_SEED, bitcoinBlockHash = BITCOIN_BLOCK_HASH): StonksPriceCalculator {
9
+ return new StonksPriceCalculator(shuffleStonksRng(serverSeed, bitcoinBlockHash));
10
10
  }
11
11
 
12
12
  it('matches the first 20 prices from the golden seed and Bitcoin block hash', () => {
@@ -1,8 +1,8 @@
1
1
  import { createHmac } from 'crypto';
2
2
  import BigNumber from 'bignumber.js';
3
- import { RollercoasterRandomState, RollercoasterRngCheckpoint, RollercoasterRngFn } from './rollercoaster-price-calculator';
3
+ import { StonksRandomState, StonksRngCheckpoint, StonksRngFn } from './stonks-price-calculator';
4
4
 
5
- export function shuffleRollercoasterRng(serverSeed: string, bitcoinBlockHash: string): RollercoasterRngFn {
5
+ export function shuffleStonksRng(serverSeed: string, bitcoinBlockHash: string): StonksRngFn {
6
6
  function initialDigest(): Buffer {
7
7
  return createHmac('sha256', serverSeed).update(bitcoinBlockHash).digest();
8
8
  }
@@ -11,17 +11,17 @@ export function shuffleRollercoasterRng(serverSeed: string, bitcoinBlockHash: st
11
11
  return createHmac('sha256', digest).update(bitcoinBlockHash).digest();
12
12
  }
13
13
 
14
- function digestFromCheckpoint(checkpoint: RollercoasterRngCheckpoint): Buffer {
14
+ function digestFromCheckpoint(checkpoint: StonksRngCheckpoint): Buffer {
15
15
  const digest = Buffer.from(checkpoint.digest, 'hex');
16
16
  if (digest.length !== 32) {
17
- throw new Error('shuffleRollercoasterRng: invalid checkpoint');
17
+ throw new Error('shuffleStonksRng: invalid checkpoint');
18
18
  }
19
19
  return digest;
20
20
  }
21
21
 
22
- return (startTickIndex: number, endTickIndex: number, checkpoint?: RollercoasterRngCheckpoint): RollercoasterRandomState[] => {
22
+ return (startTickIndex: number, endTickIndex: number, checkpoint?: StonksRngCheckpoint): StonksRandomState[] => {
23
23
  let digest = checkpoint ? digestFromCheckpoint(checkpoint) : initialDigest();
24
- const states: RollercoasterRandomState[] = [];
24
+ const states: StonksRandomState[] = [];
25
25
 
26
26
  for (let tick = startTickIndex; tick <= endTickIndex; tick++) {
27
27
  states.push({
@@ -1,8 +1,8 @@
1
1
  import BigNumber from 'bignumber.js';
2
- import { ROLLERCOASTER_VOLATILITY } from '../constants';
3
- import { RollercoasterPriceCalculator, RollercoasterRngFn } from './rollercoaster-price-calculator';
2
+ import { STONKS_VOLATILITY } from '../constants';
3
+ import { StonksPriceCalculator, StonksRngFn } from './stonks-price-calculator';
4
4
 
5
- function fixedRng(randomNumbers: BigNumber[]): RollercoasterRngFn {
5
+ function fixedRng(randomNumbers: BigNumber[]): StonksRngFn {
6
6
  return (startTickIndex, endTickIndex) =>
7
7
  randomNumbers.slice(startTickIndex, endTickIndex + 1).map((randomNumber, index) => ({
8
8
  tick: startTickIndex + index,
@@ -11,10 +11,10 @@ function fixedRng(randomNumbers: BigNumber[]): RollercoasterRngFn {
11
11
  }));
12
12
  }
13
13
 
14
- describe('RollercoasterPriceCalculator', () => {
14
+ describe('StonksPriceCalculator', () => {
15
15
  it('midpoint-normalizes uint32 boundary values so the quantile input is never zero or one', () => {
16
16
  const randomNumbers = [new BigNumber(0), new BigNumber(2 ** 32 - 1), new BigNumber(2 ** 31)];
17
- const prices = randomNumbers.map(randomNumber => new RollercoasterPriceCalculator(fixedRng([randomNumber, new BigNumber(0)])).getPriceAtTick(1));
17
+ const prices = randomNumbers.map(randomNumber => new StonksPriceCalculator(fixedRng([randomNumber, new BigNumber(0)])).getPriceAtTick(1));
18
18
 
19
19
  for (const price of prices) {
20
20
  expect(price.isFinite()).toBe(true);
@@ -26,21 +26,21 @@ describe('RollercoasterPriceCalculator', () => {
26
26
  });
27
27
 
28
28
  it.each([new BigNumber(-1), new BigNumber(2 ** 32), new BigNumber(0.5)])('rejects an invalid uint32 value: %s', randomNumber => {
29
- const calculator = new RollercoasterPriceCalculator(fixedRng([randomNumber]));
29
+ const calculator = new StonksPriceCalculator(fixedRng([randomNumber]));
30
30
 
31
- expect(() => calculator.getPriceAtTick(0)).toThrow(`RollercoasterPriceCalculator: invalid random number ${randomNumber}`);
31
+ expect(() => calculator.getPriceAtTick(0)).toThrow(`StonksPriceCalculator: invalid random number ${randomNumber}`);
32
32
  });
33
33
 
34
34
  it('produces the expected volatility and price-variation distribution', () => {
35
35
  const sampleCount = 16_384;
36
36
  const uint32Range = 2 ** 32;
37
37
  const randomNumbers = Array.from({ length: sampleCount }, (_, index) => new BigNumber(((index + 0.5) * uint32Range) / sampleCount));
38
- const ticks = new RollercoasterPriceCalculator(fixedRng([...randomNumbers, new BigNumber(0)])).getPricesInTickRange(0, sampleCount);
38
+ const ticks = new StonksPriceCalculator(fixedRng([...randomNumbers, new BigNumber(0)])).getPricesInTickRange(0, sampleCount);
39
39
  const priceVariations = ticks.slice(1).map((tick, index) => tick.price.dividedBy(ticks[index]!.price).minus(1).toNumber());
40
40
  const mean = priceVariations.reduce((sum, variation) => sum + variation, 0) / sampleCount;
41
41
  const standardDeviation = Math.sqrt(priceVariations.reduce((sum, variation) => sum + (variation - mean) ** 2, 0) / sampleCount);
42
- const withinOneStandardDeviation = priceVariations.filter(variation => Math.abs(variation) <= ROLLERCOASTER_VOLATILITY).length / sampleCount;
43
- const withinTwoStandardDeviations = priceVariations.filter(variation => Math.abs(variation) <= 2 * ROLLERCOASTER_VOLATILITY).length / sampleCount;
42
+ const withinOneStandardDeviation = priceVariations.filter(variation => Math.abs(variation) <= STONKS_VOLATILITY).length / sampleCount;
43
+ const withinTwoStandardDeviations = priceVariations.filter(variation => Math.abs(variation) <= 2 * STONKS_VOLATILITY).length / sampleCount;
44
44
 
45
45
  for (const tick of ticks) {
46
46
  expect(tick.price.isFinite()).toBe(true);
@@ -48,13 +48,13 @@ describe('RollercoasterPriceCalculator', () => {
48
48
  }
49
49
 
50
50
  expect(mean).toBeCloseTo(0, 8);
51
- expect(standardDeviation).toBeCloseTo(ROLLERCOASTER_VOLATILITY, 6);
51
+ expect(standardDeviation).toBeCloseTo(STONKS_VOLATILITY, 6);
52
52
  expect(withinOneStandardDeviation).toBeCloseTo(0.6827, 3);
53
53
  expect(withinTwoStandardDeviations).toBeCloseTo(0.9545, 3);
54
54
  });
55
55
 
56
56
  it('resumes from a checkpoint using its stored price', () => {
57
- const calculator = new RollercoasterPriceCalculator(fixedRng([new BigNumber(1), new BigNumber(2), new BigNumber(3), new BigNumber(4)]));
57
+ const calculator = new StonksPriceCalculator(fixedRng([new BigNumber(1), new BigNumber(2), new BigNumber(3), new BigNumber(4)]));
58
58
  const checkpoint = calculator.getPricesInTickRange(1, 1)[0]!;
59
59
 
60
60
  expect(calculator.getPricesInTickRange(2, 3, checkpoint)).toEqual(calculator.getPricesInTickRange(2, 3));
@@ -1,60 +1,60 @@
1
1
  import quantile from '@stdlib/stats-base-dists-normal-quantile';
2
2
  import BigNumber from 'bignumber.js';
3
- import { ROLLERCOASTER_PRICE_DECIMAL_PLACES, ROLLERCOASTER_START_PRICE, ROLLERCOASTER_VOLATILITY } from '../constants';
3
+ import { STONKS_PRICE_DECIMAL_PLACES, STONKS_START_PRICE, STONKS_VOLATILITY } from '../constants';
4
4
 
5
5
  const UINT32_RANGE = new BigNumber(2).pow(32);
6
6
  const MAX_UINT32 = UINT32_RANGE.minus(1);
7
7
 
8
- export type RollercoasterRngCheckpoint = {
8
+ export type StonksRngCheckpoint = {
9
9
  tick: number;
10
10
  price: BigNumber;
11
11
  digest: string;
12
12
  };
13
13
 
14
- export type RollercoasterRandomState = {
14
+ export type StonksRandomState = {
15
15
  tick: number;
16
16
  randomNumber: BigNumber;
17
17
  digest: string;
18
18
  };
19
19
 
20
- export type RollercoasterRngFn = (
20
+ export type StonksRngFn = (
21
21
  startTickIndex: number,
22
22
  endTickIndex: number,
23
- checkpoint?: RollercoasterRngCheckpoint,
24
- ) => RollercoasterRandomState[];
23
+ checkpoint?: StonksRngCheckpoint,
24
+ ) => StonksRandomState[];
25
25
 
26
- export class RollercoasterPriceCalculator {
27
- constructor(private readonly getRngInRange: RollercoasterRngFn) {}
26
+ export class StonksPriceCalculator {
27
+ constructor(private readonly getRngInRange: StonksRngFn) {}
28
28
 
29
- public getPriceAtTick(tickIndex: number, checkpoint?: RollercoasterRngCheckpoint): BigNumber {
29
+ public getPriceAtTick(tickIndex: number, checkpoint?: StonksRngCheckpoint): BigNumber {
30
30
  return this.getPricesInTickRange(tickIndex, tickIndex, checkpoint)[0]!.price;
31
31
  }
32
32
 
33
- public getPricesInTickRange(startTickIndex: number, endTickIndex: number, checkpoint?: RollercoasterRngCheckpoint): RollercoasterRngCheckpoint[] {
33
+ public getPricesInTickRange(startTickIndex: number, endTickIndex: number, checkpoint?: StonksRngCheckpoint): StonksRngCheckpoint[] {
34
34
  this.validateRange(startTickIndex, endTickIndex);
35
35
 
36
36
  const calculationStartTick = checkpoint?.tick ?? 0;
37
37
  if (!Number.isInteger(calculationStartTick) || calculationStartTick < 0 || calculationStartTick > startTickIndex) {
38
38
  throw new Error(
39
- `RollercoasterPriceCalculator: checkpoint at tick ${calculationStartTick} is after requested range ${startTickIndex}..${endTickIndex}`,
39
+ `StonksPriceCalculator: checkpoint at tick ${calculationStartTick} is after requested range ${startTickIndex}..${endTickIndex}`,
40
40
  );
41
41
  }
42
42
 
43
- let price = checkpoint?.price ?? ROLLERCOASTER_START_PRICE;
44
- const ticks: RollercoasterRngCheckpoint[] = [];
43
+ let price = checkpoint?.price ?? STONKS_START_PRICE;
44
+ const ticks: StonksRngCheckpoint[] = [];
45
45
  const randomStates = this.getRngInRange(calculationStartTick, endTickIndex, checkpoint);
46
46
  const expectedRandomStateCount = endTickIndex - calculationStartTick + 1;
47
47
 
48
48
  if (randomStates.length !== expectedRandomStateCount) {
49
49
  throw new Error(
50
- `RollercoasterPriceCalculator: expected ${expectedRandomStateCount} random states for ${calculationStartTick}..${endTickIndex}, got ${randomStates.length}`,
50
+ `StonksPriceCalculator: expected ${expectedRandomStateCount} random states for ${calculationStartTick}..${endTickIndex}, got ${randomStates.length}`,
51
51
  );
52
52
  }
53
53
 
54
54
  for (const [index, { tick, randomNumber, digest }] of randomStates.entries()) {
55
55
  const expectedTick = calculationStartTick + index;
56
56
  if (tick !== expectedTick) {
57
- throw new Error(`RollercoasterPriceCalculator: expected random state at tick ${expectedTick}, got ${tick}`);
57
+ throw new Error(`StonksPriceCalculator: expected random state at tick ${expectedTick}, got ${tick}`);
58
58
  }
59
59
 
60
60
  this.validateRandomNumber(randomNumber);
@@ -73,20 +73,20 @@ export class RollercoasterPriceCalculator {
73
73
 
74
74
  private calculateNextPrice(price: BigNumber, randomNumber: BigNumber): BigNumber {
75
75
  const unitRangeFloat = randomNumber.plus(0.5).dividedBy(UINT32_RANGE).toNumber();
76
- const tickReturn = quantile(unitRangeFloat, 0, ROLLERCOASTER_VOLATILITY);
76
+ const tickReturn = quantile(unitRangeFloat, 0, STONKS_VOLATILITY);
77
77
 
78
- return price.multipliedBy(BigNumber(1).plus(tickReturn)).decimalPlaces(ROLLERCOASTER_PRICE_DECIMAL_PLACES);
78
+ return price.multipliedBy(BigNumber(1).plus(tickReturn)).decimalPlaces(STONKS_PRICE_DECIMAL_PLACES);
79
79
  }
80
80
 
81
81
  private validateRandomNumber(randomNumber: BigNumber): void {
82
82
  if (!randomNumber.isInteger() || randomNumber.lt(0) || randomNumber.gt(MAX_UINT32)) {
83
- throw new Error(`RollercoasterPriceCalculator: invalid random number ${randomNumber}`);
83
+ throw new Error(`StonksPriceCalculator: invalid random number ${randomNumber}`);
84
84
  }
85
85
  }
86
86
 
87
87
  private validateRange(startTickIndex: number, endTickIndex: number): void {
88
88
  if (!Number.isInteger(startTickIndex) || !Number.isInteger(endTickIndex) || startTickIndex < 0 || endTickIndex < startTickIndex) {
89
- throw new Error(`RollercoasterPriceCalculator: invalid tick range ${startTickIndex}..${endTickIndex}`);
89
+ throw new Error(`StonksPriceCalculator: invalid tick range ${startTickIndex}..${endTickIndex}`);
90
90
  }
91
91
  }
92
92
  }
@@ -0,0 +1,348 @@
1
+ import BigNumber from 'bignumber.js';
2
+ import {
3
+ STONKS_MIN_TAKE_PROFIT_MULTIPLIER,
4
+ STONKS_ROUND_DURATION_MS,
5
+ STONKS_ROUND_TICK_COUNT,
6
+ STONKS_TICK_DURATION_MS,
7
+ } from './constants';
8
+ import { StonksBetTerminalReason, StonksDirection, StonksOpenBetState, StonksRules } from './stonks-rules';
9
+
10
+ const number = (value: BigNumber.Value) => new BigNumber(value);
11
+
12
+ function openBet(overrides: Partial<StonksOpenBetState> = {}): StonksOpenBetState {
13
+ const direction = overrides.direction ?? StonksDirection.UP;
14
+ const entryPrice = overrides.entryPrice ?? number(100);
15
+ const leverage = overrides.leverage ?? number(10);
16
+ return {
17
+ direction,
18
+ amount: number(100),
19
+ leverage,
20
+ entryPrice,
21
+ entryTick: 0,
22
+ bustPrice: StonksRules.bustPrice(entryPrice, leverage, direction),
23
+ takeProfitMultiplier: null,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe('StonksRules', () => {
29
+ describe('bustPrice', () => {
30
+ it('sits below entry for UP and above entry for DOWN, offset by entry / leverage', () => {
31
+ expect(StonksRules.bustPrice(number(100), number(10), StonksDirection.UP)).toBigNumberEqual(90);
32
+ expect(StonksRules.bustPrice(number(100), number(10), StonksDirection.DOWN)).toBigNumberEqual(110);
33
+ });
34
+ });
35
+
36
+ describe('position', () => {
37
+ it('is wager × leverage, negated for DOWN', () => {
38
+ expect(StonksRules.position(number(100), number(10), StonksDirection.UP)).toBigNumberEqual(1_000);
39
+ expect(StonksRules.position(number(100), number(10), StonksDirection.DOWN)).toBigNumberEqual(-1_000);
40
+ });
41
+ });
42
+
43
+ describe('impliedPriceForMultiplier', () => {
44
+ it('inverts multiplierAtPrice without depending on amount', () => {
45
+ const bet = openBet({ amount: number(0), leverage: number(10) });
46
+
47
+ const impliedPrice = StonksRules.impliedPriceForMultiplier(bet, number(1.5));
48
+
49
+ expect(StonksRules.multiplierAtPrice(bet, impliedPrice)).toBigNumberEqual(1.5);
50
+ });
51
+
52
+ it('moves in the opposite price direction for DOWN bets', () => {
53
+ const bet = openBet({ direction: StonksDirection.DOWN, leverage: number(10) });
54
+
55
+ expect(StonksRules.impliedPriceForMultiplier(bet, number(1.5))).toBigNumberEqual(95);
56
+ });
57
+ });
58
+
59
+ describe('isValidTakeProfitMultiplier', () => {
60
+ it('requires the multiplier to exceed the configured floor', () => {
61
+ expect(StonksRules.isValidTakeProfitMultiplier(STONKS_MIN_TAKE_PROFIT_MULTIPLIER)).toBe(false);
62
+ expect(StonksRules.isValidTakeProfitMultiplier(STONKS_MIN_TAKE_PROFIT_MULTIPLIER.plus('0.01'))).toBe(true);
63
+ });
64
+ });
65
+
66
+ describe('isTakeProfitFarEnoughFromEntry / isTakeProfitFarEnoughFromBust', () => {
67
+ it('rejects an implied price too close to entry or too close to bust', () => {
68
+ const entryPrice = number(100);
69
+ const bustPrice = number(90);
70
+
71
+ expect(StonksRules.isTakeProfitFarEnoughFromEntry(entryPrice, number('100.0001'))).toBe(false);
72
+ expect(StonksRules.isTakeProfitFarEnoughFromEntry(entryPrice, number(110))).toBe(true);
73
+
74
+ expect(StonksRules.isTakeProfitFarEnoughFromBust(entryPrice, bustPrice, number('90.0001'))).toBe(false);
75
+ expect(StonksRules.isTakeProfitFarEnoughFromBust(entryPrice, bustPrice, number(110))).toBe(true);
76
+ });
77
+ });
78
+
79
+ describe('isBustPriceDistinctFromEntry', () => {
80
+ it('is false only when bust collides with entry at the internal precision', () => {
81
+ expect(StonksRules.isBustPriceDistinctFromEntry(number(100), number(100))).toBe(false);
82
+ expect(StonksRules.isBustPriceDistinctFromEntry(number(100), number('99.99999999999'))).toBe(false);
83
+ expect(StonksRules.isBustPriceDistinctFromEntry(number(100), number(90))).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe('isFinalTick', () => {
88
+ it('is true only once the active tick reaches the last valid index', () => {
89
+ expect(StonksRules.isFinalTick(STONKS_ROUND_TICK_COUNT - 2)).toBe(false);
90
+ expect(StonksRules.isFinalTick(STONKS_ROUND_TICK_COUNT - 1)).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe('isClientTickWithinMaxLag', () => {
95
+ it('allows up to the max stale ticks and rejects beyond it', () => {
96
+ expect(StonksRules.isClientTickWithinMaxLag(100, 90)).toBe(true);
97
+ expect(StonksRules.isClientTickWithinMaxLag(100, 89)).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('getScheduledRoundId / getScheduledRoundStartTime', () => {
102
+ it('round-trips a scheduled instant back to the same round id', () => {
103
+ const epochMs = 0;
104
+ const roundId = 42;
105
+ const startTime = StonksRules.getScheduledRoundStartTime(roundId, epochMs);
106
+
107
+ expect(StonksRules.getScheduledRoundId(startTime, epochMs)).toBe(roundId);
108
+ expect(startTime.getTime()).toBe(roundId * STONKS_ROUND_DURATION_MS);
109
+ });
110
+ });
111
+
112
+ describe('tickAtOrAfter / tickBefore / tickStartTime', () => {
113
+ const startedAtMs = 1_000_000;
114
+
115
+ it('round-trips a tick through its start time', () => {
116
+ const startTime = StonksRules.tickStartTime(startedAtMs, 7);
117
+
118
+ expect(startTime.getTime()).toBe(startedAtMs + 7 * STONKS_TICK_DURATION_MS);
119
+ expect(StonksRules.tickAtOrAfter(startedAtMs, startTime.getTime())).toBe(7);
120
+ expect(StonksRules.tickBefore(startedAtMs, startTime.getTime())).toBe(6);
121
+ });
122
+
123
+ it('puts an exact tick boundary on the boundary tick, not the one before it', () => {
124
+ const boundaryMs = startedAtMs + 3 * STONKS_TICK_DURATION_MS;
125
+
126
+ expect(StonksRules.tickAtOrAfter(startedAtMs, boundaryMs)).toBe(3);
127
+ expect(StonksRules.tickBefore(startedAtMs, boundaryMs)).toBe(2);
128
+ });
129
+
130
+ it('maps an instant inside a tick window to that tick as the last tick started before it', () => {
131
+ const insideMs = startedAtMs + 3 * STONKS_TICK_DURATION_MS + 1;
132
+
133
+ expect(StonksRules.tickBefore(startedAtMs, insideMs)).toBe(3);
134
+ expect(StonksRules.tickAtOrAfter(startedAtMs, insideMs)).toBe(4);
135
+ });
136
+ });
137
+
138
+ describe('hasReachedTargetPrice', () => {
139
+ it('is inclusive of an exact match, in the direction of profit', () => {
140
+ expect(StonksRules.hasReachedTargetPrice(StonksDirection.UP, number(110), number(110))).toBe(true);
141
+ expect(StonksRules.hasReachedTargetPrice(StonksDirection.UP, number(109), number(110))).toBe(false);
142
+ expect(StonksRules.hasReachedTargetPrice(StonksDirection.DOWN, number(90), number(90))).toBe(true);
143
+ expect(StonksRules.hasReachedTargetPrice(StonksDirection.DOWN, number(91), number(90))).toBe(false);
144
+ });
145
+ });
146
+
147
+ describe('hasReachedBust', () => {
148
+ it('is inclusive of an exact match, in the direction of loss', () => {
149
+ expect(StonksRules.hasReachedBust(StonksDirection.UP, number(90), number(90))).toBe(true);
150
+ expect(StonksRules.hasReachedBust(StonksDirection.UP, number(91), number(90))).toBe(false);
151
+ expect(StonksRules.hasReachedBust(StonksDirection.DOWN, number(110), number(110))).toBe(true);
152
+ expect(StonksRules.hasReachedBust(StonksDirection.DOWN, number(109), number(110))).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe('multiplierAtPrice', () => {
157
+ it('is 1 at entry and floors at zero once loss would exceed the wager', () => {
158
+ const bet = openBet({ entryPrice: number(100), leverage: number(10), direction: StonksDirection.UP });
159
+
160
+ expect(StonksRules.multiplierAtPrice(bet, number(100))).toBigNumberEqual(1);
161
+ expect(StonksRules.multiplierAtPrice(bet, number(80))).toBigNumberEqual(0);
162
+ });
163
+ });
164
+
165
+ describe('grossPayoutAtPrice', () => {
166
+ it('scales pnl by leverage and floors at zero, matching direction', () => {
167
+ const bet = openBet({ direction: StonksDirection.UP, leverage: number(10) });
168
+
169
+ expect(StonksRules.grossPayoutAtPrice(bet, number(110))).toBigNumberEqual(200);
170
+ expect(StonksRules.grossPayoutAtPrice(bet, number(80))).toBigNumberEqual(0);
171
+ });
172
+ });
173
+
174
+ describe('evaluateTriggers', () => {
175
+ it('does not fire when price stays between bust and the caps', () => {
176
+ const bet = openBet();
177
+
178
+ expect(StonksRules.evaluateTriggers(bet, number(100), number(1_000))).toEqual({ fired: false });
179
+ });
180
+
181
+ it('fires LIQUIDATION once price reaches bust', () => {
182
+ const bet = openBet();
183
+
184
+ expect(StonksRules.evaluateTriggers(bet, bet.bustPrice, number(1_000))).toEqual({
185
+ fired: true,
186
+ reason: { kind: StonksBetTerminalReason.LIQUIDATION },
187
+ });
188
+ });
189
+
190
+ it('fires TAKE_PROFIT once price reaches the TP-implied price', () => {
191
+ const takeProfitMultiplier = number('1.5');
192
+ const bet = openBet({ takeProfitMultiplier });
193
+ const impliedPrice = StonksRules.impliedPriceForMultiplier(bet, takeProfitMultiplier);
194
+
195
+ expect(StonksRules.evaluateTriggers(bet, impliedPrice, number(1_000))).toEqual({
196
+ fired: true,
197
+ reason: { kind: StonksBetTerminalReason.TAKE_PROFIT, takeProfitMultiplier },
198
+ });
199
+ });
200
+
201
+ it('fires MAX_PAYOUT once the gross payout reaches the live cap, even overshooting it', () => {
202
+ const bet = openBet({ leverage: number(100) });
203
+ const cap = number(150);
204
+
205
+ // At price 101 the gross payout (200) overshoots the cap (150) — MAX_PAYOUT still fires,
206
+ // and the caller settles at this real tick price regardless, not a price back-solved from the cap.
207
+ expect(StonksRules.evaluateTriggers(bet, number(101), cap)).toEqual({
208
+ fired: true,
209
+ reason: { kind: StonksBetTerminalReason.MAX_PAYOUT, appliedCap: cap },
210
+ });
211
+ });
212
+
213
+ // A take-profit is the one reason whose payout is a fixed wager × multiplier rather than
214
+ // something derived from the tick price, so it is also the one reason that can promise more
215
+ // than the live cap allows. The cap is read fresh every batch, so it can be lowered under an
216
+ // already-agreed take-profit; nothing downstream re-checks it, making this the only place the
217
+ // ceiling can be enforced.
218
+ it('fires MAX_PAYOUT instead of TAKE_PROFIT when the agreed take-profit payout exceeds the live cap', () => {
219
+ const takeProfitMultiplier = number(3);
220
+ const bet = openBet({ takeProfitMultiplier });
221
+ const cap = number(250);
222
+ // Wager 100 × 3 = 300, above the cap of 250.
223
+ const impliedPrice = StonksRules.impliedPriceForMultiplier(bet, takeProfitMultiplier);
224
+
225
+ const reason = { kind: StonksBetTerminalReason.MAX_PAYOUT, appliedCap: cap } as const;
226
+
227
+ expect(StonksRules.evaluateTriggers(bet, impliedPrice, cap)).toEqual({ fired: true, reason });
228
+ expect(StonksRules.grossPayoutForReason(bet, reason, impliedPrice)).toBigNumberEqual(250);
229
+ });
230
+
231
+ // The mirror of the case above, and the reason the cap cannot simply be checked before the
232
+ // take-profit: MAX_PAYOUT pays exactly the cap, so firing it on a take-profit that sits under
233
+ // the cap would pay MORE than the multiplier the player agreed to, not less.
234
+ it('still fires TAKE_PROFIT when the tick price overshoots the cap but the agreed payout does not', () => {
235
+ const takeProfitMultiplier = number(2);
236
+ const bet = openBet({ takeProfitMultiplier });
237
+ const cap = number(250);
238
+ // One tick jumps to 130, well past the TP-implied 110: the price-derived payout there is 400
239
+ // and clears the cap, but the agreed take-profit payout is only 100 × 2 = 200, under it.
240
+ const reason = { kind: StonksBetTerminalReason.TAKE_PROFIT, takeProfitMultiplier } as const;
241
+
242
+ expect(StonksRules.evaluateTriggers(bet, number(130), cap)).toEqual({ fired: true, reason });
243
+ expect(StonksRules.grossPayoutForReason(bet, reason, number(130))).toBigNumberEqual(200);
244
+ });
245
+
246
+ it('fires TAKE_PROFIT, not MAX_PAYOUT, when the agreed take-profit payout exactly equals the live cap', () => {
247
+ const takeProfitMultiplier = number('1.1');
248
+ const bet = openBet({ takeProfitMultiplier });
249
+ const cap = bet.amount.multipliedBy(takeProfitMultiplier);
250
+ const impliedPrice = StonksRules.impliedPriceForMultiplier(bet, takeProfitMultiplier);
251
+
252
+ expect(StonksRules.evaluateTriggers(bet, impliedPrice, cap)).toEqual({
253
+ fired: true,
254
+ reason: { kind: StonksBetTerminalReason.TAKE_PROFIT, takeProfitMultiplier },
255
+ });
256
+ });
257
+
258
+ it('throws if take profit and liquidation would fire on the same tick', () => {
259
+ // A degenerate takeProfitMultiplier of 0 pushes the TP-implied price down to coincide
260
+ // exactly with bust — evaluateTriggers doesn't itself validate the multiplier, so this
261
+ // "unreachable" collision is only actually prevented by isValidTakeProfitMultiplier upstream.
262
+ const bet = openBet({ takeProfitMultiplier: number(0) });
263
+
264
+ expect(() => StonksRules.evaluateTriggers(bet, bet.bustPrice, number(1_000))).toThrow(
265
+ 'StonksRules.evaluateTriggers: take profit and liquidation both fired',
266
+ );
267
+ });
268
+ });
269
+
270
+ describe('grossPayoutForReason', () => {
271
+ const bet = openBet({ leverage: number(10) });
272
+
273
+ it('TAKE_PROFIT credits wager × multiplier exactly, ignoring price', () => {
274
+ const takeProfitMultiplier = number('1.5');
275
+
276
+ expect(
277
+ StonksRules.grossPayoutForReason(bet, { kind: StonksBetTerminalReason.TAKE_PROFIT, takeProfitMultiplier }, number(999)),
278
+ ).toBigNumberEqual(150);
279
+ });
280
+
281
+ it('LIQUIDATION is exact zero regardless of price', () => {
282
+ expect(StonksRules.grossPayoutForReason(bet, { kind: StonksBetTerminalReason.LIQUIDATION }, number(999))).toBigNumberEqual(0);
283
+ });
284
+
285
+ it('MAX_PAYOUT is exactly the applied cap, ignoring price', () => {
286
+ const appliedCap = number(150);
287
+
288
+ expect(
289
+ StonksRules.grossPayoutForReason(bet, { kind: StonksBetTerminalReason.MAX_PAYOUT, appliedCap }, number(999)),
290
+ ).toBigNumberEqual(150);
291
+ });
292
+
293
+ it('CASHOUT and ROUND_END fall back to the price-derived gross payout', () => {
294
+ const priceDerivedPayout = StonksRules.grossPayoutAtPrice(bet, number(110));
295
+
296
+ expect(StonksRules.grossPayoutForReason(bet, { kind: StonksBetTerminalReason.CASHOUT }, number(110))).toBigNumberEqual(
297
+ priceDerivedPayout,
298
+ );
299
+ expect(StonksRules.grossPayoutForReason(bet, { kind: StonksBetTerminalReason.ROUND_END }, number(110))).toBigNumberEqual(
300
+ priceDerivedPayout,
301
+ );
302
+ });
303
+ });
304
+
305
+ describe('multiplierForReason', () => {
306
+ it('TAKE_PROFIT returns the agreed multiplier, ignoring price', () => {
307
+ const bet = openBet();
308
+ const takeProfitMultiplier = number('1.5');
309
+
310
+ expect(
311
+ StonksRules.multiplierForReason(bet, { kind: StonksBetTerminalReason.TAKE_PROFIT, takeProfitMultiplier }, number(999)),
312
+ ).toBigNumberEqual(takeProfitMultiplier);
313
+ });
314
+
315
+ it.each([
316
+ [StonksDirection.UP, number(101)],
317
+ [StonksDirection.DOWN, number(99)],
318
+ ])('every other reason falls back to the price-derived multiplier, independently of wager (%s)', (direction, price) => {
319
+ const bet = openBet({ direction, amount: number(0) });
320
+
321
+ expect(StonksRules.multiplierForReason(bet, { kind: StonksBetTerminalReason.CASHOUT }, price)).toBigNumberEqual(1.1);
322
+ expect(StonksRules.multiplierForReason(bet, { kind: StonksBetTerminalReason.LIQUIDATION }, price)).toBigNumberEqual(1.1);
323
+ });
324
+ });
325
+
326
+ describe('netPayout', () => {
327
+ const wager = number(100);
328
+
329
+ it('passes losses and breakeven payouts through untaxed', () => {
330
+ expect(StonksRules.netPayout(number(90), wager)).toBigNumberEqual(90);
331
+ expect(StonksRules.netPayout(number(0), wager)).toBigNumberEqual(0);
332
+ expect(StonksRules.netPayout(wager, wager)).toBigNumberEqual(wager);
333
+ });
334
+
335
+ it('taxes only the profit portion at 5%, never the wager', () => {
336
+ // gross 101 = wager + 1 profit; only the 1 is taxed, so net = 100 + 0.95 = 100.95
337
+ expect(StonksRules.netPayout(number(101), wager)).toBigNumberEqual('100.95');
338
+ // gross 200 = wager + 100 profit; net = 100 + 95 = 195
339
+ expect(StonksRules.netPayout(number(200), wager)).toBigNumberEqual(195);
340
+ });
341
+
342
+ it('never drops a nominal win below the wager, however small the gross profit is', () => {
343
+ const net = StonksRules.netPayout(number('100.01'), wager);
344
+
345
+ expect(net).toBigNumberGreaterThan(wager);
346
+ });
347
+ });
348
+ });