Why this exists
Spout lends against stock collateral. Instead of charging the borrower interest, we sell covered calls on their collateral. The premium pays the lender's yield. If a call expires in the money, that loss is the cost of borrowing. That's the design, not a failure.
More background: tranches, T-bills, what this engine does not do
Lender money splits into two tranches. Senior earns less but can exit earlier. Junior earns most of the premium. When there aren't enough borrowers, idle funds go to T-bills. All of that is a separate workstream. This engine does one thing: pick strikes, sell calls, and roll them. It runs in the Alpaca sandbox first.
How a strike gets picked
Nine rules run in order every time we write a contract. Short version: check the market is calm enough to trade, adjust the delta band for current conditions, pick the best strike in the band, then let the safety floor override it if the floor is more conservative.
The 9 rules in detail (with all known numbers)
| # | Rule | What it does | Enabled on | Status |
|---|---|---|---|---|
| 1 | VIX regime gate | First gate. Input is the VIX level itself, as a 5-day moving average checked at every cycle entry. Under 22: normal. 22–35: switch to a defensive config. Above 35: don't trade this cycle at all. This is why names like MSTR and SMCI survive panics. | All assets | L1/L2 split TBD |
| 2 | Asset config | Per-asset delta band from backtests (AAPL 0.08–0.12, GS 0.20–0.24 after re-optimization), floor and breach params, VRP thresholds, enable flags. | All assets | File is source of truth |
| 3 | Earnings gate | An earnings date inside the window splits the cycle into pre-earnings and post-earnings sub-cycles. It is not a plain skip. | All assets | Sub-cycle mechanics TBD |
| 4 | VRP shift | VRP is the IV/RV ratio. Past the asset's thresholds it shifts the band before selection. AAPL: 0.10 up / 0.04 down. Those numbers are AAPL-specific. | AAPL + IBIT only | Defined |
| 5 | RV-percentile shift | Compute 30-trading-day rolling RV for every day in the last 252 trading days. Rank today's value as a percentile. Above 0.67: both band edges slide down 0.02. Below 0.33: both slide up 0.02. The band moves as a unit, it never widens. 30 and 252 are both trading-day counts. | AAPL only | Defined |
| 6 | Band selection | Score every contract in the shifted band: 0.6 × delta proximity to the top of the band + 0.4 × IV/RV richness. Take the highest. This favors the top of the band, which pays the most premium. | All assets | Defined |
| 7 | Floor + breach rate | A minimum OTM percentage from recent realized vol, plus a breach-rate strike (count of closes above candidate strikes). The floor path picks the nearest liquid strike to the floor target. No scoring on this path, and adaptive re-selection is off when the floor is active. | 8 of 11 assets (not AAPL, IBIT, PFE) | Params in config |
| 8 | Further-OTM wins | Final strike = whichever of the band pick and the floor target is further OTM. So scoring only matters when the band pick already clears the floor. On NVDA, GOOG, SMCI, MSTR, XOM, GLD the floor usually wins. | Floor assets | Defined |
| 9 | Profit-take roll | Mid-cycle: if premium decayed 50%+ and delta is under 0.20, buy back and re-sell at a fresh strike. Section III. | 6 of 11 assets | Defined |
The L1/L2 split inside 22–35 and what each defensive config changes. How earnings sub-cycles work exactly. The liquidity filter thresholds. A data source for VIX. Everything else is pinned in verify_launch_list_v2.py, which we still need to fetch from Drive.
The profit-take roll
Three things got mixed up under the word "roll". They are separate, and the engine treats them separately:
- Profit-take roll: the option decayed a lot mid-cycle, so we close it early and sell a fresh one into the same expiry. Protocol-level, always on, invisible to the user. The only roll that fires in production.
- Post-expiry re-entry: after a cycle ends, does a new one start on the same collateral? That's the user's choice. The app's "Auto-Roll" toggle becomes "Auto Re-entry", and after an ITM loss the default is off.
- Buy-back: closing because of exercise or ITM expiry. A different scenario. Not a roll.
Why each gate exists, and the roll edge cases
- Trigger: cost to close (bid/ask midpoint) is at or below half the premium we collected, and delta is under 0.20, so the option is well OTM.
- DTE window: never roll on expiry day (too volatile). And never before the per-asset window opens (typically 3–5 days to expiry). Without the max gate we'd roll any time premium decayed, which is not the strategy.
- Re-pricing: the whole rule stack re-runs at roll time with today's data: current VRP, current VIX regime, earnings in the remaining window. Only the expiry stays the same. If nothing in the band passes the liquidity filters, fall back to the highest delta still below the band. Never go above it: writing closer to ATM raises the chance of ending ITM, and one ITM event can wipe out many good rolls.
- Economics: the new premium must beat the buyback cost plus $0.05 per share ($5.00 per 100-share contract). This gate is not redundant. It really fails when little time is left (the new option has little theta to sell), after a vol crush, on the below-band fallback, and on floor-heavy assets. If it fails, no roll. The position rides to expiry.
- Expiry: OTM means we keep the full premium; re-entry is the user's setting. ITM means assignment, handled as a buy-back, and re-entry defaults to off. American calls can also be assigned early, so we watch for assignment all the time, not just at expiry.
A roll is two orders, not one action
"Buy to close, sell again" is a state machine: submit the close, wait for the fill, check the shares are free, submit the new sell, wait again. Alpaca may reject the new call while the old one is still open. Partial fills, rejections, and timeouts all need states. The ledger models it as eligible → roll_claimed → close_submitted → close_filled → open_submitted → open_filled, with failure branches at each step.
Deferred or disabled
The defensive roll (re-sell further OTM when delta climbs) exists in the model but is disabled in production. We build the flag but ship it off. Also for later: a faster tick (websocket quotes), and earnings-cycle participation. No stop-loss in v1; we only sell calls against shares we hold.
What Alpaca gives us
Good news: one option-chain call returns every strike with its delta, IV, and bid/ask. We never compute Greeks ourselves. Selling a covered call is a normal order with the option's symbol; Alpaca checks we hold 100 shares per contract (options level 1). Verified against Alpaca's API reference.
Endpoint map: which API call serves which rule
| Need | Endpoint | Notes |
|---|---|---|
| Strikes with delta, IV, quotes (rules 6–8) | GET data /v1beta1/options/snapshots/{underlying} | The chain. Filters: type=call, expiry, strike range, feed. Limit 1000 per page, paginated. |
| Roll tick: midpoints of held contracts | GET data /v1beta1/options/quotes/latest?symbols= | Cheapest call: bid/ask only. Midpoint = (bp + ap) / 2. Max 100 symbols per request, so chunk. |
| Delta of held contracts (roll trigger) | GET data /v1beta1/options/snapshots?symbols= | Greeks for our open positions. Same 100-symbol cap. |
| Contract discovery, open interest | GET broker /v1/options/contracts (BETA) | Careful: defaults to expiry ≤ next weekend and limit 100. |
| RV, percentile, breach closes | GET data /v2/stocks/bars | Stock daily bars, deep history. Option history only goes back to Feb 2024, so vol math uses stock bars. |
| Quote sanity | GET data /v1beta1/options/meta/conditions/{ticktype} | Condition codes. Skip stale or non-firm quotes instead of rolling on a bad midpoint. |
| VIX 5-day MA (rule 1) | Not served | Alpaca has no index data at all. We need a VIX source: Cboe, a vendor, or a proxy. Open question. |
| Sell / buy back | POST broker /v1/trading/accounts/{id}/orders | Same Orders API the backend already uses. Whole-number qty, no notional, TIF day or gtc. |
| Assignment / expiry events | GET broker account activities | Non-trade activities. Poll only, no websocket. On paper accounts they show up the next day. |
| Options enablement | POST broker options approval (BETA) | Account needs options_approved_level ≥ 1. Verify in sandbox, one call. |
A covered call order, using the symbol exactly as Alpaca returns it (we never build option symbols by hand):
POST /v1/trading/accounts/{account_id}/orders
{
"symbol": "AAPL231201C00195000",
"qty": "2",
"side": "sell",
"type": "limit",
"limit_price": "1.05",
"time_in_force": "day"
}
Three gotchas that shape the design
Feed quality. Quotes come in two feeds: opra (real prices, paid) and indicative (free, modified quotes, delayed trades). Our roll math runs on midpoints, so we must confirm which feed our keys get.
Assignment is detected, not controlled. A long holder exercises; the OCC may assign us, even before expiry. Anything ITM by $0.01+ gets exercised at expiry. We detect it from activities plus position reconciliation. On paper accounts those events lag one day.
Faster later. When 3 minutes is too slow, a websocket quote stream replaces polling. The check logic stays the same.
Where this lives in the backend
spout-backend-mono already has an empty apps/options-service scaffold (port 4004) wired into CI and staging deploy. That's the home. There is zero options logic anywhere today, so this is greenfield, but the house patterns (Alpaca client, append-only ledger with claim writes, cron polling) all exist to copy.
1. The ECS service options-svc is dormant at desired count 0 and was descoped from private beta. Building it now is a scope change the team should confirm. 2. The infra docs assign vault lock/unlock signaling to outbox tables, but outboxes were descoped on June 30 in favor of the ledger pattern. So the collateral mechanism needs a fresh decision with Paul and Lovish.
Repo map and the deployment gotchas
| App | Port | Role | Useful for us |
|---|---|---|---|
apps/api-server | 4000 | Public REST API | Later: read-only positions and premium stats |
apps/kyc-service | 4001 | On-chain identity | Nothing |
apps/order-service | 4002 | Webhook to Alpaca equity orders | Copy: Alpaca client shape, claim-based ledger writes |
apps/token-service | 4003 | Alpaca events, mint/burn on Solana | Copy: cron polling, SSE reconnect pattern |
apps/options-service | 4004 | Empty scaffold | The engine goes here |
apps/execution-worker | - | Headless worker skeleton | Optional later home for the roll tick |
Two things break on day one if we forget them: the options-service Dockerfile starts dist/apps/options-service/main.js, but importing any @libs/* changes the build output to a double-nested path, so the CMD must be updated like the other services. And ScheduleModule.forRoot() isn't imported yet, which the interval jobs need.
How we build it
Six modules inside options-service. All state goes to Postgres, per house convention: services never call each other. Every outside action (an order, a lock) is claim-guarded so two replicas can never double-submit.
Modules, tables, and env vars in detail
- market-data: one Alpaca client (chain, batch quotes and snapshots with 100-symbol chunking, stock bars, contracts) plus the VIX source. Handles pagination and rate limits.
- volatility: pure functions. VIX 5-day MA and regime level, rolling RV30, the 252-day percentile, IV/RV. Every run is saved to
volatility_snapshotso decisions can be audited. - strike-selection: the 9 rules as a pure pipeline with no I/O inside. Unit-testable against fixture chains. All numbers come from versioned config.
- execution: sell and close orders. Symbols come from Alpaca, never built by hand. Each submission claims a ledger row first and reuses a saved
client_order_idon retry, so replicas and crashes can't double-order.DRY_RUNmode runs everything except the order. - roll: an
@Intervaltick (NestJS@Crondoesn't take milliseconds). Applies Figure 2, walks the close-then-reopen state machine, and owns post-expiry re-entry flags. - reconciliation + collateral: keeps the ledgers matching Alpaca (fills, positions, assignment activities, with the paper-account one-day lag) and proves shares are free before any sell: not already covering another call, locked on fill, released on close/assign/expiry. The lock signaling mechanism is an open decision since outboxes were descoped.
| Table | Purpose |
|---|---|
option_order_status | Append-only ledger of order state changes. The unique constraint is the claim lock. Holds qty, fills, prices, client_order_id, quote timestamp and feed. |
option_position_status | Append-only position lifecycle, built from fills and reconciliation. Holds strike, expiry, premium (per-share and total), config version, collateral ref, assignment activity id. |
asset_option_config | Per-asset numbers, seeded from verify_launch_list_v2.py. Bands, thresholds, enable flags, regime overrides. Versioned. |
volatility_snapshot | Audit trail of every computed input to every decision. |
| Env var | Default | Meaning |
|---|---|---|
ALPACA_DATA_BASE_URL | https://data.sandbox.alpaca.markets | Market data host |
ROLL_CHECK_INTERVAL_MS | 180000 | Roll tick, 3 min to start |
VIX_MA_WINDOW_DAYS | 5 | VIX moving-average window |
OPTIONS_DRY_RUN | true | Full pipeline, no real orders |
ROLL_MIN_EDGE_PER_SHARE | 0.05 | Economics gate buffer |
Admin dashboard
The roll is invisible to users by design. So the team needs a window into what the engine is doing, and just as important, why it is not doing something. No new instrumentation is needed: every decision already lands in the ledgers and volatility_snapshot with its config version. The dashboard is read-only queries on top.
The panels and where their data comes from
- Status bar: dry-run flag, current VIX 5d MA and regime level, last and next tick. From
volatility_snapshotand the scheduler. - Open positions: strike, expiry, entry delta vs current delta, premium, DTE, state. From
option_position_statusplus the latest snapshots. - Decision log: one line per decision, including skips: which gate blocked (regime halt, DTE window, economics, no liquid strike) with the numbers that decided it, and the config version. This is the "why is it not trading" answer. From the decision records the engine already writes.
- Roll and expiry history: fired vs skipped rolls, OTM/ITM outcomes, assignments, buyback costs. From both ledgers.
- Premium totals: per cycle and per asset, per-share and total dollars, feeding lender accounting later.
- Health: reconciliation mismatches, stale-quote skips, API errors. Anything here means look closer.
Where it runs: read-only endpoints on api-server (it already fronts Postgres) plus one simple internal page. For testnet, even a Grafana or Retool board on the same queries works on day one. It does not belong in the user app.
Build order
Ordered so real market data flows early and nothing trades until it has proven itself in dry-run.
- 0 · Prove access started: one script that hits the chain, checks the feed type and
options_approved_level, and does one paper sell + buy-back. Half a day. - 1 · Pin the unknowns: confirm the scope change with the team, get
verify_launch_list_v2.py, pick a VIX source, and close the open questions with Paul and Marc. No code. - 2 · Scaffold + tables: Dockerfile fix, ScheduleModule, the four tables with migrations, config seed. ~1 day.
- 3 · Market data client: chain, batch quotes/snapshots, stock bars, contracts. Typed, retried, chunked. ~1–2 days.
- 4 · Volatility + strike selection: the 9-rule stack as pure functions with fixture tests, decisions saved with config versions. ~4 days.
- 5 · Orders + reconciliation: claim-guarded execution, fill/position/activity reconciliation, DRY_RUN. ~3 days.
- 6 · Collateral locking: prove shares are free before selling, lock on fill, release on close. Gates live trading. ~2 days once the mechanism is decided.
- 7 · Roll engine: the tick, the gates, the close-then-reopen state machine. ~2 days.
- 8 · Dashboard + soak, then go live gradually: the admin dashboard (Section VII) comes up first so the team can watch the dry-run. Dry-run for several sessions and compare picks with Paul's expectations. Then one manually approved paper trade. Then automated opens. Then rolls. ~1 week elapsed.
Phases 0, 2, and 3 need no answers from anyone. Phase 4 needs the strategy answers. Phase 6 needs the collateral decision. Questions get batched into 15 minutes after stand-up.
Questions
Marc's review answered most of the strategy math. What's left is regime detail, data sourcing, execution policy, and the collateral mechanism.
Already answered (from Marc and Paul's thread)
| Question | Answer |
|---|---|
| What is the volatility index? | VIX itself, as a 5-day moving average checked at every cycle entry. Under 22 normal, 22–35 defensive, above 35 skip the cycle. |
| What is VRP? | The IV/RV ratio. AAPL thresholds: 0.10 up / 0.04 down. Only AAPL and IBIT have it on. |
| How does the RV percentile work? | Rolling 30-trading-day RV, ranked over the last 252 trading days. Above 0.67 the band slides down 0.02; below 0.33 it slides up 0.02. Both edges move together. AAPL only. |
| What is the scoring function? | 0.6 × delta proximity to band top + 0.4 × IV/RV richness. The floor path skips scoring. |
| Where are the breach/floor numbers? | In verify_launch_list_v2.py (the post-June-30 file; older Drive copies are stale). Floor on 8 of 11 assets. |
| Does a roll change expiry? | No. Same expiry, fresh strike, full rule stack re-run at roll time. |
| When does the roll fire? | Decay ≥ 50%, delta < 0.20, DTE between 1 and the per-asset max (3–5 days). On 6 of 11 assets. Fallback goes below the band, never above. |
| Roll vs buy-back vs re-entry? | Profit-take roll: protocol-level, always on, invisible. "Auto-Roll" becomes user-facing "Auto Re-entry", post-expiry only, default off after ITM. Buy-back is its own scenario. |
Still open
| # | Question | Who | Blocks |
|---|---|---|---|
| 1 | Where does L1 end and L2 begin inside 22–35, and what changes in each defensive config? Only L0 is specified. | Paul + Marc | Phase 4 |
| 2 | Where do we read VIX from? Alpaca has no index data. | Marc | Phases 3–4 |
| 3 | How do earnings sub-cycles pick expiries, and do bands recompute for each? | Marc | Phase 4 |
| 4 | Exact liquidity filters (spread, size, open interest) for band selection and the roll fallback. | Marc | Phases 4, 7 |
| 5 | Share verify_launch_list_v2.py. And long-term: is the file or the DB the source of truth? | Marc | Phase 2 |
| 6 | Are per-asset max roll DTE values in the config file? Calendar or trading days? | Marc | Phase 7 |
| 7 | Order pricing when we sell (midpoint limit? marketable limit?), spread/slippage caps, daily loss limits. | Paul + Marc | Phases 5–7 |
| 8 | Position sizing: contracts per asset, from which collateral balance, in which Alpaca account(s). | Paul | Phase 5 |
| 9 | Which quote feed do our keys get: opra (real) or indicative (modified, delayed)? | Paul | Phases 7–8 |
| 10 | Confirm sandbox options approval (options_approved_level ≥ 1). One API call. | Ishan | Phase 0 |
| 11 | What must be recorded on an ITM event for the borrowing-cost accounting? And who prompts the borrower, now that post-ITM re-entry defaults to off? | Paul | Phase 5 |
| 12 | Confirm the scope change (options-svc was descoped from beta) and decide the collateral lock mechanism, since outboxes were descoped. | Paul + Lovish | Phases 1, 6 |
| 13 | App change: remove the "Auto-Roll" toggle, add "Auto Re-entry" (default off after ITM). Who owns it, when? | Product | UX only |
| 14 | Admin dashboard: is a simple internal page (or Grafana/Retool on the same queries) fine for testnet? | Paul | Phase 8 |