# 10x Vertical Spread Screener — Production Spec

**Status:** Draft v1.0
**Owner:** OptionIncomeTools
**Last reviewed:** 2026-06-29
**Scope:** Long debit vertical spreads (bull call, bear put) with rare 10x payoff capacity.

---

## 1. What we are building

A rules-based screener + ranker + alert utility that identifies long debit verticals where the **structural ceiling** (width / debit) is high enough that a 10x outcome is mathematically possible, AND multiple confirming signals (skew, directional alignment, event context, liquidity) support the trade. The screener runs in two modes and produces ranked candidates with full transparency on every input.

This is NOT a "probability of profit" screen. PoP-based screens systematically miss the kind of low-cost, high-convexity setups that occasionally 10x. The screen explicitly biases toward structural convexity + relative-value skew + directional alignment + executable liquidity.

### Core constraint

A long vertical spread **cannot** 10x unless the ratio `width / entry_debit_est >= 10`. We harden this with a 5% safety margin (10.5 minimum, 12.0 preferred) to account for fees, slippage, and exit timing.

### Two modes

| Mode | When to use | Weighting bias |
|---|---|---|
| `event_mode` | Pre-event (earnings, FDA, FOMC, M&A rumor) | Structural + skew + event_gap + directional |
| `momentum_mode` | Trend continuation, no near-term event | Structural + skew + directional + theta-aware |

---

## 2. Architecture

```
+---------------------+      +---------------------+      +---------------------+
| Universe + filters  | ---> | Per-symbol pipeline | ---> | Ranking + alerts    |
| (sectors, OI, ADV)  |      | (per-expiration)    |      | (top N per mode)    |
+---------------------+      +---------------------+      +---------------------+
         |                            |                            |
         v                            v                            v
+---------------------+      +---------------------+      +---------------------+
| Provider adapters   |      | Feature extractors  |      | Output: JSON +      |
| (Tradier, ORATS,    |      | (structural, skew,  |      | alerts (webhook +   |
| SEC EDGAR, Polygon) |      | directional, event, |      | email + KV stash)   |
|                     |      | greeks, liquidity)  |      |                     |
+---------------------+      +---------------------+      +---------------------+
```

The pipeline is intentionally pure and stateless per spread candidate. Adapters are dependency-injected so the same code runs against Tradier live, ORATS historical, or fixture data in tests.

### Module layout (code in `./code/`)

```
code/
  types.ts          // TypeScript interfaces, JSON schemas (single source of truth)
  features.ts       // Feature extractors (pure functions)
  filters.ts        // Hard rejection filters with configurable thresholds
  scoring.ts        // Scoring functions for both modes
  screener.ts       // Orchestration pipeline (universe -> candidates -> ranked output)
  adapters/
    tradier.ts      // Tradier API adapter (live chains, expirations, quotes)
    orats.ts        // ORATS adapter (historical skew, earnings, historical event moves)
    sec.ts          // SEC EDGAR adapter (8-K filings, earnings dates)
  tests/
    screener.test.ts  // Vitest unit + integration scaffolds
```

---

## 3. Feature definitions

All features are pure functions of `(spread_candidate, market_data, historical_data)`. Every threshold is configurable via the `ScreenerConfig` object — defaults documented per feature.

### A. Structural features

```typescript
width = abs(short_strike - long_strike)
entry_mid = long_mid - short_mid
entry_debit_est = entry_mid
                + 0.25 * ((long_ask - long_bid) + (short_ask - short_bid))
                + fees_per_contract
ten_x_room = width / entry_debit_est
```

**Rejection:** `ten_x_room < 10.5` → hard reject (cannot 10x by construction).
**Preferred:** `ten_x_room >= 12.0` (configurable, default 12.0).

Rationale: real fills don't hit mid. We charge a quarter of each leg's spread plus per-contract fees so the debit is what you'll actually pay. Without this haircut the screener returns ghost setups that cannot fill at the modeled price.

