An autonomous, auditable agent that trades defined-risk options structures around earnings announcements on Alpaca paper — built on the "LLM least-trusted" pattern: the model analyzes and picks from a deterministic shortlist, but every price, structure, size, and exit is computed and enforced by code it cannot override.
This is the build for the Alpaca AI Trading Agents Hackathon — track Options Alpha Agents. It implements the full Earnings Season Agent architecture sketched before build.
- Video (2:28, edge-tts + ffmpeg + PIL):
docs/video/PrintRunner.mp4— also hosted athttps://printrunner.vercel.app/video/PrintRunner.mp4 - Slides (10, reportlab, punchy):
docs/PrintRunner.pdf—https://printrunner.vercel.app/PrintRunner.pdf - Live dashboard:
https://printrunner.vercel.app/(fetches Supabase at runtime — no rebuild or commit per run; keys are injected from.envat build time and never committed)
| # | Law | Where it lives |
|---|---|---|
| P1 | LLM is the least-trusted component — it only selects from a validated shortlist | src/printrunner/llm/team.py hallucination guard |
| P2 | Defined-risk only, structurally — every position is one mleg order, max loss known |
src/printrunner/screener/desk.py |
| P3 | Fail closed — uncertainty means "don't trade" | every GateOutcome, evaluate_exit "cannot value → CLOSE" |
| P4 | Restart can't hurt — deterministic client_order_id + reconcile-before-write |
src/printrunner/execution/broker.py |
| P5 | Point-in-time honest — calendar provenance + confirm_cycles >= 1 before entry |
src/printrunner/calendar/service.py, state/state.py |
| P6 | Adapt only toward restriction — reviewer only adds bans | src/printrunner/llm/team.py:reviewer_bans |
| P7 | Paper or nothing — boot guard refuses live credentials | src/printrunner/config.py:Settings.load |
| P8 | The agent earns trust by saying NO — every rejection is journaled | data/journal.jsonl hash chain |
Two edges, both deterministic to detect:
| Family | Anomaly | Detection | Expression |
|---|---|---|---|
| RUNUP | Pre-earnings announcement drift | MoveRatio ≤ 0.95 (EM cheap vs history) |
Debit vertical, entry T−5..T−2, exits before the print |
| CRUSH | Vol risk premium into earnings | MoveRatio ≥ 1.20 and VRP > 0 |
Short iron condor, entry T−2..T−1, held through the print |
Core metrics (all from validated snapshot, never LLM output):
- EM — ATM straddle mid for the first expiry spanning the event, / spot
- EM vs history —
EM% / mean(|last 8 earnings moves|)→MoveRatio - Runup drift — 5-trading-day return into the print
- VRP — straddle IV (solved via Black-Scholes bisection) − 20-day annualized HV
- Stale quotes, wide spreads, low OI all fail gates before sizing.
GitHub Actions cron (*/20 min, 13-20 UTC, weekdays)
│
┌─────▼─────┐ events ┌──────────────┐ briefs ┌──────────────┐
│ CALENDAR ├──────────►│ ORCHESTRATOR ├─────────►│ LLM TEAM │
│ Finnhub/ │ │ 9-step cycle │◄─────────┤ Groq → AIML │
│ yfinance │ └─┬──┬──┬──┬───┘ Decision │ → Compat │
└───────────┘ │ │ │ │ JSON └──────────────┘
│ │ │ └──────────────┐
┌──────────────────┐ │ │ │ │
│ MARKET DATA │◄─────┘ │ │ ┌─────────────▼─────────┐
│ Alpaca (cached) │ │ │ │ RISK GATES G1-G10 │
└──────────────────┘ │ │ │ + conviction sizing │
│ │ └──────┬────────────────┘
┌──────────────────┐ │ │ │ pass
│ SCREENER │◄────────┘ │ ┌──────▼───────────────┐
│ metrics + desk │ candidates │ │ EXECUTION (mleg) │
└──────────────────┘ │ │ deterministic id, │
│ │ fail-closed submit │
┌──────────────────┐ │ └──────┬───────────────┘
│ POSITION MGR │◄───────────┘ │
│ exit engine │ ▼
└────────┬─────────┘ Alpaca Trading API (paper)
│ close orders │
┌────────▼────────┐ reconcile ┌────────▼─────────┐
│ JOURNAL (hash- │◄──────────────┤ RECONCILER │
│ chained JSONL) │ drift→HALT │ + kill switch │
└────────┬────────┘ └──────────────────┘
│ artifacts
┌────────▼────────┐ ┌──────────────────┐
│ DASHBOARD │ │ REVIEWER (bans) │
│ docs/index.html │ └──────────────────┘
└─────────────────┘
| Code | Gate | What it checks |
|---|---|---|
| G1 | missing data | spot/chain/hist sample present |
| G2 | stale quotes | leg quoted_at ≤ 20 min, stock quote ≤ 30 min |
| G3 | price sanity | spot in [0.80, 1.25]× ref, bid ≤ ask |
| G4 | structure price | debit/credit sign, debit < width, max_loss > 0 |
| G5 | expiry window | 4 ≤ (expiry − event) ≤ 10 days, DTE ≥ 4 |
| G6 | liquidity | OI ≥ 300, spread ≤ 15% of mid |
| G7 | risk budget | per-event ≤ $1k, aggregate ≤ $4k, concurrent < 5, ≤ 2/day, buying power, conviction 3-5 |
| G8 | market shock | SPY 1d > −1.5%, 5d > −3%, halt not latched, market open |
| G9 | runup favorable | vertical drift > −1%, condor drift > +1% |
| G10 | edge | vertical MoveRatio ≤ 0.95, condor MoveRatio ≥ 1.20 and VRP > 0 |
Gates are re-evaluated on fresh quotes immediately before submission (P4).
Exit engine: verticals target +40% / stop −35% / DTE<4 & pnl<20% → close; condors capture 55% of credit / MTM stop min(1.6×credit, 75% max loss) / DTE≤2 / wing breach → close. "Cannot value" → close.
uv sync
cp .env.example .env # fill ALPACA_API_KEY_ID, ALPACA_SECRET_KEY (paper), FINNHUB_API_KEY, GROQ_API_KEY, ...
uv run pr status # verify halt/journal/db
uv run pr cycle # one full cycle (reconcile → exits → calendar → screen → LLM → gates → execute)
uv run pr dashboard # rebuild docs/index.html (gitignored; needs SUPABASE_* in .env for live fetch)
uv run pr journal -n 30
uv run pr journal --verifypr status # halt, journal chain, open positions, aggregate risk
pr cycle # run one 9-step cycle
pr doctor # paper guard, journal chain, Alpaca CLI account/positions
pr journal [-n N] # tail journal
pr journal --verify # hash-chain verify
pr resume --confirm ACK# clear a latched HALT (manual only)
pr dashboard # rebuild static dashboard
pr override --symbol ADBE --event-date 2026-09-10 --structure call_debit_vertical --waive G9,G10 --conviction 5 --reason "..." --confirm OVERRIDE
# safety-floored operator waiver: only edge gates G9/G10
# waivable (G1-G8 failures refuse); journaled as OPERATOR_OVERRIDE
pr cycle is idempotent — rerunning the same cycle is a no-op due to deterministic client_order_ids.
See .env.example. Trading credentials must point at https://paper-api.alpaca.markets (Alpaca docs show https://paper-api.alpaca.markets/v2 as the REST prefix — Settings.load at src/printrunner/config.py:141 strips a trailing /v2 so both forms work; your paper key/secret are for paper, and double /v2/v2 is normalized). Any live host is refused at Settings.load (P7).
LLM providers are tried in order Groq → AIML → OpenAI-compatible custom endpoint. Missing keys are skipped; with no provider the agent is fail-closed for entries (exits still run). Set ALLOW_NO_LLM_ENTRIES=1 only for dry runs.
No commits per run. The loop is Cloudflare Worker → Actions → Supabase → Vercel:
- A Cloudflare Worker (
worker/, cron*/20 13-20 * * 1-5weekdays) dispatches.github/workflows/cycle.ymlvia the GitHub API (plus a backupscheduletrigger in the workflow itself; the concurrency group serializes overlaps and cycles are idempotent). State survives across fresh CI checkouts via a rollingactions/cacheondata/(unique key per run, prefix restore) — without this the P5 two-observation calendar gate could never pass in production. The workflow also installs the official Alpaca CLI (alpacahq/cli) for the reconcile second channel. EveryJournal.appendis mirrored to Supabase (src/printrunner/supabase/, fail-open), as is the daily equity snapshot. - The dashboard (
src/printrunner/dashboard/build.py) is a static shell that fetchesjournal/equityfrom Supabase in the browser at runtime — no rebuild or redeploy per run. Build locally withuv run pr dashboard(readsSUPABASE_URL/SUPABASE_ANON_KEYfrom.env), then publish withvercel deployfromdocs/.docs/index.htmlis gitignored precisely because the built file carries the public anon key; nevergit addit. - Live:
https://printrunner.vercel.app/
Required Actions secrets: ALPACA_API_KEY_ID, ALPACA_SECRET_KEY, ALPACA_BASE_URL (paper), FINNHUB_API_KEY, GROQ_API_KEY (and optionally AIML_API_KEY, OPENAI_COMPAT_*, SUPABASE_URL, SUPABASE_SERVICE_KEY, SUPABASE_ANON_KEY, DISCORD_WEBHOOK_URL). No extra secrets for the CLI — the workflow maps the same paper key/secret to ALPACA_API_KEY/ALPACA_SECRET_KEY, and the wrapper strips ALPACA_LIVE_TRADE so the CLI stays on its paper default (P7).
Alpaca infrastructure: Trading API via alpaca-py/REST (market data, chain, mleg orders) plus the official Alpaca CLI as an independent second channel — reconcile() cross-checks account/positions/clock through src/printrunner/execution/alpaca_cli.py (fail-open, journaled as CLI_CHECK, REST stays source of truth). uv run pr doctor probes the whole chain: paper guard, journal integrity, CLI account/positions.
- EM from the spanning straddle overestimates by including non-event variance.
- VRP is a realized-vs-implied proxy (no IV history on the free feed).
- Option marks come from the delayed Indicative feed on the free plan.
- The earnings calendar is third-party (Finnhub primary, yfinance fallback) — provenance and reschedule history are journaled.
- There is no options backtest in this repo: free historical chains for our window don't exist. Results are forward paper only. One contest week proves nothing about profitability. The claim is auditable process around a documented event edge.
Short legs are always covered by a same-expiry long (GCD ratios = 1, every short paired). No naked shorts are representable.
Operator waiver (Sep 3, 2026 — disclosed, not hidden)
During the contest the only in-window setup (ADBE → Sep 10) printed a regime the two coded families do not cover: EM-rich (MoveRatio 1.31) + negative 5d drift (−1.6%) + wide put spreads on the free indicative feed. The autonomous path correctly declined (LLM DECLINE_ALL, gates G6/G9/G10 fail — all journaled). With ~1.5 sessions left, the operator directed one entry through the waiver channel (src/printrunner/execution/waiver.py, pr override):
- ADBE 288/310C Sep-18 call debit vertical ×1, limit $7.59, max loss $759 —
client_order_id pr-38e18f9635fdfe0b, thesis hash80eabe8d8b71f5e4 - Waived: G9 + G10 only (edge thesis). G1–G8 all passed (fresh quotes, spreads 10–14% on both legs, budget, no market shock) — the safety floor cannot be waived in code.
- Same machinery as an autonomous entry: fresh re-quote, conviction-5 sizing, mleg builder, persist-before-submit, preregistered thesis with invalidation (
spot beyond 295.1 OR vrp<0 OR dte<=1), engine-managed exits afterwards. - Full trail in the journal:
OPERATOR_OVERRIDE→THESIS→ORDER_SUBMITTED, mirrored to Supabase and the dashboard.
Pure-logic units with no network (bars/quotes are synthetic):
uv run pytest -q # 14 tests: BS round-trip, metrics, desk, gates, journal chain, state latch, exits, LLM guard, sizing
uv run pytest -vtests/test_core.py covers: Black-Scholes round-trip & delta bounds, straddle IV, expiry selection, EM/MoveRatio, insufficient-history G1, vertical construction, stale/low-OI gates, deterministic client_order_id, signed limit convention, exit target evaluation, hallucination guard, journal tamper detection, halt latch.
config/universe.yaml # S&P 100 (hard-coded, P5)
src/printrunner/
config.py # Settings + RiskParams (single source of truth)
domain.py # pydantic contracts
util.py # NY-timezone helpers
journal/journal.py # hash-chained JSONL
state/state.py # SQLite: positions, calendar, halt, bans, equity
marketdata/{alpaca,bs,news}.py
calendar/service.py # Finnhub + yfinance, confirm_cycles
screener/{metrics,desk}.py # EM/MoveRatio/VRP + structure construction
risk/gates.py # G1-G10 + conviction sizing
execution/broker.py # mleg builder, deterministic id, fail-closed
positions/exits.py # exit plans + evaluation
reconcile/reconciler.py # broker↔local diff, latched HALT
llm/team.py # Groq→AIML→compat, JSON, cache, reviewer bans
orchestrator/cycle.py # 9-step pipeline
dashboard/build.py # static docs/index.html
cli.py # pr entry point
tests/test_core.py
.github/workflows/cycle.yml
docs/.nojekyll + docs/index.html (generated)
MIT — see LICENSE.