Zion Boggan
repos/Prediction Market Bot Postmortem/eval/test_effective_exposure.py
zionboggan.com ↗
481 lines · python
History for this file →
1
"""
2
Simulation Test Suite for Effective Exposure Discount
3
=====================================================
4
 
5
Tests the proposed change in isolation WITHOUT touching production code.
6
Mocks the database and Kalshi API to simulate real-world scenarios.
7
 
8
Run: python test_effective_exposure.py
9
"""
10
 
11
import sys
12
import unittest
13
from unittest.mock import patch, MagicMock
14
from datetime import datetime, timedelta, timezone
15
 
16
from effective_exposure import (
17
    effective_exposure_factor,
18
    compute_effective_exposure,
19
    DISCOUNT_TIERS,
20
)
21
 
22
MAX_EXPOSURE_PCT = 0.30
23
MAX_BET_PCT = 0.08
24
 
25
def old_exposure_gate(open_trades, balance):
26
    """Original gate: raw SUM(cost) of open trades."""
27
    raw_exposure = sum(float(t["cost"]) for t in open_trades)
28
    limit = balance * MAX_EXPOSURE_PCT
29
    return raw_exposure < limit, raw_exposure, limit
30
 
31
def new_exposure_gate(open_trades, price_lookup, balance):
32
    """New gate: effective exposure with discounts."""
33
    effective, details = compute_effective_exposure(open_trades, price_lookup)
34
    limit = balance * MAX_EXPOSURE_PCT
35
    return effective < limit, effective, limit, details
36
 
37
class TestExposureFactor(unittest.TestCase):
38
    """Verify discount tiers work correctly for YES and NO sides."""
39
 
40
    def test_yes_side_essentially_won(self):
41
        """YES bet, market price 0.97 → 0% exposure."""
42
        self.assertEqual(effective_exposure_factor("yes", 0.97), 0.00)
43
 
44
    def test_yes_side_very_likely(self):
45
        """YES bet, market price 0.88 → 25% exposure."""
46
        self.assertEqual(effective_exposure_factor("yes", 0.88), 0.25)
47
 
48
    def test_yes_side_probably_winning(self):
49
        """YES bet, market price 0.78 → 50% exposure."""
50
        self.assertEqual(effective_exposure_factor("yes", 0.78), 0.50)
51
 
52
    def test_yes_side_uncertain(self):
53
        """YES bet, market price 0.60 → full exposure."""
54
        self.assertEqual(effective_exposure_factor("yes", 0.60), 1.0)
55
 
56
    def test_yes_side_losing(self):
57
        """YES bet, market price 0.15 → full exposure (losing)."""
58
        self.assertEqual(effective_exposure_factor("yes", 0.15), 1.0)
59
 
60
    def test_no_side_essentially_won(self):
61
        """NO bet, YES price 0.03 → NO win_price=0.97 → 0% exposure."""
62
        self.assertEqual(effective_exposure_factor("no", 0.03), 0.00)
63
 
64
    def test_no_side_very_likely(self):
65
        """NO bet, YES price 0.12 → NO win_price=0.88 → 25% exposure."""
66
        self.assertEqual(effective_exposure_factor("no", 0.12), 0.25)
67
 
68
    def test_no_side_uncertain(self):
69
        """NO bet, YES price 0.55 → NO win_price=0.45 → full exposure."""
70
        self.assertEqual(effective_exposure_factor("no", 0.55), 1.0)
71
 
72
    def test_no_side_losing(self):
73
        """NO bet, YES price 0.90 → NO win_price=0.10 → full exposure."""
74
        self.assertEqual(effective_exposure_factor("no", 0.90), 1.0)
75
 
76
    def test_none_price_conservative(self):
77
        """No price data → always full exposure."""
78
        self.assertEqual(effective_exposure_factor("yes", None), 1.0)
79
        self.assertEqual(effective_exposure_factor("no", None), 1.0)
80
 
81
    def test_exact_boundary_95(self):
82
        """Exactly at 0.95 threshold → 0% exposure."""
