From d46b5a9e0f704bc24a5992cab3afbdd22875ad1d Mon Sep 17 00:00:00 2001 From: AlvisoOculus Date: Sat, 13 Jun 2026 05:07:43 -0700 Subject: [PATCH 1/3] feat(tools): add OptionsAhoyTool for equity-compensation tax calculations Add a keyless OptionsAhoyTool that wraps the OptionsAhoy public REST API. A single tool exposes seven calculators via a calculator selector and an inputs object: incentive stock option (ISO) exercise under the alternative minimum tax (AMT), non-qualified stock option (NSO) exercise, restricted stock unit (RSU) sell-versus-hold, single-stock concentration, protective put pricing, qualified small business stock (QSBS) eligibility, and funding a cash goal from equity lots. Uses the existing requests dependency, requires no API key, and registers in the package exports. Includes a README and mocked unit tests with no network access. --- lib/crewai-tools/src/crewai_tools/__init__.py | 2 + .../src/crewai_tools/tools/__init__.py | 2 + .../tools/optionsahoy_tool/README.md | 75 ++++++ .../tools/optionsahoy_tool/__init__.py | 0 .../optionsahoy_tool/optionsahoy_tool.py | 218 ++++++++++++++++++ .../tests/tools/optionsahoy_tool_test.py | 134 +++++++++++ 6 files changed, 431 insertions(+) create mode 100644 lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/README.md create mode 100644 lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/__init__.py create mode 100644 lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/optionsahoy_tool.py create mode 100644 lib/crewai-tools/tests/tools/optionsahoy_tool_test.py diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 8cb5ed1148..7e80714587 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -119,6 +119,7 @@ from crewai_tools.tools.mysql_search_tool.mysql_search_tool import MySQLSearchTool from crewai_tools.tools.nl2sql.nl2sql_tool import NL2SQLTool from crewai_tools.tools.ocr_tool.ocr_tool import OCRTool +from crewai_tools.tools.optionsahoy_tool.optionsahoy_tool import OptionsAhoyTool from crewai_tools.tools.oxylabs_amazon_product_scraper_tool.oxylabs_amazon_product_scraper_tool import ( OxylabsAmazonProductScraperTool, ) @@ -282,6 +283,7 @@ "MySQLSearchTool", "NL2SQLTool", "OCRTool", + "OptionsAhoyTool", "OxylabsAmazonProductScraperTool", "OxylabsAmazonSearchScraperTool", "OxylabsGoogleSearchScraperTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index 18bf4e5638..348610f7b9 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -109,6 +109,7 @@ from crewai_tools.tools.mysql_search_tool.mysql_search_tool import MySQLSearchTool from crewai_tools.tools.nl2sql.nl2sql_tool import NL2SQLTool from crewai_tools.tools.ocr_tool.ocr_tool import OCRTool +from crewai_tools.tools.optionsahoy_tool.optionsahoy_tool import OptionsAhoyTool from crewai_tools.tools.oxylabs_amazon_product_scraper_tool.oxylabs_amazon_product_scraper_tool import ( OxylabsAmazonProductScraperTool, ) @@ -266,6 +267,7 @@ "MySQLSearchTool", "NL2SQLTool", "OCRTool", + "OptionsAhoyTool", "OxylabsAmazonProductScraperTool", "OxylabsAmazonSearchScraperTool", "OxylabsGoogleSearchScraperTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/README.md b/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/README.md new file mode 100644 index 0000000000..6585c9183e --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/README.md @@ -0,0 +1,75 @@ +# OptionsAhoyTool + +## Description + +`OptionsAhoyTool` computes the tax outcome of common equity-compensation decisions +using the [OptionsAhoy](https://optionsahoy.com) public REST API. The API is keyless +and runs each calculation against the federal tax code and all fifty states plus the +District of Columbia. + +A single tool exposes seven calculators through a `calculator` selector and an +`inputs` object: + +| `calculator` | What it computes | +| ------------------ | ---------------- | +| `amt-iso` | Optimizes a multi-year incentive stock option (ISO) exercise schedule under the alternative minimum tax (AMT). | +| `nso` | Tax and after-tax proceeds of exercising non-qualified stock options (NSOs), holding versus selling. | +| `rsu-sell-vs-hold` | Selling vested restricted stock units (RSUs) at vest versus holding them, on an after-tax, risk-adjusted basis. | +| `concentration` | Analyzes a concentrated single-stock position and the after-tax cost of diversifying it. | +| `protective-put` | Prices a protective put hedge at a given downside protection level and tenor. | +| `qsbs` | Checks qualified small business stock (QSBS) eligibility and the resulting capital-gains exclusion. | +| `equity-funding` | Plans which equity lots to sell, and when, to fund a cash goal by a target date at the least after-tax cost. | + +No API key is read, stored, or sent. Field names in `inputs` match the published +schema at `https://optionsahoy.com/openapi.json` exactly. + +## Installation + +The tool ships with `crewai-tools` and uses `requests`, which is already a core +dependency. No extra install is required. + +```shell +pip install crewai[tools] +``` + +## Example + +```python +from crewai_tools import OptionsAhoyTool + +tool = OptionsAhoyTool() + +result = tool.run( + calculator="qsbs", + inputs={ + "acquisitionDate": "2018-01-01", + "saleDate": "2026-02-01", + "entityType": "us-c-corp", + "acquisitionMethod": "original-issuance", + "assetCategory": "under-50m", + "industry": "tech-software", + "activeBusiness": "yes", + "adjustedBasis": 10000, + "expectedGain": 2000000, + "stateCode": "CA", + "ordinaryIncome": 250000, + "filingStatus": "single", + }, +) +``` + +The tool returns the calculator's result as a JSON string. + +## Arguments + +- `calculator` (required): one of `amt-iso`, `nso`, `rsu-sell-vs-hold`, + `concentration`, `protective-put`, `qsbs`, `equity-funding`. +- `inputs` (required): a JSON object of inputs for the chosen calculator. Money values + are plain numbers, dates are ISO strings (`YYYY-MM-DD`), and US state codes are two + letters. + +The tool can be constructed with a custom `base_url` or `timeout` if needed: + +```python +tool = OptionsAhoyTool(timeout=60) +``` diff --git a/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/optionsahoy_tool.py b/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/optionsahoy_tool.py new file mode 100644 index 0000000000..58af9ccdea --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/optionsahoy_tool.py @@ -0,0 +1,218 @@ +import json +from typing import Any, Literal + +from crewai.tools import BaseTool +from pydantic import BaseModel, Field +import requests + + +CalculatorName = Literal[ + "amt-iso", + "nso", + "rsu-sell-vs-hold", + "concentration", + "protective-put", + "qsbs", + "equity-funding", +] + +# Required input fields per calculator, mirroring the published OpenAPI schema at +# https://optionsahoy.com/openapi.json. These are used for a friendly pre-flight +# check; the API remains the source of truth for validation. +REQUIRED_FIELDS: dict[str, tuple[str, ...]] = { + "amt-iso": ( + "shares", + "strike", + "fmv", + "filingStatus", + "ordinaryIncome", + "stateCode", + "carryforwardCredit", + "horizon", + "cashReturnRate", + "grantDate", + "hasLeftCompany", + "terminationDate", + ), + "nso": ( + "shares", + "strike", + "currentPrice", + "ordinaryIncome", + "filingStatus", + "stateCode", + "stillEmployed", + "holdYears", + "holdFunding", + ), + "rsu-sell-vs-hold": ( + "shares", + "currentPrice", + "ordinaryIncome", + "filingStatus", + "stateCode", + "stillEmployed", + "holdYears", + ), + "concentration": ( + "positionValue", + "costBasis", + "acquisitionDate", + "sector", + "stateCode", + "filingStatus", + "ordinaryIncome", + "totalAssets", + ), + "protective-put": ( + "positionValue", + "sector", + "protectionLevel", + "tenorYears", + ), + "qsbs": ( + "acquisitionDate", + "saleDate", + "entityType", + "acquisitionMethod", + "assetCategory", + "industry", + "activeBusiness", + "adjustedBasis", + "expectedGain", + "stateCode", + "ordinaryIncome", + "filingStatus", + ), + "equity-funding": ( + "targetAfterTax", + "targetDate", + "ordinaryIncome", + "filingStatus", + "stateCode", + ), +} + +# ``terminationDate`` is meaningful when null (it encodes "no termination") so it +# must be sent even when the caller leaves it unset. +_KEEP_NULL = {"terminationDate"} + + +class OptionsAhoyToolSchema(BaseModel): + """Input for OptionsAhoyTool.""" + + calculator: CalculatorName = Field( + ..., + description=( + "Which equity-compensation calculator to run. One of: " + "'amt-iso' (optimize a multi-year incentive stock option (ISO) exercise " + "schedule under the alternative minimum tax (AMT)); " + "'nso' (tax and after-tax proceeds of exercising non-qualified stock " + "options (NSOs), holding versus selling); " + "'rsu-sell-vs-hold' (sell vested restricted stock units (RSUs) at vest " + "versus hold, on an after-tax, risk-adjusted basis); " + "'concentration' (analyze a concentrated single-stock position and the " + "after-tax cost of diversifying it); " + "'protective-put' (price a protective put hedge at a given downside " + "protection level and tenor); " + "'qsbs' (check qualified small business stock (QSBS) eligibility and the " + "resulting capital-gains exclusion); " + "'equity-funding' (plan which equity lots to sell, and when, to fund a " + "cash goal by a target date at the least after-tax cost)." + ), + ) + inputs: dict[str, Any] = Field( + ..., + description=( + "The calculator inputs as a JSON object. Field names match the OptionsAhoy " + "public schema exactly (for example: shares, strike, fmv, filingStatus, " + "ordinaryIncome, stateCode, grantDate). Money values are plain numbers, " + "dates are ISO strings (YYYY-MM-DD), and US state codes are two letters." + ), + ) + + +class OptionsAhoyTool(BaseTool): + """Run an OptionsAhoy equity-compensation tax calculator. + + OptionsAhoy is a keyless public REST API that computes the tax outcome of common + equity-compensation decisions against the federal tax code and all fifty states + plus the District of Columbia. This tool wraps the seven calculators behind a + single ``calculator`` selector plus an ``inputs`` object. No API key is read, + stored, or sent. + """ + + name: str = "OptionsAhoy Equity Compensation Tax Calculator" + description: str = ( + "Computes the tax outcome of equity-compensation decisions using the " + "OptionsAhoy public API. Choose a 'calculator' and pass its 'inputs': " + "incentive stock option (ISO) exercise under the alternative minimum tax " + "(AMT), non-qualified stock option (NSO) exercise, restricted stock unit " + "(RSU) sell-versus-hold, single-stock concentration, protective put pricing, " + "qualified small business stock (QSBS) eligibility, and funding a cash goal " + "from equity lots. Returns the calculator's JSON result. No API key required." + ) + args_schema: type[BaseModel] = OptionsAhoyToolSchema + base_url: str = "https://optionsahoy.com" + timeout: int = 30 + + def _check_required(self, calculator: str, inputs: dict[str, Any]) -> None: + required = REQUIRED_FIELDS.get(calculator, ()) + missing = [field for field in required if field not in inputs] + if missing: + raise ValueError( + f"OptionsAhoy '{calculator}' is missing required input field(s): " + f"{', '.join(missing)}" + ) + + def _build_payload(self, inputs: dict[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in inputs.items() + if value is not None or key in _KEEP_NULL + } + + def _run(self, calculator: str, inputs: dict[str, Any]) -> str: + """Call the selected OptionsAhoy calculator and return its JSON result. + + Args: + calculator: The calculator endpoint to run. + inputs: The calculator inputs, matching the OptionsAhoy public schema. + + Returns: + A JSON string with the calculator result, or a JSON error object when the + request fails. + """ + self._check_required(calculator, inputs) + url = f"{self.base_url.rstrip('/')}/api/v1/{calculator}" + payload = self._build_payload(inputs) + + try: + response = requests.post(url, json=payload, timeout=self.timeout) + response.raise_for_status() + except requests.exceptions.HTTPError as exc: + detail: Any + try: + detail = exc.response.json() + except ValueError: + detail = exc.response.text + message = ( + f"OptionsAhoy '{calculator}' request failed " + f"({exc.response.status_code})" + ) + if isinstance(detail, dict) and detail.get("error"): + message = f"{message}: {detail['error']}" + return json.dumps({"error": message, "detail": detail}) + except requests.exceptions.RequestException as exc: + return json.dumps( + {"error": f"OptionsAhoy '{calculator}' request failed: {exc}"} + ) + + try: + result = response.json() + except ValueError: + return json.dumps( + {"error": (f"OptionsAhoy '{calculator}' returned a non-JSON response")} + ) + + return json.dumps(result, indent=2) diff --git a/lib/crewai-tools/tests/tools/optionsahoy_tool_test.py b/lib/crewai-tools/tests/tools/optionsahoy_tool_test.py new file mode 100644 index 0000000000..776b5fdce6 --- /dev/null +++ b/lib/crewai-tools/tests/tools/optionsahoy_tool_test.py @@ -0,0 +1,134 @@ +import json +from unittest.mock import MagicMock, patch + +from crewai_tools.tools.optionsahoy_tool.optionsahoy_tool import OptionsAhoyTool +import pytest +import requests + + +@pytest.fixture +def tool(): + return OptionsAhoyTool() + + +def test_optionsahoy_tool_initialization(): + instance = OptionsAhoyTool() + assert instance.name == "OptionsAhoy Equity Compensation Tax Calculator" + assert instance.base_url == "https://optionsahoy.com" + assert instance.timeout == 30 + + +def test_optionsahoy_tool_custom_initialization(): + instance = OptionsAhoyTool(base_url="https://example.test/", timeout=60) + assert instance.base_url == "https://example.test/" + assert instance.timeout == 60 + + +@patch("requests.post") +def test_optionsahoy_tool_run_posts_to_correct_endpoint(mock_post, tool): + mock_response = MagicMock() + mock_response.json.return_value = {"eligible": True, "exclusion": 2000000} + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + inputs = { + "acquisitionDate": "2018-01-01", + "saleDate": "2026-02-01", + "entityType": "us-c-corp", + "acquisitionMethod": "original-issuance", + "assetCategory": "under-50m", + "industry": "tech-software", + "activeBusiness": "yes", + "adjustedBasis": 10000, + "expectedGain": 2000000, + "stateCode": "CA", + "ordinaryIncome": 250000, + "filingStatus": "single", + } + + result = tool.run(calculator="qsbs", inputs=inputs) + + called_url = mock_post.call_args.args[0] + assert called_url == "https://optionsahoy.com/api/v1/qsbs" + assert mock_post.call_args.kwargs["json"] == inputs + assert mock_post.call_args.kwargs["timeout"] == 30 + + parsed = json.loads(result) + assert parsed["eligible"] is True + assert parsed["exclusion"] == 2000000 + + +@patch("requests.post") +def test_optionsahoy_tool_strips_none_but_keeps_termination_date(mock_post, tool): + mock_response = MagicMock() + mock_response.json.return_value = {"ok": True} + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + inputs = { + "shares": 1000, + "strike": 1.0, + "fmv": 10.0, + "filingStatus": "single", + "ordinaryIncome": 200000, + "stateCode": "CA", + "carryforwardCredit": 0, + "horizon": 5, + "cashReturnRate": 0.04, + "grantDate": "2022-01-01", + "hasLeftCompany": False, + "terminationDate": None, + "ticker": None, + } + + tool.run(calculator="amt-iso", inputs=inputs) + + sent = mock_post.call_args.kwargs["json"] + assert "terminationDate" in sent + assert sent["terminationDate"] is None + assert "ticker" not in sent + + +def test_optionsahoy_tool_missing_required_field_raises(tool): + with pytest.raises(ValueError, match="missing required input field"): + tool.run(calculator="qsbs", inputs={"stateCode": "CA"}) + + +@patch("requests.post") +def test_optionsahoy_tool_http_error_returns_error_json(mock_post, tool): + error_response = MagicMock() + error_response.status_code = 400 + error_response.json.return_value = {"error": "bad input"} + http_error = requests.exceptions.HTTPError(response=error_response) + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = http_error + mock_post.return_value = mock_response + + inputs = { + "positionValue": 100000, + "sector": "technology", + "protectionLevel": 0.9, + "tenorYears": 1.0, + } + + result = tool.run(calculator="protective-put", inputs=inputs) + parsed = json.loads(result) + assert "error" in parsed + assert "bad input" in parsed["error"] + + +@patch("requests.post") +def test_optionsahoy_tool_request_exception_returns_error_json(mock_post, tool): + mock_post.side_effect = requests.exceptions.ConnectionError("boom") + + inputs = { + "positionValue": 100000, + "sector": "technology", + "protectionLevel": 0.9, + "tenorYears": 1.0, + } + + result = tool.run(calculator="protective-put", inputs=inputs) + parsed = json.loads(result) + assert "error" in parsed + assert "boom" in parsed["error"] From c4e00e23a00d2582638709fb858fbcdf4ce7f3ea Mon Sep 17 00:00:00 2001 From: AlvisoOculus <152841708+AlvisoOculus@users.noreply.github.com> Date: Sat, 13 Jun 2026 06:09:52 -0700 Subject: [PATCH 2/3] optionsahoy tool: treat None-valued required fields as missing (coderabbit review) --- .../tools/optionsahoy_tool/optionsahoy_tool.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/optionsahoy_tool.py b/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/optionsahoy_tool.py index 58af9ccdea..3afab12583 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/optionsahoy_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/optionsahoy_tool/optionsahoy_tool.py @@ -158,7 +158,12 @@ class OptionsAhoyTool(BaseTool): def _check_required(self, calculator: str, inputs: dict[str, Any]) -> None: required = REQUIRED_FIELDS.get(calculator, ()) - missing = [field for field in required if field not in inputs] + missing = [ + field + for field in required + if field not in inputs + or (inputs[field] is None and field not in _KEEP_NULL) + ] if missing: raise ValueError( f"OptionsAhoy '{calculator}' is missing required input field(s): " From 67e8014007c28eaaa4689a9accb63ef01f97625a Mon Sep 17 00:00:00 2001 From: AlvisoOculus <152841708+AlvisoOculus@users.noreply.github.com> Date: Sat, 13 Jun 2026 06:09:53 -0700 Subject: [PATCH 3/3] optionsahoy tool: test None-valued required field is rejected --- .../tests/tools/optionsahoy_tool_test.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/crewai-tools/tests/tools/optionsahoy_tool_test.py b/lib/crewai-tools/tests/tools/optionsahoy_tool_test.py index 776b5fdce6..a39512dc8f 100644 --- a/lib/crewai-tools/tests/tools/optionsahoy_tool_test.py +++ b/lib/crewai-tools/tests/tools/optionsahoy_tool_test.py @@ -94,6 +94,26 @@ def test_optionsahoy_tool_missing_required_field_raises(tool): tool.run(calculator="qsbs", inputs={"stateCode": "CA"}) +def test_optionsahoy_tool_none_required_field_raises(tool): + # A required field present but None is stripped from the payload, so it must + # be treated as missing. terminationDate is the documented kept-null exception. + with pytest.raises(ValueError, match="missing required input field"): + tool.run( + calculator="nso", + inputs={ + "shares": 1000, + "strike": None, + "currentPrice": 50, + "ordinaryIncome": 200000, + "filingStatus": "single", + "stateCode": "CA", + "stillEmployed": True, + "holdYears": 2, + "holdFunding": "cash", + }, + ) + + @patch("requests.post") def test_optionsahoy_tool_http_error_returns_error_json(mock_post, tool): error_response = MagicMock()