### B. Skew features

```typescript
directional_skew_edge = IV(short_leg) - IV(long_leg)
skew_percentile = percentile(directional_skew_edge,
                             same_underlying_history,
                             matched_dte_bucket,
                             matched_delta_bucket)
```

**DTE bucket:** group by ranges `[7-14, 15-30, 31-60, 61-120, 121-180]` (configurable).
**Delta bucket:** group by absolute delta of the LONG leg `[0.10-0.20, 0.20-0.30, 0.30-0.40, 0.40-0.50]` (configurable).

**Why same underlying's own history:** cross-sectional skew comparisons are noisy. NVDA's 90-day put skew at the 25-delta has a stable own-history percentile band that's far more predictive than comparing NVDA to KO. Skew percentile of 0.85+ indicates the relationship is rich relative to its own past.

**Lookback:** 180 trading days (configurable). Below 60 days of history → flag warning, do not reject.

### C. Directional features

```typescript
// Bullish spread (bull call debit)
momentum_alignment.bull = (
  close > sma20 &&
  sma20 > sma50 &&
  (return_20d / realized_vol_20d) > 0
)

// Bearish spread (bear put debit) — mirror
momentum_alignment.bear = (
  close < sma20 &&
  sma20 < sma50 &&
  (return_20d / realized_vol_20d) < 0
)

directional_score = sum of conditions met / count of conditions
```

Three conditions all configurable. A "weak" direction (1 of 3) doesn't reject — it scores lower. Hard rejection only for momentum_mode if direction score = 0.

### D. Event features

```typescript
implied_event_move = atm_straddle_mid / spot
historical_event_move = median(|post_event_1d_move|, last 8 comparable events)
event_gap = historical_event_move / implied_event_move
```

**Event boost (additive to event_score):**
- `event_gap > 1.10` → +1 (market is under-pricing the typical move)
- `event_gap > 1.25` → +2 (strong under-pricing)
- `event_gap > 1.50` → +3 (rare; investigate before trading)

Comparable events: same event type (earnings vs FDA vs FOMC), same direction-of-surprise if known. Default lookback: 8 comparable events.

**event_mode requires `event_gap >= 1.00`** unless explicitly overridden (`config.event.allow_no_underpricing`).

### E. Greeks

```typescript
net_delta = long_delta - short_delta       // for bull call
net_gamma = long_gamma - short_gamma
net_theta = long_theta - short_theta
net_vega  = long_vega - short_vega

gamma_efficiency = abs(net_gamma) * spot / entry_debit_est
theta_drag = abs(net_theta) / entry_debit_est
```

**Net delta preferred band:** `0.18 <= abs(net_delta) <= 0.45` (configurable).
- Below 0.18: too OTM, needs a moonshot to pay.
- Above 0.45: too ITM, debit is already most of the width, ten_x_room insufficient.

`gamma_efficiency` rewards spreads where small spot moves produce big P&L (1.0+ is great).
`theta_drag` penalizes long-dated spreads where time decay erodes the position before payoff.

### F. Liquidity

```typescript
// Hard requirements
both_legs_bid > 0
both_legs_midpoint >= 0.05         // configurable

// Preferred (used in scoring, not rejection)
both_legs_open_interest >= 300     // configurable, treat as lagged 1 day
both_legs_day_volume >= 25         // configurable
quoted_size >= 10 contracts        // if size data available

// Execution cost
spread_execution_cost_ratio = (sum of half-spreads + fees) / entry_debit_est
preferred: <= 0.20
```

**Open interest is lagged by one trading day** in our data feed. Do not use today's OI to validate same-day liquidity — use yesterday's OI as a leading indicator and require today's quoted bid-ask + size for actual executability.

If quoted size data is unavailable (provider-specific), score the spread on a `liquidity_score = liquidity_score - 0.10` penalty.

---

## 4. Scoring

Each sub-score is normalized to `[0, 1]`. The mode-specific score is `100 * weighted_sum`.