83
        self.assertEqual(effective_exposure_factor("yes", 0.95), 0.00)
84
 
85
    def test_just_below_95(self):
86
        """Just below 0.95 → falls to 25% tier."""
87
        self.assertEqual(effective_exposure_factor("yes", 0.9499), 0.25)
88
 
89
    def test_exact_boundary_85(self):
90
        """Exactly at 0.85 threshold → 25% exposure."""
91
        self.assertEqual(effective_exposure_factor("yes", 0.85), 0.25)
92
 
93
    def test_exact_boundary_75(self):
94
        """Exactly at 0.75 threshold → 50% exposure."""
95
        self.assertEqual(effective_exposure_factor("yes", 0.75), 0.50)
96
 
97
class TestComputeEffective(unittest.TestCase):
98
 
99
    def test_all_uncertain_matches_raw(self):
100
        """When all positions are uncertain, effective == raw exposure."""
101
        trades = [
102
            {"id": 1, "ticker": "KXHIGHNY-26MAR31-B62.5", "side": "yes", "cost": 5.00},
103
            {"id": 2, "ticker": "KXHIGHCH-26MAR31-B55.5", "side": "no", "cost": 3.00},
104
        ]
105
        prices = {
106
            "KXHIGHNY-26MAR31-B62.5": 0.60,
107
            "KXHIGHCH-26MAR31-B55.5": 0.50,
108
        }
109
        effective, details = compute_effective_exposure(trades, prices)
110
        raw = sum(t["cost"] for t in trades)
111
        self.assertEqual(effective, raw)
112
 
113
    def test_one_won_one_uncertain(self):
114
        """One essentially won + one uncertain → only uncertain counts."""
115
        trades = [
116
            {"id": 1, "ticker": "WON-TICKER", "side": "yes", "cost": 5.00},
117
            {"id": 2, "ticker": "OPEN-TICKER", "side": "yes", "cost": 3.00},
118
        ]
119
        prices = {
120
            "WON-TICKER": 0.97,
121
            "OPEN-TICKER": 0.55,
122
        }
123
        effective, details = compute_effective_exposure(trades, prices)
124
        self.assertEqual(effective, 3.00)
125
 
126
    def test_mixed_discounts(self):
127
        """Multiple positions at different tiers."""
128
        trades = [
129
            {"id": 1, "ticker": "T1", "side": "yes", "cost": 10.00},
130
            {"id": 2, "ticker": "T2", "side": "yes", "cost": 8.00},
131
            {"id": 3, "ticker": "T3", "side": "no", "cost": 6.00},
132
            {"id": 4, "ticker": "T4", "side": "yes", "cost": 4.00},
133
        ]
134
        prices = {
135
            "T1": 0.96,
136
            "T2": 0.88,
137
            "T3": 0.22,
138
            "T4": 0.60,
139
        }
140
        effective, details = compute_effective_exposure(trades, prices)
141
        expected = 0.00 + 2.00 + 3.00 + 4.00
142
        self.assertAlmostEqual(effective, expected, places=2)
143
 
144
    def test_callable_price_lookup(self):
145
        """Price lookup via callable (simulating API call)."""
146
        trades = [
147
            {"id": 1, "ticker": "T1", "side": "yes", "cost": 5.00},
148
        ]
149
 
150
        def lookup(ticker):
151
            return 0.97 if ticker == "T1" else 0.50
152
 
153
        effective, details = compute_effective_exposure(trades, lookup)
154
        self.assertEqual(effective, 0.00)
155
 
156
    def test_missing_price_in_dict(self):
157
        """Ticker not in price dict → full exposure (conservative)."""
158
        trades = [
159
            {"id": 1, "ticker": "UNKNOWN", "side": "yes", "cost": 5.00},
160
        ]
161
        effective, details = compute_effective_exposure(trades, {})
162
        self.assertEqual(effective, 5.00)
163
 
164
    def test_empty_trades(self):
165
        """No open trades → zero exposure."""
166
        effective, details = compute_effective_exposure([], {})
167
        self.assertEqual(effective, 0.00)
