scenario-navigator 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,2 @@
1
+ dist/
2
+ *.egg-info/
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.5
2
+ Name: scenario-navigator
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Scenario Navigator API — news scenario in, ranked stock impact out.
5
+ Project-URL: Homepage, https://scenarionavigator.io
6
+ Project-URL: Documentation, https://scenarionavigator.io/docs
7
+ Author-email: Scenario Navigator <partners@scenarionavigator.io>
8
+ License: MIT
9
+ Keywords: api,finance,llm,news,stocks
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: requests>=2.28
12
+ Provides-Extra: mcp
13
+ Requires-Dist: mcp>=1.0; extra == 'mcp'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # scenario-navigator
17
+
18
+ Official Python SDK for the [Scenario Navigator API](https://scenarionavigator.io) — news scenario in, ranked stock impact out.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install scenario-navigator
24
+ ```
25
+
26
+ ## Quickstart (no signup)
27
+
28
+ ```python
29
+ from scenario_navigator_sdk import Client
30
+
31
+ client = Client.with_trial_token() # free 1-hour trial key
32
+ analysis = client.analyze("OPEC announces a surprise 2M barrel production cut")
33
+
34
+ for stock in analysis.benefiting:
35
+ print(stock.stock, stock.confidence, "—", stock.reason)
36
+ ```
37
+
38
+ With a partner key: `Client(api_key="sn_live_...")`.
39
+
40
+ ## Depths
41
+
42
+ | depth | latency | notes |
43
+ |---|---|---|
44
+ | `instant` | seconds | synchronous, no web search, confidence caps at `medium`, `preliminary=True` |
45
+ | `fast` (default) | ~1 min | full ensemble with live web search |
46
+ | `deep` | 2–3 min | deepest research pass |
47
+
48
+ ```python
49
+ quick = client.analyze("Fed cuts rates 50bps", depth="instant")
50
+ deep = client.analyze("Fed cuts rates 50bps", depth="deep", timeout=400)
51
+ ```
52
+
53
+ ## The instant read path: the feed
54
+
55
+ Trending market scenarios are pre-analyzed around the clock — no submission, <100ms:
56
+
57
+ ```python
58
+ feed = client.feed(limit=10, include_analysis=True)
59
+ for item in feed["items"]:
60
+ print(item["text"], item["analysis"])
61
+ ```
62
+
63
+ ## Webhooks
64
+
65
+ ```python
66
+ hook = client.create_webhook("https://example.com/sn-webhook")
67
+ print(hook["secret"]) # shown once — store it
68
+
69
+ # In your webhook handler:
70
+ from scenario_navigator_sdk import verify_webhook_signature
71
+ ok = verify_webhook_signature(request_body_bytes,
72
+ request.headers["X-SN-Signature"],
73
+ secret=hook["secret"])
74
+ ```
75
+
76
+ ## Account
77
+
78
+ ```python
79
+ client.usage() # today / month-to-date / 30-day series + your limits
80
+ client.rotate_key() # new key returned; old key lives 24h
81
+ ```
82
+
83
+ ## MCP server (AI agents)
84
+
85
+ ```bash
86
+ pip install "scenario-navigator[mcp]"
87
+ SCEN_NAV_TOKEN=sn_live_... python -m scenario_navigator_sdk.mcp_server
88
+ ```
89
+
90
+ Exposes `analyze_scenario` and `trending_scenarios` tools over MCP stdio.
91
+
92
+ ## Errors
93
+
94
+ All failures raise `ApiError` with `status_code`, `code` (e.g. `rate_limited`,
95
+ `quota_exceeded`), and `request_id` — include the `request_id` in support requests.
@@ -0,0 +1,80 @@
1
+ # scenario-navigator
2
+
3
+ Official Python SDK for the [Scenario Navigator API](https://scenarionavigator.io) — news scenario in, ranked stock impact out.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install scenario-navigator
9
+ ```
10
+
11
+ ## Quickstart (no signup)
12
+
13
+ ```python
14
+ from scenario_navigator_sdk import Client
15
+
16
+ client = Client.with_trial_token() # free 1-hour trial key
17
+ analysis = client.analyze("OPEC announces a surprise 2M barrel production cut")
18
+
19
+ for stock in analysis.benefiting:
20
+ print(stock.stock, stock.confidence, "—", stock.reason)
21
+ ```
22
+
23
+ With a partner key: `Client(api_key="sn_live_...")`.
24
+
25
+ ## Depths
26
+
27
+ | depth | latency | notes |
28
+ |---|---|---|
29
+ | `instant` | seconds | synchronous, no web search, confidence caps at `medium`, `preliminary=True` |
30
+ | `fast` (default) | ~1 min | full ensemble with live web search |
31
+ | `deep` | 2–3 min | deepest research pass |
32
+
33
+ ```python
34
+ quick = client.analyze("Fed cuts rates 50bps", depth="instant")
35
+ deep = client.analyze("Fed cuts rates 50bps", depth="deep", timeout=400)
36
+ ```
37
+
38
+ ## The instant read path: the feed
39
+
40
+ Trending market scenarios are pre-analyzed around the clock — no submission, <100ms:
41
+
42
+ ```python
43
+ feed = client.feed(limit=10, include_analysis=True)
44
+ for item in feed["items"]:
45
+ print(item["text"], item["analysis"])
46
+ ```
47
+
48
+ ## Webhooks
49
+
50
+ ```python
51
+ hook = client.create_webhook("https://example.com/sn-webhook")
52
+ print(hook["secret"]) # shown once — store it
53
+
54
+ # In your webhook handler:
55
+ from scenario_navigator_sdk import verify_webhook_signature
56
+ ok = verify_webhook_signature(request_body_bytes,
57
+ request.headers["X-SN-Signature"],
58
+ secret=hook["secret"])
59
+ ```
60
+
61
+ ## Account
62
+
63
+ ```python
64
+ client.usage() # today / month-to-date / 30-day series + your limits
65
+ client.rotate_key() # new key returned; old key lives 24h
66
+ ```
67
+
68
+ ## MCP server (AI agents)
69
+
70
+ ```bash
71
+ pip install "scenario-navigator[mcp]"
72
+ SCEN_NAV_TOKEN=sn_live_... python -m scenario_navigator_sdk.mcp_server
73
+ ```
74
+
75
+ Exposes `analyze_scenario` and `trending_scenarios` tools over MCP stdio.
76
+
77
+ ## Errors
78
+
79
+ All failures raise `ApiError` with `status_code`, `code` (e.g. `rate_limited`,
80
+ `quota_exceeded`), and `request_id` — include the `request_id` in support requests.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "scenario-navigator"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Scenario Navigator API — news scenario in, ranked stock impact out."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Scenario Navigator", email = "partners@scenarionavigator.io" }]
13
+ dependencies = ["requests>=2.28"]
14
+ keywords = ["finance", "stocks", "news", "llm", "api"]
15
+
16
+ [project.optional-dependencies]
17
+ mcp = ["mcp>=1.0"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://scenarionavigator.io"
21
+ Documentation = "https://scenarionavigator.io/docs"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["scenario_navigator_sdk"]
@@ -0,0 +1,253 @@
1
+ """Scenario Navigator Python SDK.
2
+
3
+ from scenario_navigator_sdk import Client
4
+
5
+ client = Client(api_key="sn_live_...") # or a trial token
6
+ analysis = client.analyze("OPEC announces a surprise 2M barrel cut")
7
+ for stock in analysis.benefiting:
8
+ print(stock.stock, stock.confidence, stock.reason)
9
+
10
+ No key yet? ``Client.with_trial_token()`` mints a free 1-hour trial key.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import hmac
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ import requests
21
+
22
+ __all__ = [
23
+ "Client",
24
+ "Analysis",
25
+ "Stock",
26
+ "ApiError",
27
+ "verify_webhook_signature",
28
+ ]
29
+
30
+ DEFAULT_BASE_URL = "https://api.scenarionavigator.io"
31
+
32
+
33
+ class ApiError(Exception):
34
+ """API returned an error. Carries status_code, code, and request_id."""
35
+
36
+ def __init__(self, status_code: int, detail: Any, code: str = "error",
37
+ request_id: Optional[str] = None):
38
+ self.status_code = status_code
39
+ self.detail = detail
40
+ self.code = code
41
+ self.request_id = request_id
42
+ super().__init__(f"[{status_code} {code}] {detail} (request_id={request_id})")
43
+
44
+
45
+ @dataclass
46
+ class Stock:
47
+ stock: str
48
+ name: Optional[str] = None
49
+ reason: Optional[str] = None
50
+ confidence: str = "low" # high | medium | low
51
+
52
+
53
+ @dataclass
54
+ class Analysis:
55
+ benefiting: List[Stock] = field(default_factory=list)
56
+ hurting: List[Stock] = field(default_factory=list)
57
+ depth: Optional[str] = None
58
+ preliminary: bool = False
59
+ slug: Optional[str] = None
60
+ scenario_id: Optional[str] = None
61
+ job_id: Optional[str] = None
62
+
63
+ @classmethod
64
+ def _from_results(cls, results: Dict[str, Any], **extra) -> "Analysis":
65
+ def stocks(items):
66
+ return [
67
+ Stock(
68
+ stock=i.get("stock", ""),
69
+ name=i.get("name"),
70
+ reason=i.get("reason"),
71
+ confidence=i.get("confidence", "low"),
72
+ )
73
+ for i in (items or [])
74
+ ]
75
+
76
+ return cls(
77
+ benefiting=stocks(results.get("benefiting")),
78
+ hurting=stocks(results.get("hurting")),
79
+ **extra,
80
+ )
81
+
82
+
83
+ class Client:
84
+ """Synchronous client for the Scenario Navigator public API."""
85
+
86
+ def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL,
87
+ timeout: float = 30.0, session: Optional[requests.Session] = None):
88
+ self.api_key = api_key
89
+ self.base_url = base_url.rstrip("/")
90
+ self.timeout = timeout
91
+ self._session = session or requests.Session()
92
+
93
+ # ------------------------------------------------------------------ util
94
+ @classmethod
95
+ def with_trial_token(cls, base_url: str = DEFAULT_BASE_URL) -> "Client":
96
+ """Mint a free trial key (1 hour, limited runs) and return a Client."""
97
+ resp = requests.post(f"{base_url.rstrip('/')}/api/public/v1/trial-token",
98
+ timeout=15)
99
+ if resp.status_code != 200:
100
+ raise ApiError(resp.status_code, resp.text)
101
+ return cls(resp.json()["token"], base_url=base_url)
102
+
103
+ def _request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
104
+ headers = kwargs.pop("headers", {})
105
+ headers.setdefault("Authorization", f"Bearer {self.api_key}")
106
+ # allow_redirects=False: the API's duplicate-scenario 303 must reach
107
+ # this handler, not be silently followed as a GET by requests.
108
+ resp = self._session.request(
109
+ method, f"{self.base_url}{path}", headers=headers,
110
+ timeout=self.timeout, allow_redirects=False, **kwargs,
111
+ )
112
+ if resp.status_code == 303:
113
+ # Duplicate scenario — surface the existing slug.
114
+ location = resp.headers.get("Location", "")
115
+ raise ApiError(303, f"Duplicate scenario: {location}", code="duplicate")
116
+ try:
117
+ body = resp.json()
118
+ except ValueError:
119
+ body = {"detail": resp.text}
120
+ if resp.status_code >= 400:
121
+ err = body.get("error", {}) if isinstance(body, dict) else {}
122
+ raise ApiError(
123
+ resp.status_code,
124
+ body.get("detail") if isinstance(body, dict) else body,
125
+ code=err.get("code", "error"),
126
+ request_id=err.get("request_id"),
127
+ )
128
+ return body
129
+
130
+ # ------------------------------------------------------------- analysis
131
+ def analyze(self, text: str, depth: str = "fast", *, wait: bool = True,
132
+ timeout: float = 300.0, poll_interval: float = 3.0,
133
+ visibility: Optional[str] = None,
134
+ idempotency_key: Optional[str] = None) -> Analysis:
135
+ """Submit a scenario. depth: "instant" (sync, seconds), "fast", "deep".
136
+
137
+ With wait=True (default) the async depths poll until completion and
138
+ return the finished Analysis. With wait=False, returns an Analysis
139
+ carrying only job_id/slug/scenario_id — poll with get_job().
140
+ """
141
+ payload: Dict[str, Any] = {"text": text, "depth": depth}
142
+ if visibility:
143
+ payload["visibility"] = visibility
144
+ headers = {}
145
+ if idempotency_key:
146
+ headers["Idempotency-Key"] = idempotency_key
147
+
148
+ try:
149
+ body = self._request("POST", "/api/public/v1/scenarios",
150
+ json=payload, headers=headers)
151
+ except ApiError as exc:
152
+ if exc.code != "duplicate":
153
+ raise
154
+ # Duplicate: the Location points at the existing scenario —
155
+ # return its saved analysis instead of failing.
156
+ slug = str(exc.detail).rstrip("/").rsplit("/", 1)[-1]
157
+ existing = self.get_scenario(slug)
158
+ return Analysis._from_results(
159
+ existing.get("analysis_results") or {},
160
+ depth=depth, slug=slug,
161
+ scenario_id=existing.get("id"),
162
+ )
163
+
164
+ if depth == "instant":
165
+ results = (body.get("result") or {}).get("results") or {}
166
+ return Analysis._from_results(
167
+ results, depth="instant",
168
+ preliminary=bool(body.get("preliminary")),
169
+ )
170
+
171
+ stub = Analysis(job_id=body.get("job_id"), slug=body.get("slug"),
172
+ scenario_id=body.get("scenario_id"), depth=depth)
173
+ if not wait:
174
+ return stub
175
+
176
+ deadline = time.monotonic() + timeout
177
+ while time.monotonic() < deadline:
178
+ job = self.get_job(stub.job_id)
179
+ status = job.get("status")
180
+ if status == "SUCCESS":
181
+ results = (job.get("result") or {}).get("results") or {}
182
+ return Analysis._from_results(
183
+ results, depth=depth, job_id=stub.job_id,
184
+ slug=stub.slug, scenario_id=stub.scenario_id,
185
+ )
186
+ if status == "FAILURE":
187
+ raise ApiError(500, (job.get("result") or {}).get(
188
+ "error", "Analysis failed"), code="analysis_failed")
189
+ time.sleep(poll_interval)
190
+ raise ApiError(408, f"Timed out after {timeout}s waiting for job "
191
+ f"{stub.job_id}", code="timeout")
192
+
193
+ def get_job(self, job_id: str) -> Dict[str, Any]:
194
+ return self._request("GET", f"/api/public/v1/job/{job_id}")
195
+
196
+ def get_scenario(self, slug: str) -> Dict[str, Any]:
197
+ return self._request("GET", f"/api/public/v1/scenarios/{slug}")
198
+
199
+ # ------------------------------------------------------------------ feed
200
+ def feed(self, *, limit: int = 25, cursor: Optional[str] = None,
201
+ include_analysis: bool = False) -> Dict[str, Any]:
202
+ """Pre-analyzed trending scenarios — the <100ms read path."""
203
+ params: Dict[str, Any] = {"limit": limit}
204
+ if cursor:
205
+ params["cursor"] = cursor
206
+ if include_analysis:
207
+ params["include"] = "analysis"
208
+ return self._request("GET", "/api/public/v1/feed/scenarios",
209
+ params=params)
210
+
211
+ # ----------------------------------------------------------------- account
212
+ def usage(self) -> Dict[str, Any]:
213
+ return self._request("GET", "/api/public/v1/usage")
214
+
215
+ def rotate_key(self) -> Dict[str, Any]:
216
+ """Mint a replacement key (old key lives 24h). Update self.api_key."""
217
+ body = self._request("POST", "/api/public/v1/keys/rotate")
218
+ self.api_key = body["token"]
219
+ return body
220
+
221
+ # ---------------------------------------------------------------- webhooks
222
+ def create_webhook(self, url: str) -> Dict[str, Any]:
223
+ return self._request("POST", "/api/public/v1/webhooks",
224
+ json={"url": url})
225
+
226
+ def list_webhooks(self) -> Dict[str, Any]:
227
+ return self._request("GET", "/api/public/v1/webhooks")
228
+
229
+ def delete_webhook(self, webhook_id: str) -> Dict[str, Any]:
230
+ return self._request("DELETE",
231
+ f"/api/public/v1/webhooks/{webhook_id}")
232
+
233
+
234
+ def verify_webhook_signature(body: bytes, signature_header: str, secret: str,
235
+ tolerance_seconds: int = 300) -> bool:
236
+ """Verify X-SN-Signature ("t=<unix>,v1=<hex>") for a webhook delivery.
237
+
238
+ if not verify_webhook_signature(request.body, sig_header, secret):
239
+ return 400
240
+ """
241
+ try:
242
+ parts = dict(p.split("=", 1) for p in signature_header.split(","))
243
+ ts = int(parts["t"])
244
+ provided = parts["v1"]
245
+ except (KeyError, ValueError, AttributeError):
246
+ return False
247
+ if abs(time.time() - ts) > tolerance_seconds:
248
+ return False
249
+ if isinstance(body, str):
250
+ body = body.encode()
251
+ expected = hmac.new(secret.encode(), b"%d." % ts + body,
252
+ hashlib.sha256).hexdigest()
253
+ return hmac.compare_digest(expected, provided)
@@ -0,0 +1,66 @@
1
+ """MCP server for Scenario Navigator — agent-native access to the API.
2
+
3
+ Run (requires `pip install "scenario-navigator[mcp]"`):
4
+
5
+ SCEN_NAV_TOKEN=sn_live_... python -m scenario_navigator_sdk.mcp_server
6
+
7
+ Or in an MCP client config:
8
+
9
+ {"command": "python", "args": ["-m", "scenario_navigator_sdk.mcp_server"],
10
+ "env": {"SCEN_NAV_TOKEN": "sn_live_..."}}
11
+
12
+ Without SCEN_NAV_TOKEN a free trial key is minted automatically.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ from . import Client
19
+
20
+ try:
21
+ from mcp.server.fastmcp import FastMCP
22
+ except ImportError as exc: # pragma: no cover
23
+ raise SystemExit(
24
+ 'MCP extra not installed — pip install "scenario-navigator[mcp]"'
25
+ ) from exc
26
+
27
+ mcp = FastMCP("scenario-navigator")
28
+
29
+ _client: Client | None = None
30
+
31
+
32
+ def _get_client() -> Client:
33
+ global _client
34
+ if _client is None:
35
+ token = os.environ.get("SCEN_NAV_TOKEN")
36
+ base = os.environ.get("SCEN_NAV_BASE_URL", "https://api.scenarionavigator.io")
37
+ _client = (
38
+ Client(token, base_url=base) if token else Client.with_trial_token(base)
39
+ )
40
+ return _client
41
+
42
+
43
+ @mcp.tool()
44
+ def analyze_scenario(text: str, depth: str = "fast") -> dict:
45
+ """Analyze a news scenario: returns ranked benefiting/hurting US stocks
46
+ with confidence labels (high/medium/low). depth: "instant" (seconds,
47
+ preliminary), "fast" (~1 min), or "deep" (2-3 min, deepest research)."""
48
+ analysis = _get_client().analyze(text, depth=depth)
49
+ return {
50
+ "depth": analysis.depth,
51
+ "preliminary": analysis.preliminary,
52
+ "benefiting": [vars(s) for s in analysis.benefiting],
53
+ "hurting": [vars(s) for s in analysis.hurting],
54
+ "slug": analysis.slug,
55
+ }
56
+
57
+
58
+ @mcp.tool()
59
+ def trending_scenarios(limit: int = 10, include_analysis: bool = True) -> dict:
60
+ """Latest pre-analyzed trending market scenarios (instant, no submission
61
+ needed). Each item can include its ranked winners/losers inline."""
62
+ return _get_client().feed(limit=limit, include_analysis=include_analysis)
63
+
64
+
65
+ if __name__ == "__main__":
66
+ mcp.run()