KB / framework
Trading Operations (paper-tracked, real prices)
Last verified
The trading surface is the platform’s agent-action layer. Quotes, chains, and market status are public (no auth) so research-only agents can read freely; positions, orders, and history sit behind a Bearer token; account CRUD, snapshots, and the equity curve sit behind an admin Bearer token. Every order is paper-tracked — no real money moves — but every quote is a live Schwab read, so executions reflect real market depth and timing.
Auth model
Three tiers on this surface:
Trading auth tiers
Constant-time comparison. All bearer-token comparisons use secrets.compare_digest — never == / !=. The invariant is pinned in CLAUDE.md; any new secret comparison follows the same pattern.
How to obtain credentials. Tokens are issued out-of-band by the operator. Each agent gets its own token; running multiple agents behind one token shares the 120 req/min budget. The operator can rotate by hitting the admin endpoints.
Account model
The trading database (/app/data/trading.db, SQLite WAL) carries multiple accounts; the agent token resolves to exactly one account. Positions, pending trades, history, and snapshots are scoped per account — concurrent agents on different accounts cannot see each other’s books. The DB schema lives in app/trading/database.py; admin can create, reset, and delete accounts via the admin endpoints below.
The data path is real even though the money isn’t: quotes and chains come straight from Charles Schwab, fills execute at the locked quote price, the pending-order state machine guards against double-execution, daily snapshots capture closing balances, and the equity-curve route reconstructs portfolio value over time.
Market data (public, no auth)
| Endpoint | Purpose |
|---|---|
GET /api/v1/trading/market/status | Market open/closed, current ET time, minutes to open/close, session enum (PRE_MARKET / OPEN / POST_MARKET / CLOSED_WEEKEND / CLOSED_HOLIDAY / CLOSED_EARLY), human reason string. Server-authoritative; the legacy JS time-math is gone. → /kb/api/get-trading-market-status |
GET /api/v1/trading/quote/{symbol} | Real-time quote — last price, bid, ask, volume, daily change. |
GET /api/v1/trading/quotes?symbols=AAPL,MSFT | Batch quote — up to 10 symbols. |
GET /api/v1/trading/options/chain/{symbol} | Full option chain with Greeks, IV, bid/ask, OI, per-strike volume. |
GET /api/v1/trading/tickers | Curated S&P 500 + ETF reference list. Not exhaustive — any Schwab symbol is tradeable. |
GET /api/v1/trading/limits | Current per-account guardrail values (position cap, daily trades, option contracts). Call this before sizing rather than hardcoding. |
Per-route detail at /kb/api.
Order shapes
Stocks and options use different endpoints. Sending option fields to /trades will be rejected with an error directing you to /trades/options. Don’t try to route by symbol or quantity — route by endpoint.
Stocks — POST /api/v1/trading/trades
POST /api/v1/trading/trades
{"symbol": "AAPL", "side": "buy", "quantity": 5}
Returns {trade_id, locked_price, expires_at, ...}. The quote is locked for 60 seconds.
Options — POST /api/v1/trading/trades/options
POST /api/v1/trading/trades/options
{
"symbol": "SPY",
"option_type": "call",
"strike": 560.0,
"expiration": "2026-06-21",
"side": "buy",
"quantity": 1
}
Multiplier is 100 (one contract = 100 shares). Cost on the wire is mark_price × 100 × quantity. Same 60-second lock as stocks.
Confirm — POST /api/v1/trading/trades/{trade_id}/confirm
curl -X POST -H "Authorization: Bearer <token>" \
https://bigclawd.com/api/v1/trading/trades/<trade_id>/confirm
The confirm path is wrapped in BEGIN IMMEDIATE + UPDATE … SET status='executing' WHERE id=? AND status='pending'. Only the caller whose rowcount==1 proceeds with the actual fill; a concurrent confirm gets HTTP 400 with the current status. This is the single-execute claim — see _execute_trade in app/trading/routes.py.
Cancel — DELETE /api/v1/trading/trades/{trade_id}
Releases the reserved capital / position and marks the pending order cancelled. Only valid while status is pending.
Option validation errors
POST /trades/options validates contract details upfront; rejections return HTTP 400 with a structured detail dict:
expired_expiration— expiration in the past.non_trading_day— expiration is a weekend.no_market— bid and ask are both 0; no liquidity for this contract.contract_not_found— strike / expiration combo not in the Schwab chain (may be a holiday-shifted expiry or unlisted strike).
Parse the detail dict — don’t string-match the human message.
Guardrails
Hard-enforced server-side. GET /api/v1/trading/limits returns the live values; call it before sizing rather than hardcoding.
- Position limit — 25% per symbol. No symbol may exceed 25% of portfolio value (stocks + options combined, cost basis). Sells exempt.
- Daily trade limit — 50. Max 50 buys + sells (stocks + options combined) per account per ET day. Sells exempt.
- Option contract limit — 10. Max 10 buy-to-open option contracts per symbol per ET day.
- Daily loss limit + max position size. Operator-configurable per account; rejection returns HTTP 400 with the breach reason.
- Market-hours enforcement. Both initiation and confirmation require the market to be open. Check
/trading/market/statusfirst. Theapp/market_calendar.pymodule is the single source of truth (early-close half-days, US federal holidays including Good Friday, weekend roll-forward — all handled there). - Single-execute claim.
BEGIN IMMEDIATE+ status-conditional UPDATE on confirm. Concurrent confirms on the sametrade_idrace for the row; only one wins. See_execute_tradeinapp/trading/routes.py. - Fund + position reservation. Pending buy orders reserve capital from available balance; pending sell orders reserve the shares/contracts. You cannot double-sell the same position.
- Long only. Must own shares to sell. No short selling. No writing naked options.
- Whole shares only. No fractional shares.
Paper vs real
Positions, orders, P&L, history, and snapshots all live in the paper-tracked /app/data/trading.db. No order ever reaches a real brokerage. Prices are not paper — every quote and chain read comes from the live Schwab market data feed. The two-step plan-then-confirm flow models real broker latency; the single-execute claim models real-world race conditions on order entry. Use the platform to size, time, and validate trading strategy as if the money were real — the only thing that won’t move is the money itself.
Daily-close tasks
The admin surface owns end-of-day. Operators (or scheduled admin agents) call:
| Endpoint | Purpose |
|---|---|
POST /admin/trading/snapshots/run | Snapshot every account’s EOD balance + position values. |
GET /api/v1/trading/admin/accounts/{account_id}/snapshots | Historical daily snapshots for one account. |
GET /api/v1/trading/admin/accounts/{account_id}/equity-curve | Reconstructed portfolio value over time from snapshots. |
GET /api/v1/trading/admin/accounts/{account_id}/history?limit=N | Paginated trade history. |
GET /api/v1/trading/admin/accounts/{account_id}/recent-trades | Most recent N trades. |
GET /api/v1/trading/admin/events | Audit feed of account-level events (reset, deposit, withdrawal). |
→ per-endpoint detail at /kb/api.
Admin endpoints
Admin Bearer token required. Catalog:
GET /api/v1/trading/admin/accounts— list every account.POST /api/v1/trading/admin/accounts— create.PATCH /api/v1/trading/admin/accounts/{account_id}— update name / starting balance / limits.DELETE /api/v1/trading/admin/accounts/{account_id}— delete.POST /api/v1/trading/admin/accounts/{account_id}/reset— reset to starting balance, clear positions + pending + history.GET /api/v1/trading/admin/accounts/{account_id}/snapshots— daily snapshots.GET /api/v1/trading/admin/accounts/{account_id}/equity-curve— equity over time.GET /api/v1/trading/admin/accounts/{account_id}/history— full trade history (paginated).GET /api/v1/trading/admin/accounts/{account_id}/recent-trades— N most recent.GET /api/v1/trading/admin/events— admin event feed.
These never appear in the rate-limit budget — admin is unthrottled. They never appear in browser-facing pages either — admin tokens live in operator-side env, not in the Astro UI.
Options specifics
- Multiplier = 100. Every option contract is 100 underlying shares. Cost on entry =
mark_price × 100 × quantity. P&L on close uses the live mark for the contract, not the underlying. - Greeks + IV. The option chain returns delta, gamma, theta, vega, rho, IV per strike. Use these to size — a 1-delta short-dated call has very different risk than a 50-delta LEAP.
- Expiration date format. ISO 8601 date —
YYYY-MM-DD. Weekends rejected (non_trading_day). - ITM auto-exercise. ITM options auto-exercise at 4:15 PM ET on expiration day — you receive the intrinsic value as cash. OTM options expire worthless.
- Option overlay on
position-read.GET /api/v1/agents/position-read?symbol=SPY&side=long&type=call&expiry=2026-06-21&strike=560returns only the signal categories / alerts / regimes that materially affect that specific option position. Use to filter dashboard noise to your book.
MCP equivalents
| Tool | Wraps | Notes |
|---|---|---|
portfolio_status | /trading/account + /positions + /positions/options | One round-trip for cash, stock positions, and option positions. |
plan_trade | POST /trades or POST /trades/options | Dispatches by argument shape — stock if no option fields, option if option_type + strike + expiration present. |
execute_trade | POST /trades/{id}/confirm or DELETE /trades/{id} | action="confirm" or action="cancel". |
All three require the agent Bearer token configured in your MCP client. Per-tool detail at /kb/mcp.
Error semantics
- 60s expiration.
POST /trades/{id}/confirmafter the lock window returns HTTP 400 with the expired status. Re-plan — don’t try to “force” the originaltrade_id. - Status-locked confirms. A
trade_idalready moved toexecuting/executed/cancelledrejects with HTTP 400 and the current status. Common cause: a retry after a network blip — the first call may have succeeded. - Market closed.
POST /trades*and the confirm path return HTTP 400 withmarket_closedwhen outside 09:30–16:00 ET Mon–Fri. Check/trading/market/statusfirst; thesessionfield tells you why. - Limit breach. HTTP 400 with the breach reason (
position_cap_exceeded,daily_trade_limit,option_contract_limit,daily_loss_limit,max_position_size). Read the structured detail; don’t string-match. - Insufficient funds. HTTP 400. Reserved capital from other pending orders counts against the check — cancel a stale pending if you need the budget.
- Holiday / early close.
/trading/market/statusdistinguishesCLOSED_HOLIDAY(NYSE federal holidays + observed-on rolls, plus Good Friday) fromCLOSED_EARLY(day-after-Thanksgiving + Christmas Eve when weekday; 13:00 close). Trading is fully blocked onCLOSED_HOLIDAY; onCLOSED_EARLYthe morning session trades normally and the post-13:00 window blocks.
See also
/kb/framework/agents— programmatic-access hub (REST + MCP + SSE overview)./kb/api— per-route articles, including every trading endpoint./kb/mcp— per-tool articles, includingportfolio_status,plan_trade,execute_trade./kb/operations/schedule— market-hours posture + cycle profiles.- Implementation:
app/trading/routes.py(stocks + admin),app/trading/options_routes.py(options),app/trading/shared.py(timezone + market-hours helpers),app/market_calendar.py(single source of truth for session state).