168
        self.assertEqual(details, [])
169
 
170
class TestOvernightLockupScenario(unittest.TestCase):
171
    """
172
    Scenario: It's 8 PM. Hermes placed 3 weather bets during the afternoon
173
    scanning cycle. Total cost = $25 on a $100 bankroll (25% exposure).
174
 
175
    By 6 AM the next morning, 2 of the 3 bets are essentially decided
176
    (market prices at 0.96 and 0.98) but Kalshi won't settle until 10 AM.
177
 
178
    OLD behavior: $25 exposure stays locked → only $5 room before 30% cap.
179
    NEW behavior: Only the uncertain bet's cost counts → much more room.
180
    """
181
 
182
    def setUp(self):
183
        self.balance = 100.00
184
        self.open_trades = [
185
            {"id": 1, "ticker": "KXHIGHNY-26MAR30-B62.5", "side": "yes", "cost": 10.00},
186
            {"id": 2, "ticker": "KXHIGHCH-26MAR30-B50.5", "side": "yes", "cost": 8.00},
187
            {"id": 3, "ticker": "KXHIGHDN-26MAR30-B55.5", "side": "no", "cost": 7.00},
188
        ]
189
 
190
    def test_old_gate_blocks_morning_trading(self):
191
        """OLD system: $25 exposure, only $5 room → a $6 bet is BLOCKED."""
192
        can_trade, raw, limit = old_exposure_gate(self.open_trades, self.balance)
193
 
194
        self.assertTrue(can_trade)
195
        self.assertEqual(raw, 25.00)
196
        self.assertEqual(limit, 30.00)
197
 
198
        room = limit - raw
199
        self.assertEqual(room, 5.00)
200
        self.assertFalse(room >= 6.00, "Old gate: no room for a $6 bet")
201
 
202
    def test_new_gate_frees_morning_capital(self):
203
        """
204
        NEW system: 2 bets essentially won, 1 uncertain.
205
        NY at 0.96 (won) → $0 effective
206
        CH at 0.98 (won) → $0 effective
207
        DN NO side, YES price 0.20 → NO win_price=0.80 → 50% tier → $3.50
208
        Total effective = $3.50, room = $26.50
209
        """
210
        prices = {
211
            "KXHIGHNY-26MAR30-B62.5": 0.96,
212
            "KXHIGHCH-26MAR30-B50.5": 0.98,
213
            "KXHIGHDN-26MAR30-B55.5": 0.20,
214
        }
215
        can_trade, effective, limit, details = new_exposure_gate(
216
            self.open_trades, prices, self.balance
217
        )
218
        self.assertTrue(can_trade)
219
        self.assertAlmostEqual(effective, 3.50, places=2)
220
        room = limit - effective
221
        self.assertAlmostEqual(room, 26.50, places=2)
222
        self.assertTrue(room >= 6.00, "New gate: plenty of room for morning bets!")
223
 
224
    def test_improvement_quantified(self):
225
        """Quantify: new system gives 5.3x more trading room in this scenario."""
226
        prices = {
227
            "KXHIGHNY-26MAR30-B62.5": 0.96,
228
            "KXHIGHCH-26MAR30-B50.5": 0.98,
229
            "KXHIGHDN-26MAR30-B55.5": 0.20,
230
        }
231
        _, raw, limit = old_exposure_gate(self.open_trades, self.balance)
232
        _, effective, _, _ = new_exposure_gate(self.open_trades, prices, self.balance)
233
 
234
        old_room = limit - raw
235
        new_room = limit - effective
236
        improvement = new_room / old_room
237
        self.assertGreater(improvement, 5.0)
238
        print(f"\n  >>> Overnight scenario: old room=${old_room:.2f}, "
239
              f"new room=${new_room:.2f} ({improvement:.1f}x improvement)")
240
 
241
class TestSafetyFlipScenario(unittest.TestCase):
242
    """
243
    Edge case: A bet LOOKED like it was winning (price at 0.90) but then
244
    the market reverses. If we discounted it, are we over-exposed?
245
 
246
    The 0.75 threshold with 50% discount is intentionally conservative.
247
    A bet at 0.90 gets 25% discount - still counts 75% of its cost.
248
    Only bets at 0.95+ get fully discounted.
249
    """