### Sub-score normalizations

```typescript
structural_score = clip((ten_x_room - 10) / 10, 0, 1)
                  // 10 -> 0, 20 -> 1, capped

skew_score = clip(skew_percentile, 0, 1)
            // 0.50 -> 0.50, 0.85 -> 0.85, 1.0 -> 1.0

directional_score = (conditions_met / total_conditions)
                   // 0, 0.33, 0.66, or 1.0

event_score = clip((event_gap - 1.0) / 0.5, 0, 1)
             // 1.0 -> 0, 1.25 -> 0.5, 1.50 -> 1.0

gamma_score = clip(gamma_efficiency / 2.0, 0, 1)
             // 0 -> 0, 2.0 -> 1.0

theta_score = clip(1.0 - theta_drag * 30, 0, 1)
             // theta_drag 0 -> 1, 0.033/day -> 0
             // (i.e., losing 1% of debit per day -> 0)

liquidity_score: see liquidity.ts (4-factor composite, range [0, 1])
```

### Mode weights

```typescript
score_event = 100 * (
    0.25 * structural_score +
    0.20 * skew_score +
    0.20 * event_score +
    0.15 * directional_score +
    0.10 * gamma_score +
    0.10 * liquidity_score
)

score_momentum = 100 * (
    0.30 * structural_score +
    0.25 * skew_score +
    0.20 * directional_score +
    0.10 * gamma_score +
    0.10 * theta_score +
    0.05 * liquidity_score
)
```

Weights are config-overridable.

---

