# FlashAlpha Risk Engine - canonical reference

**Status: OPEN BETA.** Any authenticated FlashAlpha API key works - every plan,
including Free. During the beta there is no extra charge and no tier gate. When
the beta ends, the Risk Engine becomes part of the **Growth plan** (and above);
beta users on lower tiers will get notice before the gate goes up.

This document is the canonical reference for the Risk Engine API. If another
document disagrees with this one, this one wins. Error codes, flag codes and
the wire contract documented here are STABLE: codes are never renamed or
repurposed, only added (`risk.flags.v1`).

---

## Endpoint

```
POST /v1/risk/analyze
Content-Type: application/json
X-Api-Key: <your FlashAlpha API key>
```

Synchronous and stateless: you post positions, you get back risk flags, hedge
suggestions and (optionally) metrics and a portfolio rollup. Nothing is stored
server-side about your positions between calls (see "Beta usage capture" for
the beta telemetry exception).

**You only have to say what you hold.** `spot` and per-leg `impliedVol` are
OPTIONAL - omit them and the engine resolves the live spot for each symbol and
the live implied vol for each strike from FlashAlpha's own market data, using
your API key (so your plan's data entitlements apply). Supply them explicitly
whenever you want to pin a scenario - a what-if, a back-test, a stress at a
level that is not the market - and the engine uses yours untouched.

Try it without writing any JSON: the interactive playground at
`/docs/risk-playground` builds a book from live-chain dropdowns and shows the
exact request it sent.

Body limit: **256 KB**. Rate limit: **at least 60 requests per minute per API
key** (HTTP 429 with a `Retry-After` header when exceeded). The budget is
enforced per serving node, so bursts may be admitted beyond 60 - design for 60
and do not rely on observing more.

## Request shape

```jsonc
{
  "positions": [                       // 1..25 positions, max 20 distinct symbols
    {
      "id": "my-spread",               // optional; auto "position[N]"; must be unique
      "symbol": "SPY",                 // required
      "spot": 447.12,                  // OPTIONAL - omit for the live spot.
                                       // If supplied: finite, > 0.
      "today": "2026-08-05",           // optional valuation date, YYYY-MM-DD.
                                       // Default: the CURRENT US-EASTERN TRADING DATE
                                       // (not the UTC date). Malformed values are a
                                       // 400 invalid_today, never silently replaced.
      "rate": 0.045,                   // optional, finite, [-1, 1]; default 0.045
      "dividendYield": 0.013,          // optional, finite, [0, 1]; default 0.013
      "multiplier": 100,               // optional, finite, > 0; default 100 (equity options)
      "beta": 1.0,                     // optional, finite, |beta| <= 10; default 1.0
      "legs": [                        // option legs (this OR exposure required)
        {                              // max 50 per position, 200 per request
          "action": "sell",            // buy|long | sell|short
          "type": "put",               // call|c | put|p
          "strike": 440,               // finite, > 0
          "expiry": "2026-08-15",      // YYYY-MM-DD, not before `today`
          "impliedVol": 0.22,          // OPTIONAL - omit for this strike's
                                       // live IV. If supplied: (0, 5].
          "quantity": 10               // [1, 1000000]
        }
      ],
      "exposure": {                    // delta-1 underlying / CFD / shares
        "quantity": -2500,             // signed; |q| <= 1e7
        "multiplier": 1                // optional, > 0; default 1
      }
    }
  ],
  "thresholds": {                      // optional; absolute dollar limits, all > 0
    "maxAbsDollarDelta": 50000,
    "maxAbsDollarGamma": 10000,
    "maxAbsDollarVega": 5000,
    "maxAbsDollarTheta": 2000
  },
  "mandate": { "deltaTarget": 0 },     // optional; BOOK-level target (even 0)
  "include": ["risks","hedges","portfolio","metrics"],  // default: risks+hedges+portfolio
  "metrics": {                         // only read when "metrics" is included
    "horizonDays": 1,                  // [1, 90]
    "confidence": 0.95,                // [0.5, 1)
    "mcPaths": 5000,                   // [100, 20000]
    "mcSeed": 42,                      // deterministic per seed
    "annualVol": 0.30                  // (0, 5]; REQUIRED for VaR on legs-free positions
  }
}
```

## Response shape

```jsonc
{
  "status": "OK",
  "as_of": "2026-08-05T14:30:00Z",
  "flag_schema": "risk.flags.v1",
  "flags":  [ { "code", "category", "severity", "position_id", "symbol", "message", "data" } ],
  "hedges": [ { "ref", "symbol", "feasible", "action", "instrument", "quantity",
                "est_cost", "residual_dollar_delta", "rationale" } ],
  "metrics":   [ ... ],                // null unless requested
  "portfolio": { ... }                 // null unless requested (default on)
}
```

Severities are the lowercase strings `info | warn | high | critical`, never
integers. All flag/hedge property names are snake_case and pinned by contract
tests.

---

## Errors - the complete list

Every error the engine itself produces uses this envelope:

```json
{ "status": "ERROR", "error": "<code>", "message": "<human explanation>" }
```

### Transport / auth / limits (non-400)

| HTTP | `error` | When | What to do |
|------|---------|------|------------|
| 401 | `missing_api_key` | No `X-Api-Key` header | Send your FlashAlpha API key in the header |
| 401 | `invalid_api_key` | The key was explicitly rejected by account verification, or is malformed (over 256 chars) | Check the key in your FlashAlpha profile; rejections are cached for up to 60s |
| 429 | `rate_limited` | More than 60 requests in the current minute for this key (also returned under extreme system-wide load) | Honor the `Retry-After: 60` header |
| 503 | `auth_unavailable` | Account verification is degraded (outage, timeout or malformed upstream response). **Your key was NOT rejected** | Retry shortly; do not rotate your key |
| 500 | `internal_error` | Unhandled server fault | Retry; contact support if persistent |
| 413 | (framework body) | Request body over 256 KB | Split the request; the caps make everything fit well under the limit |
| 400 | (RFC 7807 problem details, NOT the envelope) | The request body is not parseable JSON at all | Fix the JSON; envelope errors below only apply to parseable bodies |

### Validation errors (HTTP 400, envelope shape)

Request-level:

| `error` | When |
|---------|------|
| `empty_request` | No positions posted |
| `too_many_positions` | More than 25 positions |
| `too_many_legs` | More than 200 legs in the request, or more than 50 in one position (message says which) |
| `too_many_symbols` | More than 20 distinct symbols |
| `duplicate_position_id` | Two positions resolve to the same id (explicit ids AND auto-generated `position[N]` ids are checked together) |
| `invalid_threshold` | A posted threshold is not a finite number > 0 |
| `invalid_mandate` | `mandate.deltaTarget` is not finite |
| `spot_unavailable` | `spot` was omitted and no live spot could be resolved for that symbol. Supply `spot` explicitly (the message names the symbol) |
| `iv_unavailable` | `impliedVol` was omitted and no live implied vol could be resolved for that contract. Supply `impliedVol` explicitly (the message names the symbol, expiry and strike) |
| `invalid_metrics_options` | `horizonDays` outside [1,90], `confidence` outside [0.5,1), `mcPaths` outside [100,20000], or `annualVol` outside (0,5] |

Position-level (message names the offending position id):

| `error` | When |
|---------|------|
| `invalid_symbol` | Missing/blank symbol |
| `invalid_spot` | A SUPPLIED spot is non-finite or <= 0 (omit the field to use the live spot) |
| `invalid_today` | `today` present but not `YYYY-MM-DD` (it is NEVER silently replaced) |
| `invalid_rate` | Rate non-finite or outside [-1, 1] |
| `invalid_dividend_yield` | Non-finite or outside [0, 1] |
| `invalid_multiplier` | Position or exposure multiplier non-finite or <= 0 |
| `invalid_beta` | Non-finite or absolute value over 10 |
| `empty_position` | Neither `legs` nor `exposure` present |
| `invalid_exposure` | Exposure quantity non-finite or absolute value over 1e7 |

Leg-level (message names position and leg index):

| `error` | When |
|---------|------|
| `invalid_action` | Not buy/long/sell/short |
| `invalid_type` | Not call/c/put/p |
| `invalid_strike` | Non-finite or <= 0 |
| `invalid_iv` | A SUPPLIED `impliedVol` is outside (0, 5] (omit the field to use the live IV) |
| `invalid_quantity` | Outside [1, 1000000] |
| `invalid_expiry` | Not `YYYY-MM-DD` |
| `expiry_in_past` | Expiry before the valuation date. NOTE: the default valuation date is the US-Eastern trading date, so a same-day (0DTE) position posted in the US evening is still accepted |

---

## Flag codes (`risk.flags.v1`)

Programs should switch on `code`, never parse `message`. Currently emitted:

**coverage** - `covered_call` (info; fires for the covered portion even when
partially covered, with `covered_contracts` in data), `uncovered_short_put`
(high), `uncovered_short_call` (critical), `naked_short_underlying` (critical),
`non_coterminal_coverage` (warn; a protective long expires before the short it
covers - coverage is coterminal by design).

**structure** - `unbounded_loss` (critical; UPSIDE-only by definition - a naked
short put is `uncovered_short_put` + `undefined_risk_position`, never
`unbounded_loss`), `undefined_risk_position` (high), `defined_risk_position`
(info).

**expiry** - `expired_leg_present` (critical), `expiring_today_position`
(warn), `short_leg_expiring_today` (high), `near_expiry_position` (info, <= 7
days), `multi_expiry_position` (info).

**moneyness / assignment** - `short_strike_through_spot` (high),
`itm_short_near_expiry` (high; critical at <= 1 DTE),
`atm_short_near_expiry_pin` (warn; within 1% of spot, <= 3 DTE),
`pin_risk_short_strike` (warn; within 0.5%, <= 1 DTE). Netted per
(type, strike, expiry): fully offset shorts do not flag.

**greeks (posture, always on)** - `net_short_gamma` (high), `net_long_gamma`
(info), `net_short_vega` (warn), `net_long_vega` (info), `negative_theta`
(info), `positive_theta` (info), `short_gamma_short_vega` (high).

**greeks (threshold breaches, only when thresholds posted)** - `delta_breach`,
`gamma_breach`, `vega_breach`, `theta_breach` (high; `data` carries the signed
value and the limit).

**directional** - `delta_target_deviation` (warn; only when a mandate is
posted). The mandate is a BOOK-level contract: every position's dollar delta is
netted and compared to the target ONCE, so at most one deviation flag is raised
for the whole request, carrying `position_id: "portfolio"` when the book spans
more than one symbol. Band = max(10% of |target|, 2% of the largest
spot x multiplier unit).

Reserved, not yet emitted: `iv_input_missing_or_invalid`.

## Hedges

A delta-1 underlying hedge is proposed ONLY for delta problems: a posted
mandate, or a `delta_breach` (hedged toward flat).

**Mandate hedges are book-level.** When the whole book is one symbol the gap is
closable with that underlying, so you get one concrete trade sized to move the
BOOK to the target. When the book spans several symbols there is no single
delta-1 instrument that closes it, so the engine returns an explicit
`feasible: false` entry naming the gap rather than a set of per-position trades
that would each overshoot the target. Hedge per symbol, or post one position per
request, if you want concrete trades for a mixed book.

`delta_breach` hedges remain per position - a threshold breach is a property of
the position that breached it.

Gamma/vega/theta-only breaches return an explicit `feasible: false` entry
instead of a wrong trade. 0DTE (expiry-day) legs stay live in the greeks via a
half-trading-day floor.

## Metrics (opt-in via `include: ["metrics"]`)

Model `bs_gbm_supplied_iv` - Black-Scholes revaluation on your supplied IVs;
GBM paths for Monte Carlo. Per position: `max_loss`/`max_profit` (null =
unbounded on that side), `breakevens` (vs today's model mark), `assignment`
(risk-neutral P(ITM at expiry) per net-short line), `var` (worst P&L across
the +/- z sigma sqrt(h) spot band - full 21-point scan, so non-monotone
payoffs like long straddles are measured correctly), `mc_var`/`mc_cvar`
(seeded GBM, deterministic per seed), `stress` (7x3 spot x IV grid). NOTE:
`var` (band worst-case) and `mc_var` (terminal-distribution quantile) answer
slightly different questions and can legitimately differ for non-monotone
payoffs.

## Portfolio (default on)

Per-symbol netted dollar greeks, whole-book totals, gross dollar delta and
beta-weighted dollar delta (each position's delta x its posted beta).

---

## Beta usage capture (disclosure)

During the open beta each analyzed request is recorded for product analytics:
a salted hash of your API key (never the key itself), your plan tier, the
posted request body, and the outcome (emitted flag codes, hedge count, or the
validation error code). This is used to decide which risk-flag families to
build next. Capture is fail-open - a telemetry fault can never fail your
request - and will be replaced by standard account-level usage metering when
the beta ends.

## Operational notes

- Account verification is cached for 60 seconds (both accepts and explicit
  rejections) - a key rotation can take up to a minute to be visible here.
- A 503 `auth_unavailable` means OUR upstream is degraded; your key is fine.
- Rate limiting is per key with fixed one-minute windows.
- All valuation dates use the US-Eastern trading calendar date.