250
 
251
    def test_moderate_winner_still_counts(self):
252
        """Bet at 0.80 YES → 50% discount, still counts half."""
253
        trades = [{"id": 1, "ticker": "T1", "side": "yes", "cost": 10.00}]
254
 
255
        effective, _ = compute_effective_exposure(trades, {"T1": 0.80})
256
        self.assertEqual(effective, 5.00)
257
 
258
    def test_strong_winner_minimal_exposure(self):
259
        """Bet at 0.90 YES → 25% discount, counts quarter."""
260
        trades = [{"id": 1, "ticker": "T1", "side": "yes", "cost": 10.00}]
261
        effective, _ = compute_effective_exposure(trades, {"T1": 0.90})
262
        self.assertEqual(effective, 2.50)
263
 
264
    def test_near_certain_zero_exposure(self):
265
        """Only at 0.95+ does exposure drop to zero."""
266
        trades = [{"id": 1, "ticker": "T1", "side": "yes", "cost": 10.00}]
267
        effective, _ = compute_effective_exposure(trades, {"T1": 0.95})
268
        self.assertEqual(effective, 0.00)
269
 
270
    def test_worst_case_total_reversal(self):
271
        """
272
        Worst case: we freed exposure on a 0.95 bet, placed a new trade,
273
        then the 0.95 bet crashes to 0.30. Now we have MORE real exposure
274
        than the 30% cap intended.
275
 
276
        BUT: On Kalshi weather markets, a price at 0.95 means the weather
277
        event is almost certainly decided. These are binary temperature
278
        outcomes - they don't "reverse" the way stock prices can.
279
 
280
        Still, let's quantify the max theoretical over-exposure.
281
        """
282
        balance = 100.00
283
 
284
        original_trades = [
285
            {"id": 1, "ticker": "WON", "side": "yes", "cost": 20.00},
286
            {"id": 2, "ticker": "OPEN", "side": "yes", "cost": 8.00},
287
        ]
288
 
289
        prices_before = {"WON": 0.96, "OPEN": 0.55}
290
        _, effective_before, _, _ = new_exposure_gate(original_trades, prices_before, balance)
291
        self.assertAlmostEqual(effective_before, 8.00, places=2)
292
 
293
        trades_after = original_trades + [
294
            {"id": 3, "ticker": "NEW", "side": "yes", "cost": 8.00},
295
        ]
296
 
297
        prices_after = {"WON": 0.30, "OPEN": 0.55, "NEW": 0.55}
298
        _, effective_after, _, _ = new_exposure_gate(trades_after, prices_after, balance)
299
 
300
        self.assertAlmostEqual(effective_after, 36.00, places=2)
301
 
302
        overshoot_pct = (effective_after / balance) - MAX_EXPOSURE_PCT
303
        self.assertLessEqual(overshoot_pct, MAX_BET_PCT,
304
                             "Overshoot is bounded by MAX_BET_PCT")
305
        print(f"\n  >>> Worst-case reversal: {effective_after/balance:.0%} effective "
306
              f"exposure (overshoot={overshoot_pct:.0%}, bounded by MAX_BET_PCT={MAX_BET_PCT:.0%})")
307
 
308
class TestFullPipelineSimulation(unittest.TestCase):
309
    """
310
    Simulates:
311
    1. Afternoon: Bot places 3 bets, hitting 24% raw exposure
312
    2. Evening: Markets move, 2 bets are near-certain wins
313
    3. Overnight: No settlement from Kalshi
314
    4. Morning: New scanning cycle - can the bot trade?
315
    5. Mid-morning: Kalshi settles, exposure clears naturally
316
    """
317
 
318
    def test_full_24_hour_cycle(self):
319
        balance = 100.00
320
        events = []
321
 