## 5. Output schema

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://optionincometools.com/schemas/spread-candidate.json",
  "type": "object",
  "required": [
    "symbol", "mode", "expiration", "long_leg", "short_leg",
    "width", "entry_debit_est", "max_value_at_expiry", "ten_x_room",
    "total_score", "rationale_tags", "warnings"
  ],
  "properties": {
    "symbol":             { "type": "string", "pattern": "^[A-Z][A-Z.]{0,5}$" },
    "mode":               { "enum": ["event_mode", "momentum_mode"] },
    "expiration":         { "type": "string", "format": "date" },
    "long_leg":  { "$ref": "#/$defs/leg" },
    "short_leg": { "$ref": "#/$defs/leg" },
    "width":                  { "type": "number", "minimum": 0 },
    "entry_debit_est":        { "type": "number", "minimum": 0 },
    "max_value_at_expiry":    { "type": "number", "minimum": 0 },
    "ten_x_room":             { "type": "number", "minimum": 0 },
    "skew_percentile":        { "type": ["number", "null"], "minimum": 0, "maximum": 1 },
    "directional_skew_edge":  { "type": "number" },
    "momentum_alignment":     { "type": "object",
      "properties": {
        "close_gt_sma20": { "type": "boolean" },
        "sma20_gt_sma50": { "type": "boolean" },
        "trend_ratio_positive": { "type": "boolean" },
        "score": { "type": "number", "minimum": 0, "maximum": 1 }
      }
    },
    "event_gap":         { "type": ["number", "null"] },
    "net_delta":         { "type": "number" },
    "net_gamma":         { "type": "number" },
    "net_theta":         { "type": "number" },
    "net_vega":          { "type": "number" },
    "liquidity_score":   { "type": "number", "minimum": 0, "maximum": 1 },
    "total_score":       { "type": "number", "minimum": 0, "maximum": 100 },
    "rationale_tags":    { "type": "array", "items": { "type": "string" } },
    "warnings":          { "type": "array", "items": { "type": "string" } }
  },
  "$defs": {
    "leg": {
      "type": "object",
      "required": ["strike", "expiration", "right", "bid", "ask", "iv", "delta"],
      "properties": {
        "strike":     { "type": "number" },
        "expiration": { "type": "string", "format": "date" },
        "right":      { "enum": ["call", "put"] },
        "bid":        { "type": "number", "minimum": 0 },
        "ask":        { "type": "number", "minimum": 0 },
        "mid":        { "type": "number", "minimum": 0 },
        "iv":         { "type": "number", "minimum": 0 },
        "delta":      { "type": "number", "minimum": -1, "maximum": 1 },
        "gamma":      { "type": "number" },
        "theta":      { "type": "number" },
        "vega":       { "type": "number" },
        "open_interest_lagged": { "type": "integer", "minimum": 0 },
        "day_volume": { "type": "integer", "minimum": 0 },
        "quoted_size_bid": { "type": ["integer", "null"], "minimum": 0 },
        "quoted_size_ask": { "type": ["integer", "null"], "minimum": 0 }
      }
    }
  }
}
```

Example rationale tag strings (configurable, output is human-readable):

```
"ten_x_room=14.2 (well above 12.0 preferred)"
"skew_percentile=0.91 (very rich vs own 180d history)"
"event_gap=1.34 (under-priced earnings move)"
"momentum_alignment=3/3 bull"
"net_delta=0.27 (mid-band, good gamma exposure)"
"liquidity ok (OI 1200/850 lagged, vol 180/120, mid spread 8%)"
```

Warning tags:

```
"WARN_skew_history_short: 42 trading days of history (preferred 180)"
"WARN_quoted_size_unavailable: penalized liquidity_score by 0.10"
"WARN_event_gap=1.62: investigate; potentially rich for a reason"
"WARN_theta_drag_high: 4.1% of debit per day"
```

---

## 6. Configurable thresholds (single source of truth)

```typescript
// code/types.ts excerpt
export const DEFAULT_CONFIG: ScreenerConfig = {
  structural: {
    min_ten_x_room: 10.5,
    preferred_ten_x_room: 12.0,
    fees_per_contract: 0.65,         // typical retail per-leg commission
    spread_haircut_fraction: 0.25,   // 25% of each leg's bid-ask
  },
  skew: {
    lookback_trading_days: 180,
    min_lookback_for_percentile: 60,
    dte_buckets: [[7,14],[15,30],[31,60],[61,120],[121,180]],
    long_leg_delta_buckets: [[0.10,0.20],[0.20,0.30],[0.30,0.40],[0.40,0.50]],
  },
  directional: {
    sma_short: 20,
    sma_long: 50,
    trend_lookback: 20,
    require_full_alignment_momentum: false,
    require_min_score_momentum: 0.34,
  },
  event: {
    comparable_events_count: 8,
    boost_threshold_mild: 1.10,
    boost_threshold_strong: 1.25,
    boost_threshold_extreme: 1.50,
    allow_no_underpricing: false,
    require_min_event_gap_in_event_mode: 1.00,
  },
  greeks: {
    min_abs_net_delta: 0.18,
    max_abs_net_delta: 0.45,
    target_gamma_efficiency: 2.0,
  },
  liquidity: {
    require_both_legs_bid: true,
    min_midpoint: 0.05,
    preferred_oi: 300,
    preferred_day_volume: 25,
    preferred_quoted_size: 10,
    preferred_max_execution_cost_ratio: 0.20,
    penalty_no_quoted_size: 0.10,
  },
  scoring: {
    event_mode_weights: {
      structural: 0.25, skew: 0.20, event: 0.20,
      directional: 0.15, gamma: 0.10, liquidity: 0.10
    },
    momentum_mode_weights: {
      structural: 0.30, skew: 0.25, directional: 0.20,
      gamma: 0.10, theta: 0.10, liquidity: 0.05
    },
  },
  output: {
    top_n_per_mode: 25,
    min_total_score: 50,
  },
};
```

---

## 7. Live-scan vs backtest data requirements

### Live scan (real-time)
Required:
- Tradier chains with bid/ask/last/iv/delta/gamma/theta/vega/oi/volume per leg
- Tradier expirations endpoint
- Underlying spot price + 200 days of daily bars (for SMA20/50, realized vol, momentum)
- Same-underlying historical skew snapshots (180 days) — typically from ORATS or rolled-up Tradier history
- SEC EDGAR 8-K + earnings calendar for upcoming binary events
- Historical event move database (last 8 comparable events per ticker per event type)

Latency budget: under 30 seconds per ticker, end-to-end. Use parallel adapter calls.

### Backtest (point-in-time)
Required:
- Historical chains with full greeks at trade-decision timestamps (daily close minimum)
- Historical underlying bars + corporate actions
- Historical skew dataset (ORATS or equivalent) at same timestamps
- Confirmed event dates + actual realized post-event 1-day moves
- Realistic fill model (mid+0.5*half-spread or worse) and per-leg commissions

Critical: never let the backtest see future bars when computing skew percentile, momentum, or event gap. The `as_of_timestamp` parameter MUST flow through every adapter call.

See `backtest-notes.md` for the full backtest specification including walk-forward methodology and survivorship handling.

---

## 8. Test cases (representative)

```typescript
// code/tests/screener.test.ts
describe("Structural filter", () => {
  it("rejects spreads with ten_x_room < 10.5", () => {
    const c = makeCandidate({width: 5, entry_debit_est: 0.55});
    // ten_x_room = 9.09
    expect(applyStructuralFilter(c, DEFAULT_CONFIG)).toBe(null);
  });
  it("accepts spreads with ten_x_room >= 12", () => {
    const c = makeCandidate({width: 5, entry_debit_est: 0.40});
    // ten_x_room = 12.5
    expect(applyStructuralFilter(c, DEFAULT_CONFIG)?.ten_x_room).toBeGreaterThan(12);
  });
});

