shufflecom-calculations 6.10.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.
- package/lib/games/stonks/constants.d.ts +21 -0
- package/lib/games/stonks/constants.js +28 -0
- package/lib/games/stonks/constants.js.map +1 -0
- package/lib/games/stonks/rng/index.d.ts +2 -0
- package/lib/games/stonks/rng/index.js +8 -0
- package/lib/games/stonks/rng/index.js.map +1 -0
- package/lib/games/stonks/rng/shuffle-stonks-rng.d.ts +2 -0
- package/lib/games/{rollercoaster/rng/shuffle-rollercoaster-rng.js → stonks/rng/shuffle-stonks-rng.js} +5 -5
- package/lib/games/stonks/rng/shuffle-stonks-rng.js.map +1 -0
- package/lib/games/stonks/rng/stonks-price-calculator.d.ts +21 -0
- package/lib/games/{rollercoaster/rng/rollercoaster-price-calculator.js → stonks/rng/stonks-price-calculator.js} +12 -12
- package/lib/games/stonks/rng/stonks-price-calculator.js.map +1 -0
- package/lib/games/{rollercoaster/rollercoaster-rules.d.ts → stonks/stonks-rules.d.ts} +25 -25
- package/lib/games/stonks/stonks-rules.js +146 -0
- package/lib/games/stonks/stonks-rules.js.map +1 -0
- package/lib/index.d.ts +3 -3
- package/lib/index.js +3 -3
- package/lib/index.js.map +1 -1
- package/lib/sports/sports-bet.types.d.ts +2 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/games/stonks/constants.ts +44 -0
- package/src/games/stonks/rng/index.ts +7 -0
- package/src/games/{rollercoaster/rng/shuffle-rollercoaster-rng.spec.ts → stonks/rng/shuffle-stonks-rng.spec.ts} +5 -5
- package/src/games/{rollercoaster/rng/shuffle-rollercoaster-rng.ts → stonks/rng/shuffle-stonks-rng.ts} +6 -6
- package/src/games/{rollercoaster/rng/rollercoaster-price-calculator.spec.ts → stonks/rng/stonks-price-calculator.spec.ts} +12 -12
- package/src/games/{rollercoaster/rng/rollercoaster-price-calculator.ts → stonks/rng/stonks-price-calculator.ts} +19 -19
- package/src/games/stonks/stonks-rules.spec.ts +348 -0
- package/src/games/stonks/stonks-rules.ts +217 -0
- package/src/index.ts +3 -3
- package/src/sports/sports-bet.types.ts +2 -0
- package/lib/games/rollercoaster/constants.d.ts +0 -21
- package/lib/games/rollercoaster/constants.js +0 -28
- package/lib/games/rollercoaster/constants.js.map +0 -1
- package/lib/games/rollercoaster/rng/index.d.ts +0 -2
- package/lib/games/rollercoaster/rng/index.js +0 -8
- package/lib/games/rollercoaster/rng/index.js.map +0 -1
- package/lib/games/rollercoaster/rng/rollercoaster-price-calculator.d.ts +0 -21
- package/lib/games/rollercoaster/rng/rollercoaster-price-calculator.js.map +0 -1
- package/lib/games/rollercoaster/rng/shuffle-rollercoaster-rng.d.ts +0 -2
- package/lib/games/rollercoaster/rng/shuffle-rollercoaster-rng.js.map +0 -1
- package/lib/games/rollercoaster/rollercoaster-rules.js +0 -146
- package/lib/games/rollercoaster/rollercoaster-rules.js.map +0 -1
- package/src/games/rollercoaster/constants.ts +0 -44
- package/src/games/rollercoaster/rng/index.ts +0 -7
- package/src/games/rollercoaster/rollercoaster-rules.spec.ts +0 -348
- package/src/games/rollercoaster/rollercoaster-rules.ts +0 -217
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
import BigNumber from 'bignumber.js';
|
|
2
|
-
import {
|
|
3
|
-
ROLLERCOASTER_MIN_TAKE_PROFIT_MULTIPLIER,
|
|
4
|
-
ROLLERCOASTER_ROUND_DURATION_MS,
|
|
5
|
-
ROLLERCOASTER_ROUND_TICK_COUNT,
|
|
6
|
-
ROLLERCOASTER_TICK_DURATION_MS,
|
|
7
|
-
} from './constants';
|
|
8
|
-
import { RollercoasterBetTerminalReason, RollercoasterDirection, RollercoasterOpenBetState, RollercoasterRules } from './rollercoaster-rules';
|
|
9
|
-
|
|
10
|
-
const number = (value: BigNumber.Value) => new BigNumber(value);
|
|
11
|
-
|
|
12
|
-
function openBet(overrides: Partial<RollercoasterOpenBetState> = {}): RollercoasterOpenBetState {
|
|
13
|
-
const direction = overrides.direction ?? RollercoasterDirection.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: RollercoasterRules.bustPrice(entryPrice, leverage, direction),
|
|
23
|
-
takeProfitMultiplier: null,
|
|
24
|
-
...overrides,
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe('RollercoasterRules', () => {
|
|
29
|
-
describe('bustPrice', () => {
|
|
30
|
-
it('sits below entry for UP and above entry for DOWN, offset by entry / leverage', () => {
|
|
31
|
-
expect(RollercoasterRules.bustPrice(number(100), number(10), RollercoasterDirection.UP)).toBigNumberEqual(90);
|
|
32
|
-
expect(RollercoasterRules.bustPrice(number(100), number(10), RollercoasterDirection.DOWN)).toBigNumberEqual(110);
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('position', () => {
|
|
37
|
-
it('is wager × leverage, negated for DOWN', () => {
|
|
38
|
-
expect(RollercoasterRules.position(number(100), number(10), RollercoasterDirection.UP)).toBigNumberEqual(1_000);
|
|
39
|
-
expect(RollercoasterRules.position(number(100), number(10), RollercoasterDirection.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 = RollercoasterRules.impliedPriceForMultiplier(bet, number(1.5));
|
|
48
|
-
|
|
49
|
-
expect(RollercoasterRules.multiplierAtPrice(bet, impliedPrice)).toBigNumberEqual(1.5);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('moves in the opposite price direction for DOWN bets', () => {
|
|
53
|
-
const bet = openBet({ direction: RollercoasterDirection.DOWN, leverage: number(10) });
|
|
54
|
-
|
|
55
|
-
expect(RollercoasterRules.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(RollercoasterRules.isValidTakeProfitMultiplier(ROLLERCOASTER_MIN_TAKE_PROFIT_MULTIPLIER)).toBe(false);
|
|
62
|
-
expect(RollercoasterRules.isValidTakeProfitMultiplier(ROLLERCOASTER_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(RollercoasterRules.isTakeProfitFarEnoughFromEntry(entryPrice, number('100.0001'))).toBe(false);
|
|
72
|
-
expect(RollercoasterRules.isTakeProfitFarEnoughFromEntry(entryPrice, number(110))).toBe(true);
|
|
73
|
-
|
|
74
|
-
expect(RollercoasterRules.isTakeProfitFarEnoughFromBust(entryPrice, bustPrice, number('90.0001'))).toBe(false);
|
|
75
|
-
expect(RollercoasterRules.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(RollercoasterRules.isBustPriceDistinctFromEntry(number(100), number(100))).toBe(false);
|
|
82
|
-
expect(RollercoasterRules.isBustPriceDistinctFromEntry(number(100), number('99.99999999999'))).toBe(false);
|
|
83
|
-
expect(RollercoasterRules.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(RollercoasterRules.isFinalTick(ROLLERCOASTER_ROUND_TICK_COUNT - 2)).toBe(false);
|
|
90
|
-
expect(RollercoasterRules.isFinalTick(ROLLERCOASTER_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(RollercoasterRules.isClientTickWithinMaxLag(100, 90)).toBe(true);
|
|
97
|
-
expect(RollercoasterRules.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 = RollercoasterRules.getScheduledRoundStartTime(roundId, epochMs);
|
|
106
|
-
|
|
107
|
-
expect(RollercoasterRules.getScheduledRoundId(startTime, epochMs)).toBe(roundId);
|
|
108
|
-
expect(startTime.getTime()).toBe(roundId * ROLLERCOASTER_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 = RollercoasterRules.tickStartTime(startedAtMs, 7);
|
|
117
|
-
|
|
118
|
-
expect(startTime.getTime()).toBe(startedAtMs + 7 * ROLLERCOASTER_TICK_DURATION_MS);
|
|
119
|
-
expect(RollercoasterRules.tickAtOrAfter(startedAtMs, startTime.getTime())).toBe(7);
|
|
120
|
-
expect(RollercoasterRules.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 * ROLLERCOASTER_TICK_DURATION_MS;
|
|
125
|
-
|
|
126
|
-
expect(RollercoasterRules.tickAtOrAfter(startedAtMs, boundaryMs)).toBe(3);
|
|
127
|
-
expect(RollercoasterRules.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 * ROLLERCOASTER_TICK_DURATION_MS + 1;
|
|
132
|
-
|
|
133
|
-
expect(RollercoasterRules.tickBefore(startedAtMs, insideMs)).toBe(3);
|
|
134
|
-
expect(RollercoasterRules.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(RollercoasterRules.hasReachedTargetPrice(RollercoasterDirection.UP, number(110), number(110))).toBe(true);
|
|
141
|
-
expect(RollercoasterRules.hasReachedTargetPrice(RollercoasterDirection.UP, number(109), number(110))).toBe(false);
|
|
142
|
-
expect(RollercoasterRules.hasReachedTargetPrice(RollercoasterDirection.DOWN, number(90), number(90))).toBe(true);
|
|
143
|
-
expect(RollercoasterRules.hasReachedTargetPrice(RollercoasterDirection.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(RollercoasterRules.hasReachedBust(RollercoasterDirection.UP, number(90), number(90))).toBe(true);
|
|
150
|
-
expect(RollercoasterRules.hasReachedBust(RollercoasterDirection.UP, number(91), number(90))).toBe(false);
|
|
151
|
-
expect(RollercoasterRules.hasReachedBust(RollercoasterDirection.DOWN, number(110), number(110))).toBe(true);
|
|
152
|
-
expect(RollercoasterRules.hasReachedBust(RollercoasterDirection.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: RollercoasterDirection.UP });
|
|
159
|
-
|
|
160
|
-
expect(RollercoasterRules.multiplierAtPrice(bet, number(100))).toBigNumberEqual(1);
|
|
161
|
-
expect(RollercoasterRules.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: RollercoasterDirection.UP, leverage: number(10) });
|
|
168
|
-
|
|
169
|
-
expect(RollercoasterRules.grossPayoutAtPrice(bet, number(110))).toBigNumberEqual(200);
|
|
170
|
-
expect(RollercoasterRules.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(RollercoasterRules.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(RollercoasterRules.evaluateTriggers(bet, bet.bustPrice, number(1_000))).toEqual({
|
|
185
|
-
fired: true,
|
|
186
|
-
reason: { kind: RollercoasterBetTerminalReason.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 = RollercoasterRules.impliedPriceForMultiplier(bet, takeProfitMultiplier);
|
|
194
|
-
|
|
195
|
-
expect(RollercoasterRules.evaluateTriggers(bet, impliedPrice, number(1_000))).toEqual({
|
|
196
|
-
fired: true,
|
|
197
|
-
reason: { kind: RollercoasterBetTerminalReason.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(RollercoasterRules.evaluateTriggers(bet, number(101), cap)).toEqual({
|
|
208
|
-
fired: true,
|
|
209
|
-
reason: { kind: RollercoasterBetTerminalReason.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 = RollercoasterRules.impliedPriceForMultiplier(bet, takeProfitMultiplier);
|
|
224
|
-
|
|
225
|
-
const reason = { kind: RollercoasterBetTerminalReason.MAX_PAYOUT, appliedCap: cap } as const;
|
|
226
|
-
|
|
227
|
-
expect(RollercoasterRules.evaluateTriggers(bet, impliedPrice, cap)).toEqual({ fired: true, reason });
|
|
228
|
-
expect(RollercoasterRules.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: RollercoasterBetTerminalReason.TAKE_PROFIT, takeProfitMultiplier } as const;
|
|
241
|
-
|
|
242
|
-
expect(RollercoasterRules.evaluateTriggers(bet, number(130), cap)).toEqual({ fired: true, reason });
|
|
243
|
-
expect(RollercoasterRules.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 = RollercoasterRules.impliedPriceForMultiplier(bet, takeProfitMultiplier);
|
|
251
|
-
|
|
252
|
-
expect(RollercoasterRules.evaluateTriggers(bet, impliedPrice, cap)).toEqual({
|
|
253
|
-
fired: true,
|
|
254
|
-
reason: { kind: RollercoasterBetTerminalReason.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(() => RollercoasterRules.evaluateTriggers(bet, bet.bustPrice, number(1_000))).toThrow(
|
|
265
|
-
'RollercoasterRules.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
|
-
RollercoasterRules.grossPayoutForReason(bet, { kind: RollercoasterBetTerminalReason.TAKE_PROFIT, takeProfitMultiplier }, number(999)),
|
|
278
|
-
).toBigNumberEqual(150);
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it('LIQUIDATION is exact zero regardless of price', () => {
|
|
282
|
-
expect(RollercoasterRules.grossPayoutForReason(bet, { kind: RollercoasterBetTerminalReason.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
|
-
RollercoasterRules.grossPayoutForReason(bet, { kind: RollercoasterBetTerminalReason.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 = RollercoasterRules.grossPayoutAtPrice(bet, number(110));
|
|
295
|
-
|
|
296
|
-
expect(RollercoasterRules.grossPayoutForReason(bet, { kind: RollercoasterBetTerminalReason.CASHOUT }, number(110))).toBigNumberEqual(
|
|
297
|
-
priceDerivedPayout,
|
|
298
|
-
);
|
|
299
|
-
expect(RollercoasterRules.grossPayoutForReason(bet, { kind: RollercoasterBetTerminalReason.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
|
-
RollercoasterRules.multiplierForReason(bet, { kind: RollercoasterBetTerminalReason.TAKE_PROFIT, takeProfitMultiplier }, number(999)),
|
|
312
|
-
).toBigNumberEqual(takeProfitMultiplier);
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it.each([
|
|
316
|
-
[RollercoasterDirection.UP, number(101)],
|
|
317
|
-
[RollercoasterDirection.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(RollercoasterRules.multiplierForReason(bet, { kind: RollercoasterBetTerminalReason.CASHOUT }, price)).toBigNumberEqual(1.1);
|
|
322
|
-
expect(RollercoasterRules.multiplierForReason(bet, { kind: RollercoasterBetTerminalReason.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(RollercoasterRules.netPayout(number(90), wager)).toBigNumberEqual(90);
|
|
331
|
-
expect(RollercoasterRules.netPayout(number(0), wager)).toBigNumberEqual(0);
|
|
332
|
-
expect(RollercoasterRules.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(RollercoasterRules.netPayout(number(101), wager)).toBigNumberEqual('100.95');
|
|
338
|
-
// gross 200 = wager + 100 profit; net = 100 + 95 = 195
|
|
339
|
-
expect(RollercoasterRules.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 = RollercoasterRules.netPayout(number('100.01'), wager);
|
|
344
|
-
|
|
345
|
-
expect(net).toBigNumberGreaterThan(wager);
|
|
346
|
-
});
|
|
347
|
-
});
|
|
348
|
-
});
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
import BigNumber from 'bignumber.js';
|
|
2
|
-
import { calculatePayoutBN } from '../../utils/calculate-payout';
|
|
3
|
-
import {
|
|
4
|
-
ROLLERCOASTER_INTERNAL_PRICE_DECIMAL_PLACES,
|
|
5
|
-
ROLLERCOASTER_MAX_STALE_CLIENT_TICKS,
|
|
6
|
-
ROLLERCOASTER_MIN_TAKE_PROFIT_MULTIPLIER,
|
|
7
|
-
ROLLERCOASTER_PROFIT_FEE_RATE,
|
|
8
|
-
ROLLERCOASTER_ROUND_DURATION_MS,
|
|
9
|
-
ROLLERCOASTER_ROUND_TICK_COUNT,
|
|
10
|
-
ROLLERCOASTER_TICK_DURATION_MS,
|
|
11
|
-
ROLLERCOASTER_TP_MIN_PROXIMITY_TO_BUST,
|
|
12
|
-
ROLLERCOASTER_TP_MIN_PROXIMITY_TO_ENTRY,
|
|
13
|
-
} from './constants';
|
|
14
|
-
|
|
15
|
-
export enum RollercoasterDirection {
|
|
16
|
-
UP = 'UP',
|
|
17
|
-
DOWN = 'DOWN',
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export enum RollercoasterBetTerminalReason {
|
|
21
|
-
CASHOUT = 'CASHOUT',
|
|
22
|
-
TAKE_PROFIT = 'TAKE_PROFIT',
|
|
23
|
-
LIQUIDATION = 'LIQUIDATION',
|
|
24
|
-
MAX_PAYOUT = 'MAX_PAYOUT',
|
|
25
|
-
ROUND_END = 'ROUND_END',
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface RollercoasterOpenBetState {
|
|
29
|
-
readonly direction: RollercoasterDirection;
|
|
30
|
-
readonly amount: BigNumber;
|
|
31
|
-
readonly leverage: BigNumber;
|
|
32
|
-
readonly entryPrice: BigNumber;
|
|
33
|
-
readonly entryTick: number;
|
|
34
|
-
readonly bustPrice: BigNumber;
|
|
35
|
-
readonly takeProfitMultiplier: BigNumber | null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface RollercoasterTerminalBetState extends RollercoasterOpenBetState {
|
|
39
|
-
readonly terminalReason: RollercoasterBetTerminalReason;
|
|
40
|
-
readonly terminalTick: number;
|
|
41
|
-
readonly terminalPrice: BigNumber;
|
|
42
|
-
readonly multiplier: BigNumber;
|
|
43
|
-
readonly grossPayout: BigNumber;
|
|
44
|
-
readonly netPayout: BigNumber;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export type RollercoasterSettlementReason =
|
|
48
|
-
| { kind: RollercoasterBetTerminalReason.CASHOUT }
|
|
49
|
-
| { kind: RollercoasterBetTerminalReason.TAKE_PROFIT; takeProfitMultiplier: BigNumber }
|
|
50
|
-
| { kind: RollercoasterBetTerminalReason.LIQUIDATION }
|
|
51
|
-
| { kind: RollercoasterBetTerminalReason.MAX_PAYOUT; appliedCap: BigNumber }
|
|
52
|
-
| { kind: RollercoasterBetTerminalReason.ROUND_END };
|
|
53
|
-
|
|
54
|
-
export type RollercoasterTriggerOutcome = { fired: false } | { fired: true; reason: RollercoasterSettlementReason };
|
|
55
|
-
|
|
56
|
-
export class RollercoasterRules {
|
|
57
|
-
static bustPrice(entryPrice: BigNumber, leverage: BigNumber, direction: RollercoasterDirection): BigNumber {
|
|
58
|
-
const offset = entryPrice.dividedBy(leverage);
|
|
59
|
-
return direction === RollercoasterDirection.UP ? entryPrice.minus(offset) : entryPrice.plus(offset);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
static position(amount: BigNumber, leverage: BigNumber, direction: RollercoasterDirection): BigNumber {
|
|
63
|
-
const magnitude = amount.multipliedBy(leverage);
|
|
64
|
-
return direction === RollercoasterDirection.UP ? magnitude : magnitude.negated();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
static impliedPriceForMultiplier(
|
|
68
|
-
bet: Pick<RollercoasterOpenBetState, 'leverage' | 'entryPrice' | 'direction'>,
|
|
69
|
-
targetMultiplier: BigNumber,
|
|
70
|
-
): BigNumber {
|
|
71
|
-
const directionSign = bet.direction === RollercoasterDirection.UP ? 1 : -1;
|
|
72
|
-
const multiplierDelta = targetMultiplier.minus(1);
|
|
73
|
-
const priceChange = BigNumber(directionSign).multipliedBy(multiplierDelta).dividedBy(bet.leverage);
|
|
74
|
-
return bet.entryPrice.multipliedBy(BigNumber(1).plus(priceChange));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
static isValidTakeProfitMultiplier(takeProfitMultiplier: BigNumber): boolean {
|
|
78
|
-
return takeProfitMultiplier.isGreaterThan(ROLLERCOASTER_MIN_TAKE_PROFIT_MULTIPLIER);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
static isTakeProfitFarEnoughFromEntry(entryPrice: BigNumber, takeProfitImpliedPrice: BigNumber): boolean {
|
|
82
|
-
return takeProfitImpliedPrice.minus(entryPrice).abs().dividedBy(entryPrice).isGreaterThanOrEqualTo(ROLLERCOASTER_TP_MIN_PROXIMITY_TO_ENTRY);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
static isTakeProfitFarEnoughFromBust(entryPrice: BigNumber, bustPrice: BigNumber, takeProfitImpliedPrice: BigNumber): boolean {
|
|
86
|
-
return takeProfitImpliedPrice.minus(bustPrice).abs().dividedBy(entryPrice).isGreaterThanOrEqualTo(ROLLERCOASTER_TP_MIN_PROXIMITY_TO_BUST);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
static isBustPriceDistinctFromEntry(entryPrice: BigNumber, bustPrice: BigNumber): boolean {
|
|
90
|
-
return !entryPrice
|
|
91
|
-
.decimalPlaces(ROLLERCOASTER_INTERNAL_PRICE_DECIMAL_PLACES)
|
|
92
|
-
.isEqualTo(bustPrice.decimalPlaces(ROLLERCOASTER_INTERNAL_PRICE_DECIMAL_PLACES));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
static isFinalTick(activeTick: number): boolean {
|
|
96
|
-
return activeTick >= ROLLERCOASTER_ROUND_TICK_COUNT - 1;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
static isClientTickWithinMaxLag(activeTick: number, lastSeenTick: number): boolean {
|
|
100
|
-
return activeTick - lastSeenTick <= ROLLERCOASTER_MAX_STALE_CLIENT_TICKS;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
static getScheduledRoundId(now: Date, epochMs: number): number {
|
|
104
|
-
return Math.floor((now.getTime() - epochMs) / ROLLERCOASTER_ROUND_DURATION_MS);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
static getScheduledRoundStartTime(roundId: number, epochMs: number): Date {
|
|
108
|
-
return new Date(epochMs + roundId * ROLLERCOASTER_ROUND_DURATION_MS);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Tick N occupies [startedAt + N * ROLLERCOASTER_TICK_DURATION_MS, startedAt + (N + 1) * ROLLERCOASTER_TICK_DURATION_MS).
|
|
112
|
-
// tickAtOrAfter is the first tick starting at or after the instant; tickBefore is the last tick
|
|
113
|
-
// starting strictly before it. The two differ only on an exact tick boundary, which is the whole
|
|
114
|
-
// reason both exist rather than one caller-local `Math.ceil(...) - 1`.
|
|
115
|
-
static tickAtOrAfter(startedAtMs: number, atMs: number): number {
|
|
116
|
-
return Math.ceil((atMs - startedAtMs) / ROLLERCOASTER_TICK_DURATION_MS);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
static tickBefore(startedAtMs: number, atMs: number): number {
|
|
120
|
-
return RollercoasterRules.tickAtOrAfter(startedAtMs, atMs) - 1;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
static tickStartTime(startedAtMs: number, tick: number): Date {
|
|
124
|
-
return new Date(startedAtMs + tick * ROLLERCOASTER_TICK_DURATION_MS);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
static hasReachedTargetPrice(direction: RollercoasterDirection, currentPrice: BigNumber, targetPrice: BigNumber): boolean {
|
|
128
|
-
return direction === RollercoasterDirection.UP ? currentPrice.isGreaterThanOrEqualTo(targetPrice) : currentPrice.isLessThanOrEqualTo(targetPrice);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
static hasReachedBust(direction: RollercoasterDirection, price: BigNumber, bustPrice: BigNumber): boolean {
|
|
132
|
-
return direction === RollercoasterDirection.UP ? price.isLessThanOrEqualTo(bustPrice) : price.isGreaterThanOrEqualTo(bustPrice);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
static evaluateTriggers(bet: RollercoasterOpenBetState, tickPrice: BigNumber, liveMaxPayoutCap: BigNumber): RollercoasterTriggerOutcome {
|
|
136
|
-
const hitBust = RollercoasterRules.hasReachedBust(bet.direction, tickPrice, bet.bustPrice);
|
|
137
|
-
|
|
138
|
-
if (bet.takeProfitMultiplier != null) {
|
|
139
|
-
const takeProfitMultiplier = bet.takeProfitMultiplier;
|
|
140
|
-
const hitTakeProfit = RollercoasterRules.hasReachedTargetPrice(
|
|
141
|
-
bet.direction,
|
|
142
|
-
tickPrice,
|
|
143
|
-
RollercoasterRules.impliedPriceForMultiplier(bet, takeProfitMultiplier),
|
|
144
|
-
);
|
|
145
|
-
if (hitTakeProfit && hitBust) {
|
|
146
|
-
throw new Error('RollercoasterRules.evaluateTriggers: take profit and liquidation both fired');
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (hitTakeProfit) {
|
|
150
|
-
const reason = { kind: RollercoasterBetTerminalReason.TAKE_PROFIT, takeProfitMultiplier } as const;
|
|
151
|
-
// A take-profit is the one reason whose payout is a fixed wager × multiplier rather than
|
|
152
|
-
// something the tick price bounds, and the cap is read live every batch — so it can be
|
|
153
|
-
// lowered under an already-agreed multiplier. Nothing downstream re-checks it.
|
|
154
|
-
if (RollercoasterRules.grossPayoutForReason(bet, reason, tickPrice).isGreaterThan(liveMaxPayoutCap)) {
|
|
155
|
-
return { fired: true, reason: { kind: RollercoasterBetTerminalReason.MAX_PAYOUT, appliedCap: liveMaxPayoutCap } };
|
|
156
|
-
}
|
|
157
|
-
return { fired: true, reason };
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (hitBust) {
|
|
162
|
-
return { fired: true, reason: { kind: RollercoasterBetTerminalReason.LIQUIDATION } };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (RollercoasterRules.grossPayoutAtPrice(bet, tickPrice).isGreaterThanOrEqualTo(liveMaxPayoutCap)) {
|
|
166
|
-
return { fired: true, reason: { kind: RollercoasterBetTerminalReason.MAX_PAYOUT, appliedCap: liveMaxPayoutCap } };
|
|
167
|
-
}
|
|
168
|
-
return { fired: false };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
static multiplierAtPrice(bet: RollercoasterOpenBetState, price: BigNumber): BigNumber {
|
|
172
|
-
const directionSign = bet.direction === RollercoasterDirection.UP ? 1 : -1;
|
|
173
|
-
const percentagePriceChange = price.minus(bet.entryPrice).dividedBy(bet.entryPrice);
|
|
174
|
-
const leveragedPriceChange = BigNumber(directionSign).multipliedBy(bet.leverage).multipliedBy(percentagePriceChange);
|
|
175
|
-
|
|
176
|
-
const multiplier = BigNumber(1).plus(leveragedPriceChange);
|
|
177
|
-
return BigNumber.maximum(0, multiplier);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
static grossPayoutAtPrice(bet: RollercoasterOpenBetState, price: BigNumber): BigNumber {
|
|
181
|
-
const position = RollercoasterRules.position(bet.amount, bet.leverage, bet.direction);
|
|
182
|
-
const pnl = position.multipliedBy(price.minus(bet.entryPrice)).dividedBy(bet.entryPrice);
|
|
183
|
-
return BigNumber.maximum(0, bet.amount.plus(pnl));
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
static grossPayoutForReason(bet: RollercoasterOpenBetState, reason: RollercoasterSettlementReason, price: BigNumber): BigNumber {
|
|
187
|
-
switch (reason.kind) {
|
|
188
|
-
case RollercoasterBetTerminalReason.TAKE_PROFIT:
|
|
189
|
-
return calculatePayoutBN(bet.amount, reason.takeProfitMultiplier);
|
|
190
|
-
case RollercoasterBetTerminalReason.LIQUIDATION:
|
|
191
|
-
return BigNumber(0);
|
|
192
|
-
case RollercoasterBetTerminalReason.MAX_PAYOUT:
|
|
193
|
-
return reason.appliedCap;
|
|
194
|
-
case RollercoasterBetTerminalReason.CASHOUT:
|
|
195
|
-
case RollercoasterBetTerminalReason.ROUND_END:
|
|
196
|
-
return RollercoasterRules.grossPayoutAtPrice(bet, price);
|
|
197
|
-
default:
|
|
198
|
-
reason satisfies never;
|
|
199
|
-
throw new Error(`RollercoasterRules.grossPayoutForReason: unhandled reason`);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
static multiplierForReason(bet: RollercoasterOpenBetState, reason: RollercoasterSettlementReason, price: BigNumber): BigNumber {
|
|
204
|
-
if (reason.kind === RollercoasterBetTerminalReason.TAKE_PROFIT) {
|
|
205
|
-
return reason.takeProfitMultiplier;
|
|
206
|
-
}
|
|
207
|
-
return RollercoasterRules.multiplierAtPrice(bet, price);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
static netPayout(grossPayout: BigNumber, amount: BigNumber): BigNumber {
|
|
211
|
-
if (grossPayout.lte(amount)) {
|
|
212
|
-
return grossPayout;
|
|
213
|
-
}
|
|
214
|
-
const profit = grossPayout.minus(amount);
|
|
215
|
-
return amount.plus(profit.multipliedBy(BigNumber(1).minus(ROLLERCOASTER_PROFIT_FEE_RATE)));
|
|
216
|
-
}
|
|
217
|
-
}
|