Unit tests check specific examples. Property-based tests check universal invariants. After using property-based testing to find dozens of production bugs, I've learned that it's essential for financial systems where edge cases cause real money loss. This article covers production property-based testing.
Financial systems have invariants:
Property-based testing generates random inputs to verify these hold.
1from hypothesis import given, strategies as st, assume
2from decimal import Decimal
3import pytest
4
5class OrderBook:
6 """Simple order book for testing."""
7
8 def __init__(self):
9 self.bids = [] # (price, size, timestamp)
10 self.asks = []
11 self.trades = []
12
13 def add_order(self, side, price, size, timestamp):
14 if side == 'buy':
15 self.bids.append((price, size, timestamp))
16 self.bids.sort(reverse=True, key=lambda x: (x[0], -x[2]))
17 else:
18 self.asks.append((price, size, timestamp))
19 self.asks.sort(key=lambda x: (x[0], x[2]))
20
21 self._match_orders()
22
23 def _match_orders(self):
24 while self.bids and self.asks:
25 best_bid = self.bids[0]
26 best_ask = self.asks[0]
27
28 if best_bid[0] < best_ask[0]:
29 break
30
31 # Match
32 trade_size = min(best_bid[1], best_ask[1])
33 trade_price = best_ask[0] # Aggressive taker pays offer
34
35 self.trades.append((trade_price, trade_size))
36
37 # Update sizes
38 self.bids[0] = (best_bid[0], best_bid[1] - trade_size, best_bid[2])
39 self.asks[0] = (best_ask[0], best_ask[1] - trade_size, best_ask[2])
40
41 # Remove filled orders
42 if self.bids[0][1] == 0:
43 self.bids.pop(0)
44 if self.asks[0][1] == 0:
45 self.asks.pop(0)
46
47# Property-based tests
48
49@given(st.lists(
50 st.tuples(
51 st.sampled_from(['buy', 'sell']),
52 st.decimals(min_value=Decimal('0.01'), max_value=Decimal('1000'),
53 places=2),
54 st.integers(min_value=1, max_value=1000),
55 st.integers(min_value=0, max_value=1000000)
56 ),
57 min_size=1,
58 max_size=100
59))
60def test_no_crossed_book(orders):
61 """Property: Best bid should never exceed best ask."""
62 book = OrderBook()
63
64 for side, price, size, ts in orders:
65 book.add_order(side, price, size, ts)
66
67 if book.bids and book.asks:
68 assert book.bids[0][0] < book.asks[0][0], \
69 f"Crossed book: bid={book.bids[0][0]}, ask={book.asks[0][0]}"
70
71@given(st.lists(
72 st.tuples(
73 st.sampled_from(['buy', 'sell']),
74 st.decimals(min_value=Decimal('0.01'), max_value=Decimal('1000'),
75 places=2),
76 st.integers(min_value=1, max_value=1000),
77 st.integers(min_value=0, max_value=1000000)
78 ),
79 min_size=1,
80 max_size=100
81))
82def test_trade_prices_valid(orders):
83 """Property: All trades must occur at valid prices."""
84 book = OrderBook()
85
86 for side, price, size, ts in orders:
87 book.add_order(side, price, size, ts)
88
89 # All trade prices should be within bid-ask spread at time of trade
90 for trade_price, _ in book.trades:
91 assert trade_price > 0, "Trade price must be positive"
92
93@given(st.lists(
94 st.tuples(
95 st.sampled_from(['buy', 'sell']),
96 st.decimals(min_value=Decimal('0.01'), max_value=Decimal('1000'),
97 places=2),
98 st.integers(min_value=1, max_value=1000),
99 st.integers(min_value=0, max_value=1000000)
100 ),
101 min_size=2,
102 max_size=100
103))
104def test_total_volume_conservation(orders):
105 """Property: Total volume in = total volume out (trades + remaining)."""
106 book = OrderBook()
107
108 total_buy_volume = 0
109 total_sell_volume = 0
110
111 for side, price, size, ts in orders:
112 if side == 'buy':
113 total_buy_volume += size
114 else:
115 total_sell_volume += size
116
117 book.add_order(side, price, size, ts)
118
119 # Calculate remaining
120 remaining_buy = sum(order[1] for order in book.bids)
121 remaining_sell = sum(order[1] for order in book.asks)
122
123 # Calculate traded
124 traded_volume = sum(size for _, size in book.trades)
125
126 # Conservation check
127 assert total_buy_volume == remaining_buy + traded_volume
128 assert total_sell_volume == remaining_sell + traded_volume
129
130@given(st.lists(
131 st.tuples(
132 st.sampled_from(['buy', 'sell']),
133 st.decimals(min_value=Decimal('0.01'), max_value=Decimal('1000'),
134 places=2),
135 st.integers(min_value=1, max_value=1000),
136 st.integers(min_value=0, max_value=1000000)
137 ),
138 min_size=1,
139 max_size=50
140))
141def test_price_time_priority(orders):
142 """Property: Better prices and earlier times get priority."""
143 book = OrderBook()
144
145 for side, price, size, ts in orders:
146 book.add_order(side, price, size, ts)
147
148 # Check bids are sorted by price desc, then time asc
149 for i in range(len(book.bids) - 1):
150 curr = book.bids[i]
151 next_order = book.bids[i + 1]
152
153 if curr[0] == next_order[0]: # Same price
154 assert curr[2] <= next_order[2], "Time priority violated"
155 else:
156 assert curr[0] > next_order[0], "Price priority violated"
1571from hypothesis import given, strategies as st
2import numpy as np
3
4def calculate_var(returns, confidence=0.99):
5 """Historical VaR calculation."""
6 if len(returns) == 0:
7 return 0.0
8 return -np.percentile(returns, (1 - confidence) * 100)
9
10@given(st.lists(st.floats(min_value=-0.5, max_value=0.5),
11 min_size=10, max_size=1000))
12def test_var_properties(returns):
13 """Properties of VaR calculation."""
14 var_99 = calculate_var(returns, 0.99)
15 var_95 = calculate_var(returns, 0.95)
16
17 # Property 1: VaR should be non-negative
18 assert var_99 >= 0, "VaR cannot be negative"
19 assert var_95 >= 0, "VaR cannot be negative"
20
21 # Property 2: Higher confidence = higher VaR
22 assert var_99 >= var_95, "99% VaR should be >= 95% VaR"
23
24 # Property 3: VaR should not exceed max loss
25 max_loss = -min(returns)
26 assert var_99 <= max_loss, f"VaR {var_99} exceeds max loss {max_loss}"
27
28@given(
29 st.lists(st.floats(min_value=-0.5, max_value=0.5),
30 min_size=10, max_size=100),
31 st.floats(min_value=0.001, max_value=10.0)
32)
33def test_var_scaling(returns, scale_factor):
34 """Property: Scaling positions scales VaR proportionally."""
35 var_original = calculate_var(returns)
36
37 scaled_returns = [r * scale_factor for r in returns]
38 var_scaled = calculate_var(scaled_returns)
39
40 # VaR should scale linearly with position size
41 expected_var = var_original * scale_factor
42
43 # Allow small floating point error
44 assert abs(var_scaled - expected_var) < 1e-6, \
45 f"VaR scaling violated: {var_scaled} vs {expected_var}"
46Bugs found with property-based testing (2020-2024):
1Bug Type Instances Severity
2──────────────────────────────────────────────────────────────
3Order matching edge cases 7 Critical
4Position limit calculation errors 4 High
5Rounding errors in P&L 12 Medium
6Timestamp ordering violations 3 High
7Fee calculation edge cases 8 Medium
8Division by zero 5 Critical
9Integer overflow 2 Critical
101Hypothesis found:
2 Orders: [
3 ('buy', Decimal('100.00'), 50, 1000),
4 ('sell', Decimal('100.00'), 100, 999), # Earlier timestamp!
5 ('buy', Decimal('100.01'), 50, 1001)
6 ]
7
8Expected: Second buy matches with sell (price-time priority)
9Actual: First buy matched (timestamp check bug)
10@seed decorator for reproducibilityProperty-based testing is essential for financial systems. The bugs it finds are the ones that cost real money in production.
Technical Writer
NordVarg Team is a software engineer at NordVarg specializing in high-performance financial systems and type-safe programming.
Get weekly insights on building high-performance financial systems, latest industry trends, and expert tips delivered straight to your inbox.