describe("Skew percentile", () => {
  it("returns null when history too short", () => {
    const result = computeSkewPercentile(0.04, [/* 30 samples */]);
    expect(result.percentile).toBeNull();
    expect(result.warning).toContain("history_short");
  });
  it("matches DTE + delta buckets", () => { /* ... */ });
});

describe("Event gap", () => {
  it("flags under-priced moves at threshold mild", () => {
    const eg = computeEventGap(0.045, [0.05, 0.052, 0.049, 0.051, 0.05, 0.048, 0.053, 0.05]);
    // historical median ~0.05, implied 0.045 -> event_gap = 1.111
    expect(eg.gap).toBeCloseTo(1.111, 2);
    expect(eg.boost).toBe(1);
  });
});

describe("Scoring", () => {
  it("event_mode and momentum_mode weights sum to 1.0", () => {
    const w = DEFAULT_CONFIG.scoring.event_mode_weights;
    expect(Object.values(w).reduce((s,v)=>s+v, 0)).toBeCloseTo(1.0);
  });
  it("identical candidate scores differently in two modes", () => {
    const c = makeFullCandidate();
    const sEvent = scoreCandidate(c, "event_mode", DEFAULT_CONFIG);
    const sMomentum = scoreCandidate(c, "momentum_mode", DEFAULT_CONFIG);
    expect(sEvent).not.toBe(sMomentum);
  });
});
```

---

## 9. Adapter contract (interfaces)

Every adapter implements:
```typescript
interface DataAdapter {
  readonly name: string;
  // Live-mode only
  getExpirations?(symbol: string): Promise<string[]>;
  getChain?(symbol: string, expiration: string): Promise<OptionChain>;
  getQuote?(symbol: string): Promise<Quote>;
  // Historical
  getDailyBars(symbol: string, fromDate: string, toDate: string): Promise<Bar[]>;
  getSkewSnapshot?(symbol: string, asOfDate: string,
                   dteBucket: [number,number],
                   deltaBucket: [number,number]): Promise<SkewSnapshot | null>;
  getHistoricalEventMoves?(symbol: string, eventType: EventType,
                           n: number): Promise<EventMove[]>;
  // Events
  getUpcomingEvents?(symbol: string, withinDays: number): Promise<EventCalendarEntry[]>;
}
```

Concrete adapters:
- `TradierAdapter` — live chains, expirations, quotes
- `OratsAdapter` — historical skew, historical event moves
- `SecEdgarAdapter` — 8-K, 10-Q timing, earnings filings
- `PolygonAdapter` *(optional)* — fallback chain + historical aggregates

API call examples for each in section 11.

---

## 10. Example pipeline run

```typescript
import { Screener } from "./code/screener";
import { TradierAdapter } from "./code/adapters/tradier";
import { OratsAdapter } from "./code/adapters/orats";
import { SecEdgarAdapter } from "./code/adapters/sec";
import { DEFAULT_CONFIG } from "./code/types";