322
        trades = [
323
            {"id": 1, "ticker": "NY-HIGH", "side": "yes", "cost": 9.00},
324
            {"id": 2, "ticker": "CH-HIGH", "side": "yes", "cost": 8.00},
325
            {"id": 3, "ticker": "DN-LOW", "side": "no", "cost": 7.00},
326
        ]
327
        prices_afternoon = {"NY-HIGH": 0.58, "CH-HIGH": 0.62, "DN-LOW": 0.45}
328
 
329
        _, eff_afternoon, _, _ = new_exposure_gate(trades, prices_afternoon, balance)
330
 
331
        self.assertAlmostEqual(eff_afternoon, 24.00, places=2)
332
        events.append(f"3 PM: Placed 3 bets, effective exposure=${eff_afternoon:.2f}")
333
 
334
        prices_evening = {"NY-HIGH": 0.92, "CH-HIGH": 0.88, "DN-LOW": 0.10}
335
        _, eff_evening, _, _ = new_exposure_gate(trades, prices_evening, balance)
336
 
337
        self.assertAlmostEqual(eff_evening, 6.00, places=2)
338
        events.append(f"9 PM: Markets shifted, effective exposure=${eff_evening:.2f}")
339
 
340
        prices_overnight = {"NY-HIGH": 0.97, "CH-HIGH": 0.96, "DN-LOW": 0.04}
341
        _, eff_overnight, _, _ = new_exposure_gate(trades, prices_overnight, balance)
342
 
343
        self.assertAlmostEqual(eff_overnight, 0.00, places=2)
344
        events.append(f"2 AM: Near-certain, effective exposure=${eff_overnight:.2f}")
345
 
346
        room = (balance * MAX_EXPOSURE_PCT) - eff_overnight
347
        self.assertAlmostEqual(room, 30.00, places=2)
348
 
349
        old_room = (balance * MAX_EXPOSURE_PCT) - 24.00
350
        self.assertEqual(old_room, 6.00)
351
 
352
        events.append(f"6 AM: OLD room=${old_room:.2f}, NEW room=${room:.2f}")
353
        events.append(f"      Improvement: {room/old_room:.1f}x more capital available")
354
 
355
        morning_bet_cost = 7.50
356
        trades.append({"id": 4, "ticker": "LA-HIGH", "side": "yes", "cost": morning_bet_cost})
357
        prices_morning = {**prices_overnight, "LA-HIGH": 0.55}
358
        _, eff_morning, _, _ = new_exposure_gate(trades, prices_morning, balance)
359
        self.assertAlmostEqual(eff_morning, 7.50, places=2)
360
        events.append(f"6:30 AM: Placed morning bet, effective=${eff_morning:.2f}")
361
 
362
        trades_after_settle = [trades[3]]
363
        _, eff_settled, _, _ = new_exposure_gate(trades_after_settle, {"LA-HIGH": 0.55}, balance)
364
        self.assertAlmostEqual(eff_settled, 7.50, places=2)
365
        events.append(f"10 AM: Kalshi settled 3 bets, exposure=${eff_settled:.2f}")
366
 
367
        print("\n  >>> Full 24-hour simulation:")
368
        for e in events:
369
            print(f"      {e}")
370
 
371
class TestIntegrationCompatibility(unittest.TestCase):
372
    """
373
    Verify the new function can serve as a drop-in replacement for
374
    get_open_exposure() in the Filter 5 check at line 2108-2115.
375
    """
376
 
377
    def test_when_no_prices_matches_old_behavior(self):
378
        """Without price data, effective == raw (backward compatible)."""
379
        trades = [
380
            {"id": 1, "ticker": "T1", "side": "yes", "cost": 10.00},
381
            {"id": 2, "ticker": "T2", "side": "no", "cost": 5.00},
382
        ]
383
 
384
        effective, _ = compute_effective_exposure(trades, {})
385
        raw = sum(t["cost"] for t in trades)
386
        self.assertEqual(effective, raw)
387
 
388
    def test_gate_decision_matches_types(self):
389
        """The gate returns a bool just like the old comparison."""
390
        balance = 100.0
391
        trades = [{"id": 1, "ticker": "T1", "side": "yes", "cost": 25.00}]
