Metadata-Version: 2.5
Name: scenario-navigator
Version: 0.3.1
Summary: Official Python SDK for the Scenario Navigator API — news scenario in, ranked stock impact out.
Project-URL: Homepage, https://scenarionavigator.io
Project-URL: Documentation, https://scenarionavigator.io/docs
Author-email: Scenario Navigator <partners@scenarionavigator.io>
License: MIT
Keywords: api,finance,llm,news,stocks
Requires-Python: >=3.9
Requires-Dist: requests>=2.28
Provides-Extra: mcp
Requires-Dist: mcp<2,>=1.9.4; extra == 'mcp'
Description-Content-Type: text/markdown

# scenario-navigator

Official Python SDK for the [Scenario Navigator API](https://scenarionavigator.io) — news scenario in, ranked stock impact out.

The full reference (every method, the `analyze()` kwargs, error codes,
the measured latency figures) is at
[scenarionavigator.io/docs/sdk](https://scenarionavigator.io/docs/sdk).
Release notes: `sdk/python/CHANGELOG.md` in the repository; the API's own
changelog is at https://scenarionavigator.io/docs/changelog.

## Install

```bash
pip install scenario-navigator
```

Python 3.9 or newer. The `[mcp]` extra (the MCP server below) needs Python
3.10 or newer.

## Quickstart (no signup)

```python
from scenario_navigator_sdk import Client

client = Client.with_trial_token()          # free trial key: one hour, at most one mint per IP per UTC day
analysis = client.analyze("OPEC announces a surprise 2M barrel production cut")

for stock in analysis.benefiting:
    print(stock.stock, stock.confidence, "—", stock.reason)
```

With an API key from your account: `Client(api_key="sn_live_...")`, or
`Client.from_env()` to read it from `SCEN_NAV_TOKEN` — the same variable the
MCP server and every docs sample use (`SCEN_NAV_BASE_URL` overrides the host).

`analyze()` submits, then polls `GET /job/{id}` every 3 s until the job ends
(`SUCCESS` or `FAILURE`; results are kept 24 hours) — fast ≈ 1 min, deep
≈ 3 min. Paste today's headline: an exact repeat of a scenario already on the
site answers 303, and `analyze()` follows it to the saved record and returns
that analysis instead of failing. `wait=False` returns the `job_id` to poll
yourself with `get_job()`.

## Depths

| depth | latency | notes |
|---|---|---|
| `instant` | seconds | synchronous, no web search, confidence caps at `medium`, `preliminary=True` — unless a published record already covers the exact headline: then it answers free with `preliminary=False` (confidence may be `high`), still instant-tier fields only |
| `fast` (default) | ~1 min | the trade answer: ranked exposures with direction, impact, confidence, horizon, one-line reason |
| `deep` | ~3 min | the publishable artifact: causal chains, exposure order, break conditions, cited sources (sources are deep only) |

```python
quick = client.analyze("Fed cuts rates 50bps", depth="instant")
deep  = client.analyze("Fed cuts rates 50bps", depth="deep", timeout=400)
```

## analyze() kwargs

`analyze(text, depth="fast", *, wait=True, timeout=300.0, poll_interval=3.0,
visibility=None, idempotency_key=None, watchlist=None, max_results=None,
minimum_confidence=None, include_evidence=None)`

- `wait=True` polls `GET /job/{id}` until the job is terminal and returns
  the `Analysis`; `wait=False` returns the handle (`job_id`, `slug`,
  `links`, `published`, `expected_seconds`) at once.
- `timeout` is the poll deadline (`PollTimeoutError`; the run continues —
  keep its `job_id`). Set it from p95, not the mean: the September 1,
  2026 field benchmark (`docs/benchmarks/2026-09-01-fast-vs-deep/README.md`
  in the repository, figures from its `summary.json`; n = 50 per depth)
  measured p95 completions of 100.9 s for fast and 224.5 s for deep — the
  figures on [/docs/jobs](https://scenarionavigator.io/docs/jobs); the
  default covers both.
- `visibility="private"` keeps the run off the public feed; the key's
  default applies when omitted (Free and trial keys default to public,
  keys created on a paid plan to private).
- `idempotency_key` is sent as the `Idempotency-Key` header. For fast and
  deep one is generated when you pass none and kept across the call's
  retries, so a resend after a 5xx or a dropped connection replays the
  original 202 instead of starting a second run (a keyed POST that meets a
  409 is retried as well).
- `watchlist` (instant only → `analysis.watchlist_hits`), `max_results`
  (1–25), `minimum_confidence` (`low` | `medium` | `high`) and
  `include_evidence` narrow what is returned.

A duplicate (the API's 303 — the gatekeeper matched an existing published
record) returns that record's saved analysis with `kind`, `origin` and
`published` filled from the record; when its analysis is still running the
call polls it until the deadline, then raises `ApiError` code
`duplicate_pending` — never an empty `Analysis`.

## Submit now, collect later

```python
handle = client.analyze("Fed cuts rates 50bps", depth="deep", wait=False)
job = client.get_job(handle.job_id)                 # status, state, terminal, result
analysis = client.wait_for_job(handle.job_id)       # the same loop analyze() runs

for event in client.stream_job(handle.job_id):      # GET /job/{id}/stream (SSE)
    partial = (event.get("result") or {}).get("partial_results")
    if partial:
        render(partial)                             # first exposures well before completion
    if event.get("terminal"):
        final = event["result"]["results"]

batch = client.analyze_batch(["Oil reaches $150", {"text": "Fed cuts 50bps", "depth": "deep"}])
polls = client.jobs([i["job_id"] for i in batch["items"] if i["status"] == "queued"])
```

`stream_job` falls back to `get_job` polling when the API's stream ends
with its `timeout` event (the 5-minute cap) or the connection drops.

## The Analysis object

`benefiting`, `hurting`, `mixed` (lists of `Stock`), `sources` (deep only),
`depth`, `preliminary`, `slug`, `scenario_id`, `job_id`, `state`, and the
labels the site requires: `kind` (`actual` | `hypothetical` — show the ◇
label on a hypothetical) and `origin` (`live` | `backtest` — show the ⟲
label on a backtest row), both read from the record on the duplicate path
and `None` on a fresh submission. Also `published`, `unlisted_reason`,
`headline`, `links`, `request_id`, `credits_charged` (the response's
`X-Credits-Charged`; `None` when the response carried none),
`credits_refunded`, `sandbox`, `expected_seconds`, and on instant
`watchlist_hits` and `archive`.

## Errors and retries

Every failure raises `ApiError` with `status_code`, `code`, `detail`,
`request_id`, `retry_after` (seconds, when the API sent `Retry-After`),
`reason` and `body`. Subclasses: `RateLimitError` (429 `rate_limited`),
`AuthenticationError` (401 `invalid_token`), `QuotaExceededError` (402
`quota_exceeded`), `AnalysisFailedError` (the job ended in FAILURE:
`error_code`, `retryable`) and `PollTimeoutError` (the SDK's own
deadline). `status_code` is `None` for those last two SDK-side conditions
(0.3.1; they were 408 / 500 before), `0` for `connection_error` (the
request never got an answer). `duplicate_pending` keeps the API's 303.
Every other `code` is the API envelope's own, passed through unchanged —
`forbidden`, `not_found`, `conflict`, `validation_error`, `invalid_request`,
`payload_too_large`, `internal_error`, `unavailable` and the rest of the
catalog at https://scenarionavigator.io/docs/reference#errors.

Retries: 429 is always retried; 5xx on GET; 5xx, 409 and connection
errors on a POST that carries an `Idempotency-Key`. A keyless POST is
never retried. `Retry-After` is honored (capped at 30 s per wait);
`max_retries` (default 3) bounds the loop. The trial mint is never
auto-retried — both of its caps are daily; the 429 carries `retry_after`
= seconds until the UTC day rolls over.

Include the `request_id` when you write to support at
partners@scenarionavigator.io (or the contact form at
https://scenarionavigator.io/contact?kind=support). `Client.requests_log()`
returns your key's own request log, one row per request, so you can look a
`request_id` up yourself. After every call `client.last_request_id` and
`client.last_rate_limit` (`{"limit", "remaining", "reset"}`) hold the
response's `X-Request-ID` and `X-RateLimit-*` headers.

## The instant read path: the feed

Trending market scenarios are pre-analyzed around the clock. No submission needed:

```python
feed = client.feed(limit=10, include_analysis=True, ticker="NVDA")
for item in feed["items"]:
    print(item["kind"], item["text"], item["analysis"])   # show ◇ when kind == "hypothetical"

matches = client.feed_match(["NVDA", "XOM"], since="2026-09-01T00:00:00+00:00", limit=10)
```

## Webhooks

Builder plan and up. One endpoint receives every event: `analysis.complete`,
`analysis.failed`, `scenario.matched` (watches), `scenario.unlisted`,
`scenario.listed` and `webhook.test`; the `X-SN-Event` header names each
delivery's event and the body's `id` is stable across retries (dedupe on it).

```python
hook = client.create_webhook("https://example.com/sn-webhook")
print(hook["secret"])  # shown once — store it
print(hook["events"])  # every event this endpoint can receive

# In your webhook handler:
from scenario_navigator_sdk import verify_webhook_signature
ok = verify_webhook_signature(request_body_bytes,
                              request.headers["X-SN-Signature"],
                              secret=hook["secret"])   # either v1= verifies during a rotation

client.test_webhook(hook["id"])           # signed webhook.test, never a strike
client.webhook_deliveries(hook["id"])     # last 50 attempts: status, error, duration
client.disable_webhook(hook["id"])        # stops in-flight retries too
client.enable_webhook(hook["id"])         # after the failure cap or your own Disable (a plan-lapse pause resumes by itself when a plan is back)
client.rotate_webhook_secret(hook["id"])  # old secret stays valid 24 h
client.create_watch(["NVDA", "AMD"], name="chips")   # scenario.matched fires on a hit
```

## Account

```python
client.usage()        # today / month-to-date / 30-day series + your limits
client.rotate_key()   # new key returned; old key lives 24h
client.set_visibility(slug, "private"); client.delete_scenario(slug); client.revalidate_scenario(slug)
client.list_scenarios(); client.requests_log(status="4xx")
client.track_record() # ONE blended number over live + ⟲ backtest calls; note / components / live_only say how much is backtest
```

## MCP server (AI agents)

The package ships an MCP server that gives Claude Desktop, Cursor, Claude
Code or any MCP client the tools `analyze_scenario`, `submit_scenario`,
`get_job_result`, `trending_scenarios`, `match_portfolio`, `usage_status`,
`get_scenario_markets`, `get_track_record`, `get_scenario_outcomes` and
`watch_portfolio` (0.3.1; the published 0.2.0 wheel serves
`analyze_scenario`, `trending_scenarios`, `match_portfolio` and
`usage_status`, and no console script). Copy-paste configs for each client
are at [scenarionavigator.io/docs/mcp](https://scenarionavigator.io/docs/mcp).

With [uv](https://docs.astral.sh/uv/) installed nothing else is needed.
This form runs the module of whatever PyPI serves, so it works today
(`--python 3.11`: the `[mcp]` extra needs Python 3.10 or newer):

```bash
uvx --python 3.11 --from "scenario-navigator[mcp]" python -m scenario_navigator_sdk.mcp_server
```

```json
{"mcpServers": {"scenario-navigator": {
  "command": "uvx",
  "args": ["--python", "3.11", "--from", "scenario-navigator[mcp]", "python", "-m", "scenario_navigator_sdk.mcp_server"],
  "env": {"SCEN_NAV_TOKEN": "<TOKEN>"}}}}
```

Note for 0.2.0: its `analyze_scenario` polls a job after every submit, so
on a sandbox (`sn_test_`) key — which answers at once with a canned 200
and no job — it fails with `Job not found`; use a live or trial key with
the published version. 0.3.1 returns the canned result at zero credits.

When 0.3.1 is on PyPI (`pip index versions scenario-navigator` lists it),
the `scenario-navigator-mcp` console script replaces the module form:

```bash
uvx --from "scenario-navigator[mcp]>=0.3.1" scenario-navigator-mcp
```

```json
{"mcpServers": {"scenario-navigator": {
  "command": "uvx",
  "args": ["--from", "scenario-navigator[mcp]>=0.3.1", "scenario-navigator-mcp"],
  "env": {"SCEN_NAV_TOKEN": "<TOKEN>"}}}}
```

Without uv, install the extra into a Python 3.10+ interpreter and run the
module with that interpreter's absolute path
(`python3 -c "import sys; print(sys.executable)"`):

```bash
pip install "scenario-navigator[mcp]"
SCEN_NAV_TOKEN=sn_live_... /full/path/to/python3 -m scenario_navigator_sdk.mcp_server
```

Environment: `SCEN_NAV_TOKEN` (your key; without it the server mints a
trial key that renews at most once per IP per UTC day, each mint covering
one fast run) and `SCEN_NAV_BASE_URL` (the API origin, default
`https://api.scenarionavigator.io`; override it for a staging or
self-hosted deployment).

Verify: quit and reopen the client; Claude Desktop lists
`scenario-navigator` and its tools in the connectors menu, `claude mcp list`
shows `✔ Connected`, Cursor's Settings → MCP shows a green dot. With Node,
`npx -y @modelcontextprotocol/inspector uvx --python 3.11 --from "scenario-navigator[mcp]" python -m scenario_navigator_sdk.mcp_server`
opens the Inspector (with 0.3.1 on PyPI:
`npx -y @modelcontextprotocol/inspector uvx --from "scenario-navigator[mcp]>=0.3.1" scenario-navigator-mcp`). Run by hand the server prints nothing and waits —
that is the success state.

Troubleshooting: `spawn python ENOENT` / `spawn uvx ENOENT` / "server
disconnected" — the client does not read your shell PATH; use the absolute
path from `which uvx` or `which python3`. `No module named
scenario_navigator_sdk` — installed into a different interpreter than the
config runs. `MCP extra not installed` — add the `[mcp]` extra (Python
3.10+). Logs: macOS `~/Library/Logs/Claude/mcp-server-scenario-navigator.log`,
Windows `%APPDATA%\Claude\logs\`, Cursor Output → MCP Logs. On Windows use
`py -m scenario_navigator_sdk.mcp_server` or the full `python.exe` path.
Full list: https://scenarionavigator.io/docs/mcp#troubleshooting