const screener = new Screener({
  config: DEFAULT_CONFIG,
  adapters: {
    chains: new TradierAdapter({ token: process.env.TRADIER_TOKEN! }),
    skew: new OratsAdapter({ token: process.env.ORATS_TOKEN! }),
    events: new SecEdgarAdapter({ userAgent: "OptionIncomeTools/1.0" }),
  },
});

const result = await screener.run({
  universe: ["AAPL","MSFT","NVDA","TSLA","META","AMZN","GOOGL","AVGO"],
  modes: ["event_mode", "momentum_mode"],
  dteWindow: [21, 90],
  spreadTypes: ["bull_call", "bear_put"],
});

console.log(JSON.stringify(result.candidates.slice(0, 10), null, 2));
```

Output:
```json
[
  {
    "symbol": "NVDA",
    "mode": "event_mode",
    "expiration": "2026-08-15",
    "long_leg":  { "strike": 145, "right": "call", "bid": 0.42, "ask": 0.48, "iv": 0.41, "delta": 0.28, ... },
    "short_leg": { "strike": 160, "right": "call", "bid": 0.08, "ask": 0.11, "iv": 0.39, "delta": 0.10, ... },
    "width": 15,
    "entry_debit_est": 0.41,
    "max_value_at_expiry": 15.0,
    "ten_x_room": 36.6,
    "skew_percentile": 0.88,
    "directional_skew_edge": -0.02,
    "momentum_alignment": { "close_gt_sma20": true, "sma20_gt_sma50": true, "trend_ratio_positive": true, "score": 1.0 },
    "event_gap": 1.31,
    "net_delta": 0.18,
    "net_gamma": 0.014,
    "net_theta": -0.012,
    "liquidity_score": 0.78,
    "total_score": 84.2,
    "rationale_tags": [
      "ten_x_room=36.6 (well above 12.0 preferred)",
      "skew_percentile=0.88 (very rich vs own 180d history)",
      "event_gap=1.31 (earnings premium under-priced)",
      "momentum_alignment=3/3 bull"
    ],
    "warnings": []
  }
]
```

---

## 11. Adapter API calls (concrete examples)

### Tradier — expirations

```http
GET https://api.tradier.com/v1/markets/options/expirations
    ?symbol=NVDA&includeAllRoots=true&strikes=false
Authorization: Bearer YOUR_TRADIER_TOKEN
Accept: application/json
```

Response:
```json
{ "expirations": { "date": ["2026-07-18", "2026-08-15", "2026-09-19", "2026-12-19"] } }
```

### Tradier — chain with greeks

```http
GET https://api.tradier.com/v1/markets/options/chains
    ?symbol=NVDA&expiration=2026-08-15&greeks=true