392
        prices = {"T1": 0.97}
393
 
394
        can_trade, effective, limit, _ = new_exposure_gate(trades, prices, balance)
395
        self.assertIsInstance(can_trade, bool)
396
        self.assertIsInstance(effective, float)
397
        self.assertIsInstance(limit, float)
398
 
399
    def test_does_not_modify_input(self):
400
        """Ensure the function doesn't mutate the input trade list."""
401
        trades = [{"id": 1, "ticker": "T1", "side": "yes", "cost": 10.00}]
402
        original = [dict(t) for t in trades]
403
        compute_effective_exposure(trades, {"T1": 0.97})
404
        self.assertEqual(trades, original)
405
 
406
class TestStress(unittest.TestCase):
407
 
408
    def test_max_positions_at_various_states(self):
409
        """8 trades (MAX_DAILY_TRADES), all at different price levels."""
410
        trades = [
411
            {"id": i, "ticker": f"T{i}", "side": "yes", "cost": 4.00}
412
            for i in range(1, 9)
413
        ]
414
 
415
        prices = {
416
            "T1": 0.99,
417
            "T2": 0.97,
418
            "T3": 0.95,
419
            "T4": 0.90,
420
            "T5": 0.80,
421
            "T6": 0.60,
422
            "T7": 0.45,
423
            "T8": 0.10,
424
        }
425
        effective, details = compute_effective_exposure(trades, prices)
426
 
427
        self.assertAlmostEqual(effective, 15.00, places=2)
428
 
429
        raw = sum(t["cost"] for t in trades)
430
        reduction = 1 - (effective / raw)
431
        print(f"\n  >>> Stress test: 8 positions, raw=${raw:.2f}, "
432
              f"effective=${effective:.2f} ({reduction:.0%} reduction)")
433
 
434
    def test_all_won_zero_exposure(self):
435
        """All positions essentially won → zero exposure."""
436
        trades = [
437
            {"id": i, "ticker": f"T{i}", "side": "yes", "cost": 5.00}
438
            for i in range(1, 5)
439
        ]
440
        prices = {f"T{i}": 0.98 for i in range(1, 5)}
441
        effective, _ = compute_effective_exposure(trades, prices)
442
        self.assertEqual(effective, 0.00)
443
 
444
    def test_all_lost_full_exposure(self):
445
        """All positions are losing → still full exposure (conservative)."""
446
        trades = [
447
            {"id": i, "ticker": f"T{i}", "side": "yes", "cost": 5.00}
448
            for i in range(1, 5)
449
        ]
450
        prices = {f"T{i}": 0.10 for i in range(1, 5)}
451
        effective, _ = compute_effective_exposure(trades, prices)
452
        self.assertEqual(effective, 20.00)
453
 
454
class TestDetailsOutput(unittest.TestCase):
455
    """The details list should be usable for Discord reporting."""
456
 
457
    def test_details_contain_all_fields(self):
458
        trades = [{"id": 1, "ticker": "T1", "side": "yes", "cost": 5.00}]
459
        _, details = compute_effective_exposure(trades, {"T1": 0.90})
460
        d = details[0]
461
        self.assertIn("id", d)
462
        self.assertIn("ticker", d)
463
        self.assertIn("side", d)
464
        self.assertIn("cost", d)
465
        self.assertIn("yes_price", d)
466
        self.assertIn("factor", d)
467
        self.assertIn("effective_cost", d)
468
 
469
    def test_details_count_matches_trades(self):
470
        trades = [
471
            {"id": i, "ticker": f"T{i}", "side": "yes", "cost": 3.00}
472
            for i in range(1, 4)
473
        ]
474
        _, details = compute_effective_exposure(trades, {})
475
        self.assertEqual(len(details), 3)
476
 
477
if __name__ == "__main__":
478
    print("=" * 70)
479
    print("HERMES EFFECTIVE EXPOSURE DISCOUNT - SIMULATION TEST SUITE")
480
    print("=" * 70)
481
    unittest.main(verbosity=2)