magiqa-mcp 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.
- magiqa_mcp-0.1.0/PKG-INFO +66 -0
- magiqa_mcp-0.1.0/README.md +44 -0
- magiqa_mcp-0.1.0/magiqa_mcp/__init__.py +3 -0
- magiqa_mcp-0.1.0/magiqa_mcp/server.py +331 -0
- magiqa_mcp-0.1.0/magiqa_mcp.egg-info/PKG-INFO +66 -0
- magiqa_mcp-0.1.0/magiqa_mcp.egg-info/SOURCES.txt +11 -0
- magiqa_mcp-0.1.0/magiqa_mcp.egg-info/dependency_links.txt +1 -0
- magiqa_mcp-0.1.0/magiqa_mcp.egg-info/entry_points.txt +2 -0
- magiqa_mcp-0.1.0/magiqa_mcp.egg-info/requires.txt +5 -0
- magiqa_mcp-0.1.0/magiqa_mcp.egg-info/top_level.txt +1 -0
- magiqa_mcp-0.1.0/pyproject.toml +41 -0
- magiqa_mcp-0.1.0/setup.cfg +4 -0
- magiqa_mcp-0.1.0/tests/test_server.py +127 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: magiqa-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for MagiQa Options: covered calls, cash-secured puts, spreads, iron condors and options scanners for AI assistants
|
|
5
|
+
Author-email: Magiqa <info@magiqa.us>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://options.magiqa.us
|
|
8
|
+
Project-URL: Documentation, https://options.magiqa.us/documentation
|
|
9
|
+
Project-URL: Source, https://github.com/Avetik1980/optionsAPI
|
|
10
|
+
Keywords: mcp,options,covered calls,iron condor,stocks,finance,ai
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: mcp<3,>=2.3
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# magiqa-mcp
|
|
24
|
+
|
|
25
|
+
MCP server for [MagiQa Options](https://options.magiqa.us). Lets Claude, Cursor and other MCP clients screen US stock options with your MagiQa API key.
|
|
26
|
+
|
|
27
|
+
## Tools
|
|
28
|
+
|
|
29
|
+
| Tool | What it returns |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `get_stock_snapshot` | Price, SMA/RSI/MACD, signal, trend, valuation, earnings estimates |
|
|
32
|
+
| `find_covered_calls` | Covered calls, one symbol or market-wide |
|
|
33
|
+
| `find_cash_secured_puts` | Cash-secured puts |
|
|
34
|
+
| `find_credit_spreads` | Bull put (bullish) or bear call (bearish) spreads |
|
|
35
|
+
| `find_debit_spreads` | Bull call (bullish) or bear put (bearish) spreads |
|
|
36
|
+
| `find_iron_condors` | Short or long iron condors |
|
|
37
|
+
| `find_straddles_strangles` | Long or short straddles and strangles |
|
|
38
|
+
| `find_high_probability_options` | Highest probability-of-profit trades |
|
|
39
|
+
| `get_unusual_options_activity` | Volume far above open interest |
|
|
40
|
+
| `get_most_active_options` | Highest options volume |
|
|
41
|
+
| `get_highest_open_interest` | Largest open interest |
|
|
42
|
+
| `get_earnings_calendar` | Upcoming earnings |
|
|
43
|
+
|
|
44
|
+
Each tool returns at most 50 rows (default 10). Option premiums are from the previous session close. Results are analytics, not investment advice.
|
|
45
|
+
|
|
46
|
+
## Setup
|
|
47
|
+
|
|
48
|
+
Get an API key at [options.magiqa.us](https://options.magiqa.us), then add the server to your MCP client.
|
|
49
|
+
|
|
50
|
+
**Claude Desktop** (`claude_desktop_config.json`) or **Cursor** (`.cursor/mcp.json`):
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"magiqa": {
|
|
56
|
+
"command": "uvx",
|
|
57
|
+
"args": ["magiqa-mcp"],
|
|
58
|
+
"env": { "MAGIQA_API_KEY": "your-api-key" }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Without `uv`: `pip install magiqa-mcp` and use `"command": "magiqa-mcp"` with no `args`.
|
|
65
|
+
|
|
66
|
+
Your plan's rate limits and symbol access apply.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# magiqa-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [MagiQa Options](https://options.magiqa.us). Lets Claude, Cursor and other MCP clients screen US stock options with your MagiQa API key.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | What it returns |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `get_stock_snapshot` | Price, SMA/RSI/MACD, signal, trend, valuation, earnings estimates |
|
|
10
|
+
| `find_covered_calls` | Covered calls, one symbol or market-wide |
|
|
11
|
+
| `find_cash_secured_puts` | Cash-secured puts |
|
|
12
|
+
| `find_credit_spreads` | Bull put (bullish) or bear call (bearish) spreads |
|
|
13
|
+
| `find_debit_spreads` | Bull call (bullish) or bear put (bearish) spreads |
|
|
14
|
+
| `find_iron_condors` | Short or long iron condors |
|
|
15
|
+
| `find_straddles_strangles` | Long or short straddles and strangles |
|
|
16
|
+
| `find_high_probability_options` | Highest probability-of-profit trades |
|
|
17
|
+
| `get_unusual_options_activity` | Volume far above open interest |
|
|
18
|
+
| `get_most_active_options` | Highest options volume |
|
|
19
|
+
| `get_highest_open_interest` | Largest open interest |
|
|
20
|
+
| `get_earnings_calendar` | Upcoming earnings |
|
|
21
|
+
|
|
22
|
+
Each tool returns at most 50 rows (default 10). Option premiums are from the previous session close. Results are analytics, not investment advice.
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
Get an API key at [options.magiqa.us](https://options.magiqa.us), then add the server to your MCP client.
|
|
27
|
+
|
|
28
|
+
**Claude Desktop** (`claude_desktop_config.json`) or **Cursor** (`.cursor/mcp.json`):
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"mcpServers": {
|
|
33
|
+
"magiqa": {
|
|
34
|
+
"command": "uvx",
|
|
35
|
+
"args": ["magiqa-mcp"],
|
|
36
|
+
"env": { "MAGIQA_API_KEY": "your-api-key" }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Without `uv`: `pip install magiqa-mcp` and use `"command": "magiqa-mcp"` with no `args`.
|
|
43
|
+
|
|
44
|
+
Your plan's rate limits and symbol access apply.
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MagiQa Options MCP server (stdio).
|
|
3
|
+
|
|
4
|
+
Thin wrapper over the MagiQa REST API using the caller's own API key
|
|
5
|
+
(MAGIQA_API_KEY). Plan limits and symbol access are enforced by the API.
|
|
6
|
+
|
|
7
|
+
Scanner fields that are estimated rather than measured (IV rank/percentile,
|
|
8
|
+
historical volatility, OI change) are stripped from every response until
|
|
9
|
+
they are backed by stored history.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any, Literal, Optional
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from mcp.server import MCPServer
|
|
18
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
19
|
+
|
|
20
|
+
from magiqa_mcp import __version__
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://options.magiqa.us"
|
|
23
|
+
DEFAULT_LIMIT = 10
|
|
24
|
+
MAX_LIMIT = 50
|
|
25
|
+
TIMEOUT_SECONDS = 30.0
|
|
26
|
+
|
|
27
|
+
ESTIMATED_FIELDS = frozenset({
|
|
28
|
+
"iv_rank", "iv_percentile", "iv_high_52w", "iv_low_52w", "iv_signal", "strategy_hint",
|
|
29
|
+
"iv_hv", "iv_hv_ratio", "historical_volatility", "his_vol_30d",
|
|
30
|
+
"previous_oi", "oi_change", "oi_change_pct",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
STOCK_SNAPSHOT_FIELDS = (
|
|
34
|
+
"symbol", "name", "sector", "price", "change", "change_percent", "open", "high", "low",
|
|
35
|
+
"volume_raw", "avg_volume_raw", "market_cap", "pe_ratio", "dividend_yield", "one_year_return",
|
|
36
|
+
"sma_5", "sma_8", "sma_13", "sma_50", "sma_200", "rsi", "macd", "macd_signal", "volatility",
|
|
37
|
+
"signal", "sma_decision", "trend", "altman_z_score", "piotroski_score",
|
|
38
|
+
"eps_actual", "eps_estimated", "revenue_actual", "revenue_estimated",
|
|
39
|
+
"quote_year_high", "quote_year_low", "last_updated", "quote_refreshed_at",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
INSTRUCTIONS = (
|
|
43
|
+
"MagiQa screens US stock options (S&P 500 + popular ETFs) and returns strategy-ready trades "
|
|
44
|
+
"with probability of profit, breakevens and returns. Option premiums come from the previous "
|
|
45
|
+
"session close. Results are analytics, not investment advice."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MagiqaAPIError(ToolError):
|
|
50
|
+
"""Anticipated API failure; the SDK shows this message to the model."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MagiqaClient:
|
|
54
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None,
|
|
55
|
+
transport: Optional[httpx.BaseTransport] = None):
|
|
56
|
+
self.api_key = api_key or os.getenv("MAGIQA_API_KEY", "")
|
|
57
|
+
self.base_url = (base_url or os.getenv("MAGIQA_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
58
|
+
self._http = httpx.Client(
|
|
59
|
+
base_url=self.base_url,
|
|
60
|
+
headers={"X-API-Key": self.api_key, "User-Agent": f"magiqa-mcp/{__version__}"},
|
|
61
|
+
timeout=TIMEOUT_SECONDS,
|
|
62
|
+
transport=transport,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def get(self, path: str, params: Optional[dict] = None) -> Any:
|
|
66
|
+
if not self.api_key:
|
|
67
|
+
raise MagiqaAPIError(
|
|
68
|
+
"MAGIQA_API_KEY is not set. Get a key at https://options.magiqa.us and add it to "
|
|
69
|
+
"the MCP server's environment."
|
|
70
|
+
)
|
|
71
|
+
clean = {k: v for k, v in (params or {}).items() if v is not None}
|
|
72
|
+
try:
|
|
73
|
+
resp = self._http.get(path, params=clean)
|
|
74
|
+
except httpx.HTTPError as e:
|
|
75
|
+
raise MagiqaAPIError(f"Could not reach MagiQa API: {e}") from e
|
|
76
|
+
if resp.status_code == 401:
|
|
77
|
+
raise MagiqaAPIError(_unauthorized_message(resp))
|
|
78
|
+
if resp.status_code == 403:
|
|
79
|
+
raise MagiqaAPIError(f"Not available on your MagiQa plan (403): {_detail(resp)}")
|
|
80
|
+
if resp.status_code == 429:
|
|
81
|
+
raise MagiqaAPIError("MagiQa rate limit reached (429). Try again later or upgrade the plan.")
|
|
82
|
+
if resp.status_code >= 400:
|
|
83
|
+
raise MagiqaAPIError(f"MagiQa API error {resp.status_code}: {_detail(resp)}")
|
|
84
|
+
return resp.json()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _unauthorized_message(resp: httpx.Response) -> str:
|
|
88
|
+
"""A 401 is either a bad key or an endpoint that still accepts RapidAPI only."""
|
|
89
|
+
detail = _detail(resp)
|
|
90
|
+
lowered = detail.lower()
|
|
91
|
+
if "rapidapi" in lowered or "x-rapidapi" in lowered:
|
|
92
|
+
return (
|
|
93
|
+
"This MagiQa endpoint is not available with an API key (401). "
|
|
94
|
+
"It only accepts RapidAPI traffic."
|
|
95
|
+
)
|
|
96
|
+
if detail and detail not in ("nope", "{}"):
|
|
97
|
+
return f"MagiQa rejected the API key (401): {detail}"
|
|
98
|
+
return "MagiQa rejected the API key (401). Check MAGIQA_API_KEY."
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _detail(resp: httpx.Response) -> str:
|
|
102
|
+
try:
|
|
103
|
+
body = resp.json()
|
|
104
|
+
return str(body.get("detail", body))[:300] if isinstance(body, dict) else str(body)[:300]
|
|
105
|
+
except ValueError:
|
|
106
|
+
return resp.text[:300]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _clamp(limit: int) -> int:
|
|
110
|
+
return max(1, min(int(limit), MAX_LIMIT))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def shape(payload: Any, limit: int) -> Any:
|
|
114
|
+
"""Drop estimated fields at any depth and cap every list at `limit` rows."""
|
|
115
|
+
if isinstance(payload, dict):
|
|
116
|
+
out = {}
|
|
117
|
+
for k, v in payload.items():
|
|
118
|
+
if k in ESTIMATED_FIELDS:
|
|
119
|
+
continue
|
|
120
|
+
if isinstance(v, list) and len(v) > limit:
|
|
121
|
+
out[k] = [shape(x, limit) for x in v[:limit]]
|
|
122
|
+
out[f"{k}_total"] = len(v)
|
|
123
|
+
else:
|
|
124
|
+
out[k] = shape(v, limit)
|
|
125
|
+
return out
|
|
126
|
+
if isinstance(payload, list):
|
|
127
|
+
return [shape(x, limit) for x in payload[:limit]]
|
|
128
|
+
return payload
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_server(client: Optional[MagiqaClient] = None) -> MCPServer:
|
|
132
|
+
api = client or MagiqaClient()
|
|
133
|
+
mcp = MCPServer(
|
|
134
|
+
"MagiQa Options",
|
|
135
|
+
instructions=INSTRUCTIONS,
|
|
136
|
+
version=__version__,
|
|
137
|
+
website_url="https://options.magiqa.us",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def fetch(path: str, limit: int, params: Optional[dict] = None) -> Any:
|
|
141
|
+
n = _clamp(limit)
|
|
142
|
+
return shape(api.get(path, {**(params or {}), "limit": n}), n)
|
|
143
|
+
|
|
144
|
+
@mcp.tool()
|
|
145
|
+
def get_stock_snapshot(symbol: str) -> dict:
|
|
146
|
+
"""Price, technical indicators (SMA, RSI, MACD, volatility, signal, trend), valuation and
|
|
147
|
+
upcoming earnings estimates for one US stock or ETF."""
|
|
148
|
+
data = api.get(f"/api/stocks/{symbol.upper().strip()}")
|
|
149
|
+
if not isinstance(data, dict):
|
|
150
|
+
return {"symbol": symbol.upper(), "data": data}
|
|
151
|
+
return {k: data.get(k) for k in STOCK_SNAPSHOT_FIELDS if k in data}
|
|
152
|
+
|
|
153
|
+
@mcp.tool()
|
|
154
|
+
def find_covered_calls(
|
|
155
|
+
symbol: Optional[str] = None,
|
|
156
|
+
min_days_to_expiration: int = 0,
|
|
157
|
+
max_days_to_expiration: int = 60,
|
|
158
|
+
min_probability: float = 0,
|
|
159
|
+
min_return: float = 0,
|
|
160
|
+
limit: int = DEFAULT_LIMIT,
|
|
161
|
+
) -> Any:
|
|
162
|
+
"""Covered call opportunities (own 100 shares, sell a call). With `symbol`, returns that
|
|
163
|
+
symbol's covered calls; without it, the best across the market using the filters."""
|
|
164
|
+
if symbol:
|
|
165
|
+
return fetch(f"/api/basic-options/covered-calls/{symbol.upper().strip()}", limit)
|
|
166
|
+
return fetch("/api/basic-options/covered-calls", limit, {
|
|
167
|
+
"minDaysToExpiration": min_days_to_expiration,
|
|
168
|
+
"maxDaysToExpiration": max_days_to_expiration,
|
|
169
|
+
"minProbability": min_probability,
|
|
170
|
+
"minReturn": min_return,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
@mcp.tool()
|
|
174
|
+
def find_cash_secured_puts(
|
|
175
|
+
symbol: Optional[str] = None,
|
|
176
|
+
min_days_to_expiration: Optional[int] = None,
|
|
177
|
+
max_days_to_expiration: Optional[int] = None,
|
|
178
|
+
min_profit_probability: Optional[float] = None,
|
|
179
|
+
min_annualized_return: Optional[float] = None,
|
|
180
|
+
max_investment: Optional[float] = None,
|
|
181
|
+
limit: int = DEFAULT_LIMIT,
|
|
182
|
+
) -> Any:
|
|
183
|
+
"""Cash-secured (naked) put opportunities: sell a put, collect premium, possibly buy the
|
|
184
|
+
stock at the strike. Optional symbol and filters."""
|
|
185
|
+
return fetch("/api/basic-options/naked-puts", limit, {
|
|
186
|
+
"symbol": symbol.upper().strip() if symbol else None,
|
|
187
|
+
"minDaysToExpiration": min_days_to_expiration,
|
|
188
|
+
"maxDaysToExpiration": max_days_to_expiration,
|
|
189
|
+
"minProfitProbability": min_profit_probability,
|
|
190
|
+
"minAnnualizedReturn": min_annualized_return,
|
|
191
|
+
"maxInvestment": max_investment,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
@mcp.tool()
|
|
195
|
+
def find_credit_spreads(
|
|
196
|
+
direction: Literal["bullish", "bearish"],
|
|
197
|
+
min_days_to_expiration: Optional[int] = None,
|
|
198
|
+
max_days_to_expiration: Optional[int] = None,
|
|
199
|
+
min_profit_probability: Optional[float] = None,
|
|
200
|
+
min_roi: Optional[float] = None,
|
|
201
|
+
max_investment: Optional[float] = None,
|
|
202
|
+
limit: int = DEFAULT_LIMIT,
|
|
203
|
+
) -> Any:
|
|
204
|
+
"""Credit spreads: bullish = bull put spread, bearish = bear call spread. Defined risk,
|
|
205
|
+
collect premium up front."""
|
|
206
|
+
path = "/api/spreads/bull-put" if direction == "bullish" else "/api/spreads/bear-call"
|
|
207
|
+
return fetch(path, limit, {
|
|
208
|
+
"minDaysToExpiration": min_days_to_expiration,
|
|
209
|
+
"maxDaysToExpiration": max_days_to_expiration,
|
|
210
|
+
"minProfitProbability": min_profit_probability,
|
|
211
|
+
"minROI": min_roi,
|
|
212
|
+
"maxInvestment": max_investment,
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
@mcp.tool()
|
|
216
|
+
def find_debit_spreads(
|
|
217
|
+
direction: Literal["bullish", "bearish"],
|
|
218
|
+
min_days_to_expiration: Optional[int] = None,
|
|
219
|
+
max_days_to_expiration: Optional[int] = None,
|
|
220
|
+
min_profit_probability: Optional[float] = None,
|
|
221
|
+
min_roi: Optional[float] = None,
|
|
222
|
+
max_investment: Optional[float] = None,
|
|
223
|
+
limit: int = DEFAULT_LIMIT,
|
|
224
|
+
) -> Any:
|
|
225
|
+
"""Debit spreads: bullish = bull call spread, bearish = bear put spread. Defined risk,
|
|
226
|
+
pay premium up front."""
|
|
227
|
+
path = "/api/spreads/bull-call" if direction == "bullish" else "/api/spreads/bear-put"
|
|
228
|
+
return fetch(path, limit, {
|
|
229
|
+
"minDaysToExpiration": min_days_to_expiration,
|
|
230
|
+
"maxDaysToExpiration": max_days_to_expiration,
|
|
231
|
+
"minProfitProbability": min_profit_probability,
|
|
232
|
+
"minROI": min_roi,
|
|
233
|
+
"maxInvestment": max_investment,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
@mcp.tool()
|
|
237
|
+
def find_iron_condors(
|
|
238
|
+
side: Literal["short", "long"] = "short",
|
|
239
|
+
min_days_to_expiration: int = 0,
|
|
240
|
+
max_days_to_expiration: int = 180,
|
|
241
|
+
min_profit_probability: float = 0,
|
|
242
|
+
limit: int = DEFAULT_LIMIT,
|
|
243
|
+
) -> Any:
|
|
244
|
+
"""Iron condors. short = sell the condor for a credit (profits if the stock stays in a
|
|
245
|
+
range); long = buy it (profits on a big move)."""
|
|
246
|
+
return fetch("/api/scanners/iron-condor-screener", limit, {
|
|
247
|
+
"strategy_type": f"{side}_iron_condor",
|
|
248
|
+
"minDaysToExpiration": min_days_to_expiration,
|
|
249
|
+
"maxDaysToExpiration": max_days_to_expiration,
|
|
250
|
+
"minProbProfit": min_profit_probability,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
@mcp.tool()
|
|
254
|
+
def find_straddles_strangles(
|
|
255
|
+
kind: Literal["straddle", "strangle"] = "straddle",
|
|
256
|
+
side: Literal["long", "short"] = "long",
|
|
257
|
+
min_days_to_expiration: int = 0,
|
|
258
|
+
max_days_to_expiration: int = 90,
|
|
259
|
+
min_profit_probability: float = 0,
|
|
260
|
+
min_volume: int = 0,
|
|
261
|
+
limit: int = DEFAULT_LIMIT,
|
|
262
|
+
) -> Any:
|
|
263
|
+
"""Straddles (same strike) or strangles (different strikes). long = bet on a big move;
|
|
264
|
+
short = bet on a quiet market."""
|
|
265
|
+
return fetch(f"/api/scanners/{kind}-screener", limit, {
|
|
266
|
+
"strategy_type": side,
|
|
267
|
+
"minDaysToExpiration": min_days_to_expiration,
|
|
268
|
+
"maxDaysToExpiration": max_days_to_expiration,
|
|
269
|
+
"minProbProfit": min_profit_probability,
|
|
270
|
+
"minVolume": min_volume,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
@mcp.tool()
|
|
274
|
+
def find_high_probability_options(
|
|
275
|
+
min_profit_probability: float = 60,
|
|
276
|
+
max_days_to_expiration: int = 45,
|
|
277
|
+
min_volume: int = 100,
|
|
278
|
+
limit: int = DEFAULT_LIMIT,
|
|
279
|
+
) -> Any:
|
|
280
|
+
"""Option trades with the highest probability of profit across the market."""
|
|
281
|
+
return fetch("/api/scanners/options-screener/high-probability", limit, {
|
|
282
|
+
"minProfitProb": min_profit_probability,
|
|
283
|
+
"maxDaysToExpiration": max_days_to_expiration,
|
|
284
|
+
"minVolume": min_volume,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
@mcp.tool()
|
|
288
|
+
def get_unusual_options_activity(
|
|
289
|
+
symbol: Optional[str] = None,
|
|
290
|
+
option_type: Literal["all", "call", "put"] = "all",
|
|
291
|
+
min_volume: int = 1000,
|
|
292
|
+
max_days_to_expiration: int = 45,
|
|
293
|
+
limit: int = DEFAULT_LIMIT,
|
|
294
|
+
) -> Any:
|
|
295
|
+
"""Contracts trading far above their open interest (possible new positioning)."""
|
|
296
|
+
if symbol:
|
|
297
|
+
return fetch(f"/api/scanners/unusual-activity/{symbol.upper().strip()}", limit)
|
|
298
|
+
return fetch("/api/scanners/unusual-activity", limit, {
|
|
299
|
+
"optionType": option_type,
|
|
300
|
+
"minVolume": min_volume,
|
|
301
|
+
"maxDaysToExpiration": max_days_to_expiration,
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
@mcp.tool()
|
|
305
|
+
def get_most_active_options(symbol: Optional[str] = None, limit: int = DEFAULT_LIMIT) -> Any:
|
|
306
|
+
"""Underlyings (or one symbol's contracts) with the highest options volume."""
|
|
307
|
+
if symbol:
|
|
308
|
+
return fetch(f"/api/scanners/most-active-options/{symbol.upper().strip()}", limit)
|
|
309
|
+
return fetch("/api/scanners/most-active-options", limit)
|
|
310
|
+
|
|
311
|
+
@mcp.tool()
|
|
312
|
+
def get_highest_open_interest(symbol: Optional[str] = None, limit: int = DEFAULT_LIMIT) -> Any:
|
|
313
|
+
"""Underlyings (or one symbol's contracts) with the largest open interest."""
|
|
314
|
+
if symbol:
|
|
315
|
+
return fetch(f"/api/scanners/highest-open-interest/{symbol.upper().strip()}", limit)
|
|
316
|
+
return fetch("/api/scanners/highest-open-interest", limit)
|
|
317
|
+
|
|
318
|
+
@mcp.tool()
|
|
319
|
+
def get_earnings_calendar(days: int = 7) -> Any:
|
|
320
|
+
"""Upcoming earnings reports for the next `days` days (max 30)."""
|
|
321
|
+
return shape(api.get("/api/stocks/earnings-calendar", {"days": max(1, min(days, 30))}), 200)
|
|
322
|
+
|
|
323
|
+
return mcp
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main() -> None:
|
|
327
|
+
build_server().run("stdio")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
if __name__ == "__main__":
|
|
331
|
+
main()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: magiqa-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for MagiQa Options: covered calls, cash-secured puts, spreads, iron condors and options scanners for AI assistants
|
|
5
|
+
Author-email: Magiqa <info@magiqa.us>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://options.magiqa.us
|
|
8
|
+
Project-URL: Documentation, https://options.magiqa.us/documentation
|
|
9
|
+
Project-URL: Source, https://github.com/Avetik1980/optionsAPI
|
|
10
|
+
Keywords: mcp,options,covered calls,iron condor,stocks,finance,ai
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: mcp<3,>=2.3
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# magiqa-mcp
|
|
24
|
+
|
|
25
|
+
MCP server for [MagiQa Options](https://options.magiqa.us). Lets Claude, Cursor and other MCP clients screen US stock options with your MagiQa API key.
|
|
26
|
+
|
|
27
|
+
## Tools
|
|
28
|
+
|
|
29
|
+
| Tool | What it returns |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `get_stock_snapshot` | Price, SMA/RSI/MACD, signal, trend, valuation, earnings estimates |
|
|
32
|
+
| `find_covered_calls` | Covered calls, one symbol or market-wide |
|
|
33
|
+
| `find_cash_secured_puts` | Cash-secured puts |
|
|
34
|
+
| `find_credit_spreads` | Bull put (bullish) or bear call (bearish) spreads |
|
|
35
|
+
| `find_debit_spreads` | Bull call (bullish) or bear put (bearish) spreads |
|
|
36
|
+
| `find_iron_condors` | Short or long iron condors |
|
|
37
|
+
| `find_straddles_strangles` | Long or short straddles and strangles |
|
|
38
|
+
| `find_high_probability_options` | Highest probability-of-profit trades |
|
|
39
|
+
| `get_unusual_options_activity` | Volume far above open interest |
|
|
40
|
+
| `get_most_active_options` | Highest options volume |
|
|
41
|
+
| `get_highest_open_interest` | Largest open interest |
|
|
42
|
+
| `get_earnings_calendar` | Upcoming earnings |
|
|
43
|
+
|
|
44
|
+
Each tool returns at most 50 rows (default 10). Option premiums are from the previous session close. Results are analytics, not investment advice.
|
|
45
|
+
|
|
46
|
+
## Setup
|
|
47
|
+
|
|
48
|
+
Get an API key at [options.magiqa.us](https://options.magiqa.us), then add the server to your MCP client.
|
|
49
|
+
|
|
50
|
+
**Claude Desktop** (`claude_desktop_config.json`) or **Cursor** (`.cursor/mcp.json`):
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"magiqa": {
|
|
56
|
+
"command": "uvx",
|
|
57
|
+
"args": ["magiqa-mcp"],
|
|
58
|
+
"env": { "MAGIQA_API_KEY": "your-api-key" }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Without `uv`: `pip install magiqa-mcp` and use `"command": "magiqa-mcp"` with no `args`.
|
|
65
|
+
|
|
66
|
+
Your plan's rate limits and symbol access apply.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
magiqa_mcp/__init__.py
|
|
4
|
+
magiqa_mcp/server.py
|
|
5
|
+
magiqa_mcp.egg-info/PKG-INFO
|
|
6
|
+
magiqa_mcp.egg-info/SOURCES.txt
|
|
7
|
+
magiqa_mcp.egg-info/dependency_links.txt
|
|
8
|
+
magiqa_mcp.egg-info/entry_points.txt
|
|
9
|
+
magiqa_mcp.egg-info/requires.txt
|
|
10
|
+
magiqa_mcp.egg-info/top_level.txt
|
|
11
|
+
tests/test_server.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
magiqa_mcp
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "magiqa-mcp"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "MCP server for MagiQa Options: covered calls, cash-secured puts, spreads, iron condors and options scanners for AI assistants"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Magiqa", email = "info@magiqa.us" }]
|
|
13
|
+
keywords = ["mcp", "options", "covered calls", "iron condor", "stocks", "finance", "ai"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
17
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"mcp>=2.3,<3",
|
|
23
|
+
"httpx>=0.27",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=7"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
magiqa-mcp = "magiqa_mcp.server:main"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://options.magiqa.us"
|
|
34
|
+
Documentation = "https://options.magiqa.us/documentation"
|
|
35
|
+
Source = "https://github.com/Avetik1980/optionsAPI"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools]
|
|
38
|
+
packages = ["magiqa_mcp"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.dynamic]
|
|
41
|
+
version = { attr = "magiqa_mcp.__version__" }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""In-memory tests for magiqa-mcp against a mocked MagiQa API (no network)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
from mcp import Client
|
|
10
|
+
|
|
11
|
+
from magiqa_mcp.server import MAX_LIMIT, MagiqaClient, build_server, shape
|
|
12
|
+
|
|
13
|
+
EXPECTED_TOOLS = {
|
|
14
|
+
"get_stock_snapshot", "find_covered_calls", "find_cash_secured_puts", "find_credit_spreads",
|
|
15
|
+
"find_debit_spreads", "find_iron_condors", "find_straddles_strangles",
|
|
16
|
+
"find_high_probability_options", "get_unusual_options_activity", "get_most_active_options",
|
|
17
|
+
"get_highest_open_interest", "get_earnings_calendar",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def make_server(handler, api_key="test-key"):
|
|
22
|
+
seen = []
|
|
23
|
+
|
|
24
|
+
def wrapped(request: httpx.Request) -> httpx.Response:
|
|
25
|
+
seen.append(request)
|
|
26
|
+
return handler(request)
|
|
27
|
+
|
|
28
|
+
client = MagiqaClient(api_key=api_key, base_url="https://api.test",
|
|
29
|
+
transport=httpx.MockTransport(wrapped))
|
|
30
|
+
return build_server(client), seen
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def call(server, name, args):
|
|
34
|
+
async def run():
|
|
35
|
+
async with Client(server) as c:
|
|
36
|
+
return await c.call_tool(name, args)
|
|
37
|
+
return asyncio.run(run())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def payload(result):
|
|
41
|
+
if result.structured_content is not None:
|
|
42
|
+
sc = result.structured_content
|
|
43
|
+
return sc.get("result", sc) if isinstance(sc, dict) and set(sc) == {"result"} else sc
|
|
44
|
+
return json.loads(result.content[0].text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_lists_exactly_the_v1_tools():
|
|
48
|
+
server, _ = make_server(lambda r: httpx.Response(200, json={}))
|
|
49
|
+
|
|
50
|
+
async def run():
|
|
51
|
+
async with Client(server) as c:
|
|
52
|
+
return await c.list_tools()
|
|
53
|
+
tools = {t.name for t in asyncio.run(run()).tools}
|
|
54
|
+
assert tools == EXPECTED_TOOLS
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_shape_strips_estimated_fields_and_caps_lists():
|
|
58
|
+
data = {"count": 3, "data": [{"symbol": "A", "iv_rank": 50, "pop": 70}] * 3}
|
|
59
|
+
out = shape(data, 2)
|
|
60
|
+
assert len(out["data"]) == 2 and out["data_total"] == 3
|
|
61
|
+
assert "iv_rank" not in out["data"][0] and out["data"][0]["pop"] == 70
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_covered_calls_sends_key_filters_and_clamped_limit():
|
|
65
|
+
server, seen = make_server(lambda r: httpx.Response(200, json={"data": [{"symbol": "AAPL"}]}))
|
|
66
|
+
res = call(server, "find_covered_calls", {"max_days_to_expiration": 45, "limit": 500})
|
|
67
|
+
assert not res.is_error
|
|
68
|
+
req = seen[-1]
|
|
69
|
+
assert req.url.path == "/api/basic-options/covered-calls"
|
|
70
|
+
assert req.headers["X-API-Key"] == "test-key"
|
|
71
|
+
assert req.url.params["maxDaysToExpiration"] == "45"
|
|
72
|
+
assert req.url.params["limit"] == str(MAX_LIMIT)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_covered_calls_with_symbol_uses_symbol_route():
|
|
76
|
+
server, seen = make_server(lambda r: httpx.Response(200, json={"data": []}))
|
|
77
|
+
call(server, "find_covered_calls", {"symbol": "aapl"})
|
|
78
|
+
assert seen[-1].url.path == "/api/basic-options/covered-calls/AAPL"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_credit_spread_direction_routes():
|
|
82
|
+
server, seen = make_server(lambda r: httpx.Response(200, json={"data": []}))
|
|
83
|
+
call(server, "find_credit_spreads", {"direction": "bearish"})
|
|
84
|
+
assert seen[-1].url.path == "/api/spreads/bear-call"
|
|
85
|
+
call(server, "find_credit_spreads", {"direction": "bullish", "min_roi": 10})
|
|
86
|
+
assert seen[-1].url.path == "/api/spreads/bull-put"
|
|
87
|
+
assert seen[-1].url.params["minROI"] == "10.0"
|
|
88
|
+
assert "maxInvestment" not in seen[-1].url.params
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_iron_condor_side_maps_to_strategy_type():
|
|
92
|
+
server, seen = make_server(lambda r: httpx.Response(200, json={"data": []}))
|
|
93
|
+
call(server, "find_iron_condors", {"side": "short"})
|
|
94
|
+
assert seen[-1].url.params["strategy_type"] == "short_iron_condor"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_stock_snapshot_returns_curated_fields_only():
|
|
98
|
+
row = {"symbol": "NVDA", "price": 239.24, "sma_50": 219.77, "balance_total_assets": 1, "iv_rank": 9}
|
|
99
|
+
server, _ = make_server(lambda r: httpx.Response(200, json=row))
|
|
100
|
+
out = payload(call(server, "get_stock_snapshot", {"symbol": "nvda"}))
|
|
101
|
+
assert out == {"symbol": "NVDA", "price": 239.24, "sma_50": 219.77}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.parametrize("status,needle", [(401, "API key"), (403, "plan"), (429, "rate limit")])
|
|
105
|
+
def test_api_errors_become_readable_tool_errors(status, needle):
|
|
106
|
+
server, _ = make_server(lambda r: httpx.Response(status, json={"detail": "nope"}))
|
|
107
|
+
res = call(server, "get_most_active_options", {})
|
|
108
|
+
assert res.is_error
|
|
109
|
+
assert needle in res.content[0].text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_rapidapi_only_endpoint_is_not_blamed_on_the_key():
|
|
113
|
+
server, _ = make_server(lambda r: httpx.Response(
|
|
114
|
+
401, json={"detail": "Missing RapidAPI header. X-RapidAPI-Host is required."}
|
|
115
|
+
))
|
|
116
|
+
res = call(server, "get_most_active_options", {})
|
|
117
|
+
assert res.is_error
|
|
118
|
+
text = res.content[0].text
|
|
119
|
+
assert "not available with an API key" in text
|
|
120
|
+
assert "Check MAGIQA_API_KEY" not in text
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_missing_api_key_is_explained_without_calling_api():
|
|
124
|
+
server, seen = make_server(lambda r: httpx.Response(200, json={}), api_key="")
|
|
125
|
+
res = call(server, "get_earnings_calendar", {})
|
|
126
|
+
assert res.is_error and "MAGIQA_API_KEY" in res.content[0].text
|
|
127
|
+
assert seen == []
|