optionsahoy 0.1.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ build/
7
+ dist/
8
+ *.pyc
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: optionsahoy
3
+ Version: 0.1.0
4
+ Summary: Thin Python client for the OptionsAhoy keyless public equity-compensation REST API.
5
+ Project-URL: Homepage, https://optionsahoy.com
6
+ Project-URL: Repository, https://github.com/AlvisoOculus/optionsahoy-mcp
7
+ Project-URL: Documentation, https://optionsahoy.com/for-agents
8
+ Author: AlphaLatitude Inc.
9
+ License: MIT
10
+ Keywords: amt,equity-compensation,iso,nso,options,qsbs,rsu
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Office/Business :: Financial
15
+ Requires-Python: >=3.9
16
+ Requires-Dist: httpx>=0.27
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == 'dev'
19
+ Requires-Dist: respx>=0.21; extra == 'dev'
20
+ Requires-Dist: ruff>=0.5; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # optionsahoy
24
+
25
+ A thin, dependency-light Python client for the OptionsAhoy keyless public REST API.
26
+ It wraps the equity-compensation calculators (incentive stock option (ISO) /
27
+ alternative minimum tax (AMT), non-qualified stock options (NSO), restricted stock
28
+ units (RSU), single-stock concentration, protective put hedges, qualified small
29
+ business stock (QSBS), and funding a cash goal from equity).
30
+
31
+ No API key is required, read, or sent anywhere. The only runtime dependency is
32
+ [httpx](https://www.python-httpx.org/).
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install optionsahoy
38
+ ```
39
+
40
+ Or from this repository, editable:
41
+
42
+ ```bash
43
+ pip install -e integrations/python/optionsahoy
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from optionsahoy import OptionsAhoyClient, OptionsAhoyError
50
+
51
+ client = OptionsAhoyClient() # base_url defaults to https://optionsahoy.com
52
+
53
+ try:
54
+ result = client.qsbs(
55
+ acquisitionDate="2018-01-01",
56
+ saleDate="2026-02-01",
57
+ entityType="us-c-corp",
58
+ acquisitionMethod="original-issuance",
59
+ assetCategory="under-50m",
60
+ industry="tech-software",
61
+ activeBusiness="yes",
62
+ adjustedBasis=10000,
63
+ expectedGain=2000000,
64
+ stateCode="CA",
65
+ ordinaryIncome=250000,
66
+ filingStatus="single",
67
+ )
68
+ print(result)
69
+ except OptionsAhoyError as err:
70
+ print(err.status_code, err.payload)
71
+ ```
72
+
73
+ Field names, types, and required-ness mirror the published OpenAPI schema at
74
+ <https://optionsahoy.com/openapi.json>. Optional fields left unset are not sent.
75
+
76
+ ### Forward-looking inputs
77
+
78
+ Some endpoints (for example `nso` and `rsu_sell_vs_hold`) accept forward-looking
79
+ fields such as `expectedSalePrice` and `volatility` that the OpenAPI schema marks
80
+ optional. At runtime the API requires you to supply these explicitly, or to set a
81
+ covered `ticker` (for example `"NVDA"`) so the API can derive them from that
82
+ symbol's trailing data. Do not invent these values; pass what the user provides or a
83
+ ticker. Omitting both returns a clear 400 explaining which field is needed.
84
+
85
+ ## Methods
86
+
87
+ - `amt_iso(...)` — multi-year ISO exercise optimizer under the AMT
88
+ - `nso(...)` — NSO exercise tax and after-tax proceeds
89
+ - `rsu_sell_vs_hold(...)` — sell-at-vest versus hold for RSUs
90
+ - `concentration(...)` — concentrated single-stock position analysis
91
+ - `protective_put(...)` — protective put hedge pricing
92
+ - `qsbs(...)` — QSBS eligibility and capital-gains exclusion
93
+ - `equity_funding(...)` — plan equity sales to fund a cash goal by a target date
@@ -0,0 +1,71 @@
1
+ # optionsahoy
2
+
3
+ A thin, dependency-light Python client for the OptionsAhoy keyless public REST API.
4
+ It wraps the equity-compensation calculators (incentive stock option (ISO) /
5
+ alternative minimum tax (AMT), non-qualified stock options (NSO), restricted stock
6
+ units (RSU), single-stock concentration, protective put hedges, qualified small
7
+ business stock (QSBS), and funding a cash goal from equity).
8
+
9
+ No API key is required, read, or sent anywhere. The only runtime dependency is
10
+ [httpx](https://www.python-httpx.org/).
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install optionsahoy
16
+ ```
17
+
18
+ Or from this repository, editable:
19
+
20
+ ```bash
21
+ pip install -e integrations/python/optionsahoy
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ from optionsahoy import OptionsAhoyClient, OptionsAhoyError
28
+
29
+ client = OptionsAhoyClient() # base_url defaults to https://optionsahoy.com
30
+
31
+ try:
32
+ result = client.qsbs(
33
+ acquisitionDate="2018-01-01",
34
+ saleDate="2026-02-01",
35
+ entityType="us-c-corp",
36
+ acquisitionMethod="original-issuance",
37
+ assetCategory="under-50m",
38
+ industry="tech-software",
39
+ activeBusiness="yes",
40
+ adjustedBasis=10000,
41
+ expectedGain=2000000,
42
+ stateCode="CA",
43
+ ordinaryIncome=250000,
44
+ filingStatus="single",
45
+ )
46
+ print(result)
47
+ except OptionsAhoyError as err:
48
+ print(err.status_code, err.payload)
49
+ ```
50
+
51
+ Field names, types, and required-ness mirror the published OpenAPI schema at
52
+ <https://optionsahoy.com/openapi.json>. Optional fields left unset are not sent.
53
+
54
+ ### Forward-looking inputs
55
+
56
+ Some endpoints (for example `nso` and `rsu_sell_vs_hold`) accept forward-looking
57
+ fields such as `expectedSalePrice` and `volatility` that the OpenAPI schema marks
58
+ optional. At runtime the API requires you to supply these explicitly, or to set a
59
+ covered `ticker` (for example `"NVDA"`) so the API can derive them from that
60
+ symbol's trailing data. Do not invent these values; pass what the user provides or a
61
+ ticker. Omitting both returns a clear 400 explaining which field is needed.
62
+
63
+ ## Methods
64
+
65
+ - `amt_iso(...)` — multi-year ISO exercise optimizer under the AMT
66
+ - `nso(...)` — NSO exercise tax and after-tax proceeds
67
+ - `rsu_sell_vs_hold(...)` — sell-at-vest versus hold for RSUs
68
+ - `concentration(...)` — concentrated single-stock position analysis
69
+ - `protective_put(...)` — protective put hedge pricing
70
+ - `qsbs(...)` — QSBS eligibility and capital-gains exclusion
71
+ - `equity_funding(...)` — plan equity sales to fund a cash goal by a target date
@@ -0,0 +1,10 @@
1
+ """OptionsAhoy: a thin, dependency-light client for the keyless public REST API."""
2
+
3
+ from optionsahoy.client import (
4
+ DEFAULT_BASE_URL,
5
+ OptionsAhoyClient,
6
+ OptionsAhoyError,
7
+ )
8
+
9
+ __all__ = ["OptionsAhoyClient", "OptionsAhoyError", "DEFAULT_BASE_URL"]
10
+ __version__ = "0.1.0"
@@ -0,0 +1,383 @@
1
+ """HTTP client for the OptionsAhoy keyless public REST API.
2
+
3
+ Every endpoint is an unauthenticated POST (except the discovery/stats GETs, which
4
+ this client does not wrap). Field names, types, and required-ness mirror the
5
+ published OpenAPI schema at https://optionsahoy.com/openapi.json one for one; this
6
+ client does not invent or rename fields.
7
+
8
+ No API key is read, stored, or sent anywhere.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ import httpx
16
+
17
+ DEFAULT_BASE_URL = "https://optionsahoy.com"
18
+ DEFAULT_TIMEOUT = 30.0
19
+
20
+
21
+ class OptionsAhoyError(Exception):
22
+ """Raised when the OptionsAhoy API returns an HTTP error or an unusable response.
23
+
24
+ Attributes:
25
+ status_code: HTTP status code, when one is available.
26
+ payload: Parsed error body (typically ``{"error": "..."}``), when available.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ message: str,
32
+ *,
33
+ status_code: Optional[int] = None,
34
+ payload: Optional[Any] = None,
35
+ ) -> None:
36
+ super().__init__(message)
37
+ self.status_code = status_code
38
+ self.payload = payload
39
+
40
+
41
+ def _drop_none(payload: Dict[str, Any]) -> Dict[str, Any]:
42
+ """Strip keys whose value is None so optional, unset fields are not posted.
43
+
44
+ ``terminationDate`` is intentionally kept even when None: the AMT/ISO schema
45
+ requires it and treats null as a meaningful "no termination" value.
46
+ """
47
+ keep_null = {"terminationDate"}
48
+ return {k: v for k, v in payload.items() if v is not None or k in keep_null}
49
+
50
+
51
+ class OptionsAhoyClient:
52
+ """Synchronous client wrapping the OptionsAhoy calculator endpoints.
53
+
54
+ Example:
55
+ >>> client = OptionsAhoyClient()
56
+ >>> result = client.qsbs(
57
+ ... acquisitionDate="2018-01-01",
58
+ ... saleDate="2026-02-01",
59
+ ... entityType="us-c-corp",
60
+ ... acquisitionMethod="original-issuance",
61
+ ... assetCategory="under-50m",
62
+ ... industry="tech-software",
63
+ ... activeBusiness="yes",
64
+ ... adjustedBasis=10000,
65
+ ... expectedGain=2000000,
66
+ ... stateCode="CA",
67
+ ... ordinaryIncome=250000,
68
+ ... filingStatus="single",
69
+ ... )
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ base_url: str = DEFAULT_BASE_URL,
75
+ *,
76
+ timeout: float = DEFAULT_TIMEOUT,
77
+ client: Optional[httpx.Client] = None,
78
+ ) -> None:
79
+ self.base_url = base_url.rstrip("/")
80
+ self._timeout = timeout
81
+ self._client = client or httpx.Client(timeout=timeout)
82
+
83
+ def __enter__(self) -> "OptionsAhoyClient":
84
+ return self
85
+
86
+ def __exit__(self, *exc: Any) -> None:
87
+ self.close()
88
+
89
+ def close(self) -> None:
90
+ self._client.close()
91
+
92
+ # -- transport ---------------------------------------------------------
93
+
94
+ def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
95
+ url = f"{self.base_url}{path}"
96
+ body = _drop_none(payload)
97
+ try:
98
+ response = self._client.post(url, json=body)
99
+ response.raise_for_status()
100
+ except httpx.HTTPStatusError as exc:
101
+ detail: Any = None
102
+ try:
103
+ detail = exc.response.json()
104
+ except Exception: # noqa: BLE001 - body may not be JSON
105
+ detail = exc.response.text
106
+ message = f"OptionsAhoy request to {path} failed ({exc.response.status_code})"
107
+ if isinstance(detail, dict) and detail.get("error"):
108
+ message = f"{message}: {detail['error']}"
109
+ raise OptionsAhoyError(
110
+ message, status_code=exc.response.status_code, payload=detail
111
+ ) from exc
112
+ except httpx.HTTPError as exc:
113
+ raise OptionsAhoyError(f"OptionsAhoy request to {path} failed: {exc}") from exc
114
+
115
+ try:
116
+ return response.json()
117
+ except ValueError as exc:
118
+ raise OptionsAhoyError(
119
+ f"OptionsAhoy response from {path} was not valid JSON"
120
+ ) from exc
121
+
122
+ # -- endpoints ---------------------------------------------------------
123
+
124
+ def amt_iso(
125
+ self,
126
+ *,
127
+ shares: int,
128
+ strike: float,
129
+ fmv: float,
130
+ filingStatus: str,
131
+ ordinaryIncome: float,
132
+ stateCode: str,
133
+ carryforwardCredit: float,
134
+ horizon: int,
135
+ cashReturnRate: float,
136
+ grantDate: str,
137
+ hasLeftCompany: bool,
138
+ terminationDate: Optional[str],
139
+ expectedGrowth: Optional[float] = None,
140
+ ticker: Optional[str] = None,
141
+ volatilityDrag: Optional[float] = None,
142
+ volatility: Optional[float] = None,
143
+ ) -> Dict[str, Any]:
144
+ """Optimize a multi-year incentive stock option (ISO) exercise schedule under the
145
+ alternative minimum tax (AMT)."""
146
+ return self._post(
147
+ "/api/v1/amt-iso",
148
+ {
149
+ "shares": shares,
150
+ "strike": strike,
151
+ "fmv": fmv,
152
+ "filingStatus": filingStatus,
153
+ "ordinaryIncome": ordinaryIncome,
154
+ "stateCode": stateCode,
155
+ "carryforwardCredit": carryforwardCredit,
156
+ "horizon": horizon,
157
+ "cashReturnRate": cashReturnRate,
158
+ "grantDate": grantDate,
159
+ "hasLeftCompany": hasLeftCompany,
160
+ "terminationDate": terminationDate,
161
+ "expectedGrowth": expectedGrowth,
162
+ "ticker": ticker,
163
+ "volatilityDrag": volatilityDrag,
164
+ "volatility": volatility,
165
+ },
166
+ )
167
+
168
+ def nso(
169
+ self,
170
+ *,
171
+ shares: int,
172
+ strike: float,
173
+ currentPrice: float,
174
+ ordinaryIncome: float,
175
+ filingStatus: str,
176
+ stateCode: str,
177
+ stillEmployed: bool,
178
+ holdYears: float,
179
+ holdFunding: str,
180
+ expectedSalePrice: Optional[float] = None,
181
+ haircut: Optional[float] = None,
182
+ volatility: Optional[float] = None,
183
+ expectedMarketReturn: Optional[float] = None,
184
+ ticker: Optional[str] = None,
185
+ ) -> Dict[str, Any]:
186
+ """Compute the tax and after-tax proceeds of exercising non-qualified stock
187
+ options (NSOs) and holding versus selling."""
188
+ return self._post(
189
+ "/api/v1/nso",
190
+ {
191
+ "shares": shares,
192
+ "strike": strike,
193
+ "currentPrice": currentPrice,
194
+ "ordinaryIncome": ordinaryIncome,
195
+ "filingStatus": filingStatus,
196
+ "stateCode": stateCode,
197
+ "stillEmployed": stillEmployed,
198
+ "holdYears": holdYears,
199
+ "holdFunding": holdFunding,
200
+ "expectedSalePrice": expectedSalePrice,
201
+ "haircut": haircut,
202
+ "volatility": volatility,
203
+ "expectedMarketReturn": expectedMarketReturn,
204
+ "ticker": ticker,
205
+ },
206
+ )
207
+
208
+ def rsu_sell_vs_hold(
209
+ self,
210
+ *,
211
+ shares: int,
212
+ currentPrice: float,
213
+ ordinaryIncome: float,
214
+ filingStatus: str,
215
+ stateCode: str,
216
+ stillEmployed: bool,
217
+ holdYears: float,
218
+ expectedSalePrice: Optional[float] = None,
219
+ haircut: Optional[float] = None,
220
+ volatility: Optional[float] = None,
221
+ expectedMarketReturn: Optional[float] = None,
222
+ ticker: Optional[str] = None,
223
+ ) -> Dict[str, Any]:
224
+ """Compare selling vested restricted stock units (RSUs) at vest against holding
225
+ them, on an after-tax, risk-adjusted basis."""
226
+ return self._post(
227
+ "/api/v1/rsu-sell-vs-hold",
228
+ {
229
+ "shares": shares,
230
+ "currentPrice": currentPrice,
231
+ "ordinaryIncome": ordinaryIncome,
232
+ "filingStatus": filingStatus,
233
+ "stateCode": stateCode,
234
+ "stillEmployed": stillEmployed,
235
+ "holdYears": holdYears,
236
+ "expectedSalePrice": expectedSalePrice,
237
+ "haircut": haircut,
238
+ "volatility": volatility,
239
+ "expectedMarketReturn": expectedMarketReturn,
240
+ "ticker": ticker,
241
+ },
242
+ )
243
+
244
+ def concentration(
245
+ self,
246
+ *,
247
+ positionValue: float,
248
+ costBasis: float,
249
+ acquisitionDate: str,
250
+ sector: str,
251
+ stateCode: str,
252
+ filingStatus: str,
253
+ ordinaryIncome: float,
254
+ totalAssets: float,
255
+ expectedPositionReturn: Optional[float] = None,
256
+ expectedMarketReturn: Optional[float] = None,
257
+ ticker: Optional[str] = None,
258
+ volatilityDrag: Optional[float] = None,
259
+ volatility: Optional[float] = None,
260
+ hedgeChoice: Optional[Dict[str, Any]] = None,
261
+ ) -> Dict[str, Any]:
262
+ """Analyze a concentrated single-stock position and the after-tax cost of
263
+ diversifying it."""
264
+ return self._post(
265
+ "/api/v1/concentration",
266
+ {
267
+ "positionValue": positionValue,
268
+ "costBasis": costBasis,
269
+ "acquisitionDate": acquisitionDate,
270
+ "sector": sector,
271
+ "stateCode": stateCode,
272
+ "filingStatus": filingStatus,
273
+ "ordinaryIncome": ordinaryIncome,
274
+ "totalAssets": totalAssets,
275
+ "expectedPositionReturn": expectedPositionReturn,
276
+ "expectedMarketReturn": expectedMarketReturn,
277
+ "ticker": ticker,
278
+ "volatilityDrag": volatilityDrag,
279
+ "volatility": volatility,
280
+ "hedgeChoice": hedgeChoice,
281
+ },
282
+ )
283
+
284
+ def protective_put(
285
+ self,
286
+ *,
287
+ positionValue: float,
288
+ sector: str,
289
+ protectionLevel: float,
290
+ tenorYears: float,
291
+ volatility: Optional[float] = None,
292
+ expectedReturn: Optional[float] = None,
293
+ tickerLabel: Optional[str] = None,
294
+ ) -> Dict[str, Any]:
295
+ """Price a protective put hedge for a stock position at a given downside
296
+ protection level and tenor."""
297
+ return self._post(
298
+ "/api/v1/protective-put",
299
+ {
300
+ "positionValue": positionValue,
301
+ "sector": sector,
302
+ "protectionLevel": protectionLevel,
303
+ "tenorYears": tenorYears,
304
+ "volatility": volatility,
305
+ "expectedReturn": expectedReturn,
306
+ "tickerLabel": tickerLabel,
307
+ },
308
+ )
309
+
310
+ def qsbs(
311
+ self,
312
+ *,
313
+ acquisitionDate: str,
314
+ saleDate: str,
315
+ entityType: str,
316
+ acquisitionMethod: str,
317
+ assetCategory: str,
318
+ industry: str,
319
+ activeBusiness: str,
320
+ adjustedBasis: float,
321
+ expectedGain: float,
322
+ stateCode: str,
323
+ ordinaryIncome: float,
324
+ filingStatus: str,
325
+ ) -> Dict[str, Any]:
326
+ """Check qualified small business stock (QSBS) eligibility and the resulting
327
+ federal and state capital-gains exclusion."""
328
+ return self._post(
329
+ "/api/v1/qsbs",
330
+ {
331
+ "acquisitionDate": acquisitionDate,
332
+ "saleDate": saleDate,
333
+ "entityType": entityType,
334
+ "acquisitionMethod": acquisitionMethod,
335
+ "assetCategory": assetCategory,
336
+ "industry": industry,
337
+ "activeBusiness": activeBusiness,
338
+ "adjustedBasis": adjustedBasis,
339
+ "expectedGain": expectedGain,
340
+ "stateCode": stateCode,
341
+ "ordinaryIncome": ordinaryIncome,
342
+ "filingStatus": filingStatus,
343
+ },
344
+ )
345
+
346
+ def equity_funding(
347
+ self,
348
+ *,
349
+ targetAfterTax: float,
350
+ targetDate: str,
351
+ ordinaryIncome: float,
352
+ filingStatus: str,
353
+ stateCode: str,
354
+ stacks: Optional[List[Dict[str, Any]]] = None,
355
+ lots: Optional[List[Dict[str, Any]]] = None,
356
+ currentPrice: Optional[float] = None,
357
+ expectedAnnualGrowth: Optional[float] = None,
358
+ cashInterestRate: Optional[float] = None,
359
+ riskToleranceShortfall: Optional[float] = None,
360
+ defaultVolatility: Optional[float] = None,
361
+ today: Optional[str] = None,
362
+ ) -> Dict[str, Any]:
363
+ """Plan which equity lots to sell, and when, to fund a cash goal by a target date
364
+ with the least after-tax cost. Provide ``stacks`` (preferred) or the legacy
365
+ ``lots`` plus ``currentPrice``."""
366
+ return self._post(
367
+ "/api/v1/equity-funding",
368
+ {
369
+ "targetAfterTax": targetAfterTax,
370
+ "targetDate": targetDate,
371
+ "ordinaryIncome": ordinaryIncome,
372
+ "filingStatus": filingStatus,
373
+ "stateCode": stateCode,
374
+ "stacks": stacks,
375
+ "lots": lots,
376
+ "currentPrice": currentPrice,
377
+ "expectedAnnualGrowth": expectedAnnualGrowth,
378
+ "cashInterestRate": cashInterestRate,
379
+ "riskToleranceShortfall": riskToleranceShortfall,
380
+ "defaultVolatility": defaultVolatility,
381
+ "today": today,
382
+ },
383
+ )
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "optionsahoy"
7
+ version = "0.1.0"
8
+ description = "Thin Python client for the OptionsAhoy keyless public equity-compensation REST API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "AlphaLatitude Inc." }]
13
+ keywords = ["equity-compensation", "iso", "amt", "nso", "rsu", "qsbs", "options"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Office/Business :: Financial",
19
+ ]
20
+ dependencies = ["httpx>=0.27"]
21
+
22
+ [project.urls]
23
+ Homepage = "https://optionsahoy.com"
24
+ Repository = "https://github.com/AlvisoOculus/optionsahoy-mcp"
25
+ Documentation = "https://optionsahoy.com/for-agents"
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8", "respx>=0.21", "ruff>=0.5"]
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["optionsahoy"]
32
+
33
+ [tool.pytest.ini_options]
34
+ markers = [
35
+ "live: hits the real OptionsAhoy API (skipped unless OA_LIVE=1 is set).",
36
+ ]
37
+
38
+ [tool.ruff]
39
+ line-length = 100
@@ -0,0 +1,217 @@
1
+ """Mocked-transport tests for the OptionsAhoy client.
2
+
3
+ The HTTP layer is mocked with respx so each test asserts the method posts to the
4
+ correct path with the correct JSON body and parses the response, without hitting
5
+ the network.
6
+ """
7
+
8
+ import os
9
+
10
+ import httpx
11
+ import pytest
12
+ import respx
13
+
14
+ from optionsahoy import OptionsAhoyClient, OptionsAhoyError
15
+
16
+ BASE = "https://optionsahoy.com"
17
+
18
+ # Minimal, schema-valid kwargs per endpoint. Method name -> (path, kwargs).
19
+ CASES = {
20
+ "amt_iso": (
21
+ "/api/v1/amt-iso",
22
+ dict(
23
+ shares=1000,
24
+ strike=2.0,
25
+ fmv=20.0,
26
+ filingStatus="single",
27
+ ordinaryIncome=200000,
28
+ stateCode="CA",
29
+ carryforwardCredit=0,
30
+ horizon=5,
31
+ cashReturnRate=0.04,
32
+ grantDate="2021-01-01",
33
+ hasLeftCompany=False,
34
+ terminationDate=None,
35
+ ),
36
+ ),
37
+ "nso": (
38
+ "/api/v1/nso",
39
+ dict(
40
+ shares=1000,
41
+ strike=2.0,
42
+ currentPrice=20.0,
43
+ ordinaryIncome=200000,
44
+ filingStatus="single",
45
+ stateCode="CA",
46
+ stillEmployed=True,
47
+ holdYears=2,
48
+ holdFunding="cash",
49
+ ),
50
+ ),
51
+ "rsu_sell_vs_hold": (
52
+ "/api/v1/rsu-sell-vs-hold",
53
+ dict(
54
+ shares=500,
55
+ currentPrice=50.0,
56
+ ordinaryIncome=200000,
57
+ filingStatus="single",
58
+ stateCode="CA",
59
+ stillEmployed=True,
60
+ holdYears=1,
61
+ ),
62
+ ),
63
+ "concentration": (
64
+ "/api/v1/concentration",
65
+ dict(
66
+ positionValue=500000,
67
+ costBasis=50000,
68
+ acquisitionDate="2020-01-01",
69
+ sector="tech_software",
70
+ stateCode="CA",
71
+ filingStatus="single",
72
+ ordinaryIncome=200000,
73
+ totalAssets=800000,
74
+ ),
75
+ ),
76
+ "protective_put": (
77
+ "/api/v1/protective-put",
78
+ dict(
79
+ positionValue=500000,
80
+ sector="tech_software",
81
+ protectionLevel=0.1,
82
+ tenorYears=1,
83
+ ),
84
+ ),
85
+ "qsbs": (
86
+ "/api/v1/qsbs",
87
+ dict(
88
+ acquisitionDate="2018-01-01",
89
+ saleDate="2026-02-01",
90
+ entityType="us-c-corp",
91
+ acquisitionMethod="original-issuance",
92
+ assetCategory="under-50m",
93
+ industry="tech-software",
94
+ activeBusiness="yes",
95
+ adjustedBasis=10000,
96
+ expectedGain=2000000,
97
+ stateCode="CA",
98
+ ordinaryIncome=250000,
99
+ filingStatus="single",
100
+ ),
101
+ ),
102
+ "equity_funding": (
103
+ "/api/v1/equity-funding",
104
+ dict(
105
+ targetAfterTax=200000,
106
+ targetDate="2027-01-01",
107
+ ordinaryIncome=200000,
108
+ filingStatus="single",
109
+ stateCode="CA",
110
+ stacks=[
111
+ {
112
+ "currentPrice": 50.0,
113
+ "lots": [
114
+ {
115
+ "shares": 1000,
116
+ "costBasisPerShare": 10.0,
117
+ "acquisitionDate": "2021-01-01",
118
+ }
119
+ ],
120
+ }
121
+ ],
122
+ ),
123
+ ),
124
+ }
125
+
126
+
127
+ @pytest.fixture
128
+ def client():
129
+ return OptionsAhoyClient()
130
+
131
+
132
+ @pytest.mark.parametrize("method_name", list(CASES))
133
+ @respx.mock
134
+ def test_method_posts_correct_path_and_body(client, method_name):
135
+ path, kwargs = CASES[method_name]
136
+ sentinel = {"ok": True, "result": {"method": method_name}}
137
+ route = respx.post(f"{BASE}{path}").mock(
138
+ return_value=httpx.Response(200, json=sentinel)
139
+ )
140
+
141
+ result = getattr(client, method_name)(**kwargs)
142
+
143
+ assert route.called
144
+ assert result == sentinel
145
+ request = route.calls.last.request
146
+ import json
147
+
148
+ sent = json.loads(request.content)
149
+ # Every supplied kwarg (None stripped, except terminationDate) is in the body.
150
+ for key, value in kwargs.items():
151
+ if value is None and key != "terminationDate":
152
+ continue
153
+ assert sent[key] == value
154
+
155
+
156
+ @respx.mock
157
+ def test_none_optionals_are_stripped(client):
158
+ route = respx.post(f"{BASE}/api/v1/nso").mock(
159
+ return_value=httpx.Response(200, json={"ok": True})
160
+ )
161
+ client.nso(**CASES["nso"][1])
162
+ import json
163
+
164
+ sent = json.loads(route.calls.last.request.content)
165
+ assert "haircut" not in sent
166
+ assert "ticker" not in sent
167
+
168
+
169
+ @respx.mock
170
+ def test_termination_date_null_is_kept(client):
171
+ route = respx.post(f"{BASE}/api/v1/amt-iso").mock(
172
+ return_value=httpx.Response(200, json={"ok": True})
173
+ )
174
+ client.amt_iso(**CASES["amt_iso"][1])
175
+ import json
176
+
177
+ sent = json.loads(route.calls.last.request.content)
178
+ assert "terminationDate" in sent
179
+ assert sent["terminationDate"] is None
180
+
181
+
182
+ @respx.mock
183
+ def test_http_error_raises_optionsahoy_error(client):
184
+ respx.post(f"{BASE}/api/v1/qsbs").mock(
185
+ return_value=httpx.Response(400, json={"error": "bad input"})
186
+ )
187
+ with pytest.raises(OptionsAhoyError) as exc:
188
+ client.qsbs(**CASES["qsbs"][1])
189
+ assert exc.value.status_code == 400
190
+ assert exc.value.payload == {"error": "bad input"}
191
+ assert "bad input" in str(exc.value)
192
+
193
+
194
+ @respx.mock
195
+ def test_non_json_response_raises(client):
196
+ respx.post(f"{BASE}/api/v1/nso").mock(
197
+ return_value=httpx.Response(200, text="not json")
198
+ )
199
+ with pytest.raises(OptionsAhoyError):
200
+ client.nso(**CASES["nso"][1])
201
+
202
+
203
+ def test_custom_base_url_strips_trailing_slash():
204
+ c = OptionsAhoyClient(base_url="https://example.test/")
205
+ assert c.base_url == "https://example.test"
206
+
207
+
208
+ # --- live smoke -----------------------------------------------------------
209
+
210
+
211
+ @pytest.mark.live
212
+ @pytest.mark.skipif(os.environ.get("OA_LIVE") != "1", reason="set OA_LIVE=1 to run")
213
+ def test_live_qsbs_returns_top_level_key():
214
+ client = OptionsAhoyClient()
215
+ result = client.qsbs(**CASES["qsbs"][1])
216
+ assert isinstance(result, dict)
217
+ assert "ok" in result or "result" in result, result