Authorization: Bearer YOUR_TRADIER_TOKEN
Accept: application/json
```

Response (truncated):
```json
{
  "options": {
    "option": [
      {
        "symbol": "NVDA260815C00145000",
        "strike": 145.0,
        "option_type": "call",
        "bid": 0.42, "ask": 0.48, "last": 0.45,
        "volume": 312, "open_interest": 1840,
        "greeks": { "delta": 0.28, "gamma": 0.013, "theta": -0.011, "vega": 0.062, "mid_iv": 0.412 }
      }
    ]
  }
}
```

### SEC EDGAR — recent filings

```http
GET https://data.sec.gov/submissions/CIK0001045810.json
User-Agent: OptionIncomeTools/1.0 contact@optionincometools.com
```

Response (truncated):
```json
{
  "name": "NVIDIA CORP",
  "tickers": ["NVDA"],
  "filings": {
    "recent": {
      "form": ["8-K", "10-Q", "8-K"],
      "filingDate": ["2026-05-28", "2026-05-22", "2026-04-15"],
      "primaryDocument": ["nvda-8k-20260528.htm", "..."],
      "items": ["2.02,9.01", "", "5.02"]
    }
  }
}
```

Items 2.02 + 9.01 = earnings release. Use this to anchor `event_gap` lookups.

### ORATS — historical skew (illustrative)

```http
GET https://api.orats.io/datav2/hist/strikes
    ?token=YOUR_ORATS_TOKEN&ticker=NVDA&tradeDate=2026-06-28&dte=30&delta=0.25
```

Response:
```json
{
  "data": [
    {
      "ticker": "NVDA",
      "tradeDate": "2026-06-28",
      "dte": 30,
      "delta": 0.25,
      "smvVol": 0.412,
      "callMidIv": 0.408,
      "putMidIv": 0.434
    }
  ]
}
```

ORATS endpoints used:
- `/datav2/hist/strikes` for historical strike-level IV
- `/datav2/hist/dailies` for historical option daily aggregates
- `/datav2/cores/general` for current snapshot if Tradier unavailable

### Polygon (optional fallback)

```http
GET https://api.polygon.io/v3/snapshot/options/NVDA?strike_price.gte=140&strike_price.lte=165&expiration_date=2026-08-15
Authorization: Bearer YOUR_POLYGON_TOKEN
```

---

## 12. Operational concerns

### Rate limiting
- Tradier: 60 RPS sandbox, 120 RPS prod. Throttle to 50 concurrent.
- ORATS: 10 RPS typical. Throttle to 5 concurrent.
- SEC EDGAR: 10 RPS, hard. Always send User-Agent. Throttle to 5 concurrent.

### Caching
- Chains: 60s TTL during market hours, 5min after-hours
- Skew snapshots: 24h TTL (history doesn't change retroactively)
- Event calendars: 6h TTL
- Daily bars: 24h TTL

### Alerting
On any candidate with `total_score >= alert_threshold` (default 80) and not seen in last 60min:
- Webhook POST to configured URL with full candidate JSON
- Optional email to subscriber list via SES / Resend
- Stash in KV with 24h TTL keyed by `${symbol}:${expiration}:${long_strike}:${short_strike}:${mode}` to dedupe

### Failure modes
- One adapter down: continue with degraded scoring. Mark warning per affected feature.
- All chain adapters down: return empty result with error, do not poison cache.
- Stale skew history (<60 days): return candidate with `skew_percentile=null` + warning, do not use skew_score in total (re-normalize remaining weights).

---

## 13. What this screener does NOT do

- It does not place trades.
- It does not predict direction with any confidence. Directional signals are confirming inputs, not forecasts.
- It does not handle American-style early-assignment risk (out of scope for v1; flag in `warnings` instead).
- It does not handle multi-leg structures beyond two-leg verticals (no calendars, no condors, no flies).
- It does not adjust for borrow costs, dividends scheduled within trade window, or ex-div assignment risk. These are deferred to v2.

---

## 14. Roadmap

- v1.0 (this spec): bull call + bear put verticals, two modes, Tradier + ORATS + EDGAR adapters
- v1.1: dividend-adjusted greeks, ex-div assignment warnings
- v1.2: alert dedupe + webhook + email integration
- v2.0: ratio spreads, broken-wing butterflies, calendar+vertical combos
- v2.1: tax-lot / wash-sale aware backtest mode
- v3.0: ensemble with Forward-Factor strategy on same underlyings (cross-strategy portfolio view)
