1"""
2Unit tests for calculator.py — pure math, no network calls.
3"""
4
5import pytest
6from src.calculator import (
7 ADR_REGISTRY,
8 DEFAULT_TICKERS,
9 compute_premium,
10 build_result,
11 compute_all,
12)
13
14
15
16
17
18
19
20PRICES_FULL = {
21 "TSM": 175.50,
22 "2330.TW": 1050.0,
23 "UMC": 7.20,
24 "2303.TW": 52.0,
25 "CHT": 38.50,
26 "2412.TW": 130.0,
27 "ASX": 9.80,
28 "3711.TW": 310.0,
29 "HIMX": 6.40,
30 "3324.TW": 106.0,
31 "IMOS": 24.00,
32 "8150.TW": 40.0,
33}
34
35FX = 32.5
36
37
38
39
40
41
42class TestComputePremium:
43 def test_tsm_premium_positive(self):
44
45
46 result = compute_premium(
47 adr_price_usd=175.50,
48 tw_price_twd=1050.0,
49 adr_ratio=5,
50 fx_usd_twd=32.5,
51 )
52 assert result["premium_pct"] == pytest.approx(8.64, abs=0.01)
53 assert result["tw_equivalent_usd"] == pytest.approx(161.538, abs=0.01)
54 assert "premium" in result["premium_label"]
55 assert "8.64" in result["premium_label"]
56
57 def test_discount_when_adr_cheaper(self):
58
59 result = compute_premium(
60 adr_price_usd=150.0,
61 tw_price_twd=1050.0,
62 adr_ratio=5,
63 fx_usd_twd=32.5,
64 )
65 assert result["premium_pct"] < 0
66 assert "discount" in result["premium_label"]
67
68 def test_parity(self):
69
70
71
72 tw_equiv = (100.0 * 2) / 32.5
73 result = compute_premium(
74 adr_price_usd=tw_equiv,
75 tw_price_twd=100.0,
76 adr_ratio=2,
77 fx_usd_twd=32.5,
78 )
79 assert result["premium_pct"] == 0.0
80 assert "parity" in result["premium_label"]
81
82 def test_premium_rounds_to_two_decimals(self):
83 result = compute_premium(175.50, 1050.0, 5, 32.5)
84
85 assert result["premium_pct"] == round(result["premium_pct"], 2)
86
87 def test_raises_on_zero_fx(self):
88 with pytest.raises(ValueError, match="fx_usd_twd must be positive"):
89 compute_premium(175.50, 1050.0, 5, fx_usd_twd=0)
90
91 def test_raises_on_negative_fx(self):
92 with pytest.raises(ValueError, match="fx_usd_twd must be positive"):
93 compute_premium(175.50, 1050.0, 5, fx_usd_twd=-1.0)
94
95 def test_raises_on_zero_ratio(self):
96 with pytest.raises(ValueError, match="adr_ratio must be positive"):
97 compute_premium(175.50, 1050.0, adr_ratio=0, fx_usd_twd=32.5)
98
99 def test_tw_equivalent_usd_formula(self):
100
101 result = compute_premium(200.0, 1000.0, 5, 32.0)
102 expected_equiv = (1000.0 * 5) / 32.0
103 assert result["tw_equivalent_usd"] == pytest.approx(expected_equiv, rel=1e-4)
104
105 def test_large_premium(self):
106
107 result = compute_premium(200.0, 1050.0, 5, 32.5)
108 assert result["premium_pct"] > 20
109
110 def test_zero_tw_price_is_guarded_upstream(self):
111
112
113
114
115
116 with pytest.raises((ZeroDivisionError, ValueError)):
117 compute_premium(175.50, 0.0, 5, 32.5)
118
119 def test_high_ratio_cht(self):
120
121 result = compute_premium(
122 adr_price_usd=38.50,
123 tw_price_twd=130.0,
124 adr_ratio=10,
125 fx_usd_twd=32.5,
126 )
127 expected_equiv = (130.0 * 10) / 32.5
128 assert result["tw_equivalent_usd"] == pytest.approx(expected_equiv, rel=1e-4)
129
130 assert result["premium_pct"] == pytest.approx(-3.75, abs=0.01)
131
132
133
134
135
136
137class TestBuildResult:
138 def test_tsm_successful_build(self):
139 result = build_result("TSM", PRICES_FULL, FX)
140 assert result["adr_ticker"] == "TSM"
141 assert result["tw_ticker"] == "2330.TW"
142 assert result["adr_ratio"] == 5
143 assert result["adr_price_usd"] == 175.50
144 assert result["tw_price_twd"] == 1050.0
145 assert result["premium_pct"] is not None
146 assert result["error"] is None
147
148 def test_all_required_keys_present(self):
149 result = build_result("TSM", PRICES_FULL, FX)
150 for key in (
151 "adr_ticker", "tw_ticker", "company_name", "exchange", "adr_ratio",
152 "adr_price_usd", "tw_price_twd", "tw_equivalent_usd",
153 "premium_pct", "premium_label", "error",
154 ):
155 assert key in result, f"Missing key: {key}"
156
157 def test_missing_adr_price_sets_error(self):
158 prices = {**PRICES_FULL, "TSM": None}
159 result = build_result("TSM", prices, FX)
160 assert result["error"] is not None
161 assert "TSM" in result["error"]
162 assert result["premium_pct"] is None
163
164 def test_missing_tw_price_sets_error(self):
165 prices = {**PRICES_FULL, "2330.TW": None}
166 result = build_result("TSM", prices, FX)
167 assert result["error"] is not None
168 assert result["premium_pct"] is None
169
170 def test_missing_fx_sets_error(self):
171 result = build_result("TSM", PRICES_FULL, fx_usd_twd=None)
172 assert result["error"] is not None
173 assert "FX" in result["error"]
174 assert result["premium_pct"] is None
175
176 def test_all_prices_missing_error_mentions_all(self):
177 prices = {**PRICES_FULL, "TSM": None, "2330.TW": None}
178 result = build_result("TSM", prices, fx_usd_twd=None)
179
180 assert "TSM" in result["error"]
181 assert "2330.TW" in result["error"]
182 assert "FX" in result["error"]
183
184 def test_company_name_populated(self):
185 result = build_result("TSM", PRICES_FULL, FX)
186 assert "TSMC" in result["company_name"] or "Taiwan Semiconductor" in result["company_name"]
187
188 def test_exchange_field(self):
189 tsm = build_result("TSM", PRICES_FULL, FX)
190 assert tsm["exchange"] == "NYSE"
191 himx = build_result("HIMX", PRICES_FULL, FX)
192 assert himx["exchange"] == "NASDAQ"
193
194
195
196
197
198
199class TestComputeAll:
200 def test_returns_one_row_per_ticker(self):
201 results = compute_all(["TSM", "UMC"], PRICES_FULL, FX)
202 assert len(results) == 2
203
204 def test_results_ordered_as_requested(self):
205 results = compute_all(["UMC", "TSM"], PRICES_FULL, FX)
206 assert results[0]["adr_ticker"] == "UMC"
207 assert results[1]["adr_ticker"] == "TSM"
208
209 def test_unknown_ticker_gets_error_row(self):
210 results = compute_all(["TSM", "FAKE"], PRICES_FULL, FX)
211 fake_row = next(r for r in results if r["adr_ticker"] == "FAKE")
212 assert fake_row["error"] is not None
213 assert "Unknown ticker" in fake_row["error"]
214
215 def test_all_default_tickers_computable(self):
216 results = compute_all(DEFAULT_TICKERS, PRICES_FULL, FX)
217 assert len(results) == len(DEFAULT_TICKERS)
218 for r in results:
219 assert r["error"] is None, f"{r['adr_ticker']} had error: {r['error']}"
220
221 def test_empty_ticker_list_returns_empty(self):
222 results = compute_all([], PRICES_FULL, FX)
223 assert results == []
224
225 def test_partial_price_failure_doesnt_block_others(self):
226 prices = {**PRICES_FULL, "TSM": None}
227 results = compute_all(["TSM", "UMC"], prices, FX)
228 tsm = next(r for r in results if r["adr_ticker"] == "TSM")
229 umc = next(r for r in results if r["adr_ticker"] == "UMC")
230 assert tsm["error"] is not None
231 assert umc["error"] is None
232 assert umc["premium_pct"] is not None
233
234
235
236
237
238
239class TestADRRegistry:
240 def test_all_registry_entries_have_required_keys(self):
241 for ticker, entry in ADR_REGISTRY.items():
242 for key in ("tw_ticker", "ratio", "name", "exchange"):
243 assert key in entry, f"{ticker} missing '{key}'"
244
245 def test_all_ratios_are_positive_integers(self):
246 for ticker, entry in ADR_REGISTRY.items():
247 assert isinstance(entry["ratio"], int), f"{ticker} ratio is not int"
248 assert entry["ratio"] > 0, f"{ticker} ratio must be positive"
249
250 def test_tw_tickers_end_with_dot_tw(self):
251 for ticker, entry in ADR_REGISTRY.items():
252 assert entry["tw_ticker"].endswith(".TW"), (
253 f"{ticker}: tw_ticker '{entry['tw_ticker']}' must end in .TW"
254 )
255
256 def test_exchanges_are_valid(self):
257 valid = {"NYSE", "NASDAQ"}
258 for ticker, entry in ADR_REGISTRY.items():
259 assert entry["exchange"] in valid, (
260 f"{ticker}: exchange '{entry['exchange']}' not in {valid}"
261 )
262
263 def test_default_tickers_all_in_registry(self):
264 for t in DEFAULT_TICKERS:
265 assert t in ADR_REGISTRY, f"DEFAULT_TICKERS contains unknown ticker: {t}"
266
267 def test_tsm_ratio_is_five(self):
268
269 assert ADR_REGISTRY["TSM"]["ratio"] == 5
270
271 def test_umc_ratio_is_five(self):
272
273
274 assert ADR_REGISTRY["UMC"]["ratio"] == 5
275
276 def test_cht_ratio_is_ten(self):
277
278
279 assert ADR_REGISTRY["CHT"]["ratio"] == 10
280
281 def test_asx_ratio_is_two(self):
282
283
284 assert ADR_REGISTRY["ASX"]["ratio"] == 2
285
286 def test_himx_ratio_is_two(self):
287
288 assert ADR_REGISTRY["HIMX"]["ratio"] == 2
289
290 def test_imos_ratio_is_twenty(self):
291
292
293 assert ADR_REGISTRY["IMOS"]["ratio"] == 20
294
295 def test_tsm_tw_ticker(self):
296 assert ADR_REGISTRY["TSM"]["tw_ticker"] == "2330.TW"