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,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
|