> ## Documentation Index
> Fetch the complete documentation index at: https://stockcontext.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Batch workflows

> Run many symbols with bounded concurrency, resolve coverage first, and stay under your quota.

There is no bulk fundamentals endpoint — a factor batch is your own loop, one symbol per request. Use `stock_universe` to build the symbol set, fetch the overview for covered rows, and deepen only the shortlist. The rate you run at is set by your plan's per-minute limit.

## Resolve coverage before you spend the batch

`stock_universe {"coverage":"has_fundamentals"}` returns the symbols eligible for a fundamentals fan-out without one resolution call per ticker. Page with `next_offset`; use the `shape` filter when a factor only applies to a statement family. Use `stock_search` only for user-entered names/tickers, and `stock_reference {"catalog":"coverage"}` for measured aggregate rates.

## Concurrency follows the minute limit

Pick your in-flight count from the plan's per-minute limit and leave headroom for retries.

| Plan    | Minute limit | In-flight |
| ------- | -----------: | --------- |
| Free    |     30 / min | 1–2       |
| Starter |     60 / min | 2–3       |
| Builder |    300 / min | 5 or more |

Also mind the quota: Free is 100 requests **per day**, which a single 30-symbol watchlist refresh can exhaust once you add a resolve call and an overview call each. Read `X-Credits-Remaining` after each authenticated tool call when it is present, and stop immediately on a `quota_exhausted` `429`. See [Plans and limits](/docs/reference/plans-and-limits).

## A bounded worker pool

Both versions cap in-flight requests and back off on a `429`.

<Tabs>
  <Tab title="Python">
    ```python title="batch.py" theme={null}
    import asyncio
    import httpx

    BASE = "https://api.stockcontext.com"


    async def overview(client, sem, symbol, key):
        async with sem:  # cap in-flight at the semaphore's size
            res = await client.post(
                f"{BASE}/v1/tools/stock_overview",
                json={"symbol": symbol},
                headers={"X-API-Key": key},
            )
            if res.status_code == 429:
                reason = None
                try:
                    reason = res.json().get("reason")
                except ValueError:
                    pass
                if reason == "quota_exhausted":
                    remaining = res.headers.get("X-Credits-Remaining", "0")
                    raise RuntimeError(f"quota exhausted; credits remaining: {remaining}")
                retry_after = float(res.headers.get("Retry-After", 5))
                await asyncio.sleep(retry_after)
                return await overview(client, sem, symbol, key)
            res.raise_for_status()
            return symbol, res.json()


    async def run(symbols, key, in_flight=2):  # Free: 1–2, Builder: 5+
        sem = asyncio.Semaphore(in_flight)
        async with httpx.AsyncClient(timeout=15) as client:
            return await asyncio.gather(*(overview(client, sem, s, key) for s in symbols))
    ```
  </Tab>

  <Tab title="TypeScript">
    ```ts title="batch.ts" theme={null}
    const BASE = "https://api.stockcontext.com";
    const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

    async function overview(symbol: string, key: string): Promise<[string, unknown]> {
      const res = await fetch(`${BASE}/v1/tools/stock_overview`, {
        method: "POST",
        headers: { "X-API-Key": key, "Content-Type": "application/json" },
        body: JSON.stringify({ symbol }),
      });
      if (res.status === 429) {
        let reason: unknown;
        try {
          reason = (await res.clone().json()).reason;
        } catch {}
        if (reason === "quota_exhausted") {
          throw new Error(
            `quota exhausted; credits remaining: ${res.headers.get("X-Credits-Remaining") ?? "0"}`,
          );
        }
        const retryAfter = Number(res.headers.get("Retry-After") ?? 5);
        await sleep(retryAfter * 1000);
        return overview(symbol, key);
      }
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return [symbol, await res.json()];
    }

    // Plain promise pool, no dependency. Free: inFlight 1–2, Builder: 5+.
    async function run(symbols: string[], key: string, inFlight = 2) {
      const queue = [...symbols];
      const results: [string, unknown][] = [];
      const workers = Array.from({ length: inFlight }, async () => {
        let symbol: string | undefined;
        while ((symbol = queue.shift())) results.push(await overview(symbol, key));
      });
      await Promise.all(workers);
      return results;
    }
    ```
  </Tab>
</Tabs>

The typed `reason` separates a minute `429` from a quota `429`; do not infer it from timing. For the full policy (attempt caps, total-wait budget), wrap each request with the loop in [Errors and retries](/docs/guides/errors-and-retries).

## Watching for new data

Schema 2 successful `200` responses include a composite `ETag`. Store the response and send the same request body later with `If-None-Match`. A `304 Not Modified` has an empty body and means your cached response is still current for that exact tool, schema, params, fundamentals build, policy version, and market cursor.

For fundamentals, watch `meta.as_of.sec`, `meta.published_at`, `meta.build.fundamentals`, and `meta.last_verified_at` when present. For price-backed responses, also watch `meta.as_of.market` and the response freshness fields such as `sessions_behind`.

Use this polling shape for watchlists:

1. Resolve the symbol once with `stock_search`.
2. Fetch schema 2 data and persist the body plus `ETag`.
3. Re-poll with the same body and `If-None-Match`.
4. On `304`, reuse your cached body. On `200`, replace it and store the new `ETag`.

Schema 2 `304`s still pass authentication and rate limiting, but the quota credit is refunded at the API edge. Do not expect a response body on `304`.

## Keep SEC-backed tools out of a tight loop

`stock_filings`, `stock_insider`, and `stock_ownership` read SEC data and can be slow cold, so pull them only for the shortlist a user actually opens — in a background job, cached hard.

## Where batches go wrong

* Fanning out `stock_overview` over symbols you never checked with `stock_search`, spending quota on `ingestion_pending` tickers.
* Continuing to send after a `429` instead of waiting.
* Looping on `quota_exhausted` instead of stopping until the window resets.
* Rendering a refused field (`unavailable` is present) as `0`. Carry the refusal token through.

<Card title="Production patterns" icon="activity" href="/docs/guides/production-patterns">
  Caching, key safety, and SEC loading states around the loop.
</Card>
