street-agent is one Rust binary with three isolated concerns: a reasoning layer (LLM), a risk engine (pure Rust), and an execution layer (paper-default, live-gated). Each is a module with a typed interface and no shared mutable state. The entire architecture is a single idea — the probabilistic layer cannot see past the deterministic one.
A single proposed trade passes through the entire system in this order. Any layer can abort. No layer can skip.
┌──────────────────────────────────┐
│ MARKET + PORTFOLIO CONTEXT │ Read-only snapshot: IV,
│ assembled every cycle │ price, positions, NLV, time
└─────────────┬────────────────────┘
│
▼
┌──────────────────────────────────┐
│ REASONING LAYER (LLM) │ Anthropic SDK, typed tools
│ proposes ≤ 1 trade per cycle │ No access to risk config
└─────────────┬────────────────────┘
│ ProposedOrder
▼
┌──────────────────────────────────┐
│ RISK ENGINE (Rust) │ Hardcoded + config gates
│ approves or rejects │ LLM cannot modify
└─────────────┬────────────────────┘
│ ApprovedOrder (or abort)
▼
┌──────────────────────────────────┐
│ EXECUTION LAYER │ Paper-default
│ routes to broker │ --live flag required
└─────────────┬────────────────────┘
│ Fill
▼
┌──────────────────────────────────┐
│ PERSISTENCE + PUBLIC LOG │ SQLite (WAL) + delayed feed
│ records + schedules publication │ public_at = filled_at + 30m
└──────────────────────────────────┘
The reasoning layer talks to the rest of the system through a typed tool registry. Each tool is a strongly-typed Rust function exposed to the LLM as an Anthropic tool schema. The LLM has no other way to reach the outside world — no shell, no filesystem, no network.
get_market_snapshot(underlying)get_iv_rank(underlying)get_price_history(underlying, window)get_option_chain(underlying, expiry)get_implied_vol(option)get_greeks(option)
price_black_scholes(params)solve_iv_newton(option, market)price_spread(legs)theoretical_pnl_grid(position)
construct_bull_put(underlying, expiry)construct_bear_call(underlying, expiry)construct_iron_condor(underlying, expiry)construct_calendar(underlying, strikes)evaluate_strategy(position)
get_portfolio_exposure()get_position_risk(position_id)preview_risk_impact(proposed)submit_order(order) → risk gateclose_position(position_id) → risk gatecancel_order(order_id)
log_reasoning(trace)log_rejection(reason)emit_status(snapshot)
grade_closed_position(position_id)write_lesson_to_ledger(lesson)
Every proposed order passes through a series of checks in a fixed order. Any rejection is final; the LLM receives the rejection reason as a tool result and must decide what to do next. The engine is a pure Rust module with no async, no external calls, and no dependency on the LLM or the broker.
// src/risk/engine.rs — simplified pub fn evaluate(order: &ProposedOrder, ctx: &Context) -> Result<ApprovedOrder, RejectionReason> { // 1. Strategy allowlist if !ctx.config.allowed_strategies.contains(&order.strategy) { return Err(RejectionReason::StrategyNotAllowed); } // 2. Position sizing let size_pct = order.max_loss / ctx.nlv; if size_pct > ctx.config.max_position_pct { return Err(RejectionReason::PositionTooLarge(size_pct)); } // 3. Concurrent positions if ctx.open_positions.len() >= ctx.config.max_concurrent { return Err(RejectionReason::TooManyPositions); } // 4. Per-underlying exposure let per_ul = ctx.exposure_for(order.underlying()); if per_ul + order.max_loss > ctx.config.max_per_underlying { return Err(RejectionReason::UnderlyingExposureExceeded); } // 5. Days-to-expiry window let dte = order.days_to_expiry(); if dte < ctx.config.min_dte || dte > ctx.config.max_dte { return Err(RejectionReason::DteOutOfRange); } // 6. Liquidity if order.underlying_adv() < ctx.config.min_adv { return Err(RejectionReason::LowLiquidity); } // 7. Bid/ask spread ceiling if order.spread_pct() > ctx.config.max_spread_pct { return Err(RejectionReason::WideSpread); } // 8. Circuit breakers if ctx.daily_drawdown_pct() > ctx.config.daily_halt { return Err(RejectionReason::DailyCircuitBreaker); } if ctx.weekly_drawdown_pct() > ctx.config.weekly_halt { return Err(RejectionReason::WeeklyCircuitBreaker); } Ok(ApprovedOrder::from(order)) }
The LLM cannot call evaluate directly. It calls submit_order, which is the only code path that can reach the broker, and that path runs evaluate internally. There's no tool the LLM can call that submits an order while bypassing the engine, because there's no code path in the binary that does that.
The agent prices options locally rather than trusting broker-reported greeks. A Black-Scholes implementation for European-style pricing handles the forward math; a Newton-Raphson solver handles the inverse problem of deriving implied volatility from observed market prices.
Newton-Raphson's convergence is fast when the initial guess is reasonable, but it has well-known failure modes on deep-ITM options and options near expiry where vega collapses. The implementation falls back to bisection when Newton fails to converge within 30 iterations, and returns an explicit SolverFailed error rather than a silently-wrong value when both methods fail. The downstream code treats IV solver failure as a rejection, not as "assume zero."
OptionStratLib (v0.15.2) is used as a strategy-construction library, not a pricing engine. The field-notes post covers the specific sharp edges — CoveredCall panicking when fees exceed premium, BullPutSpread panicking on max profit calculation, Collar and ProtectivePut missing write_html support. Every call into the library is wrapped in a panic-catching boundary and a fallback path, because a panic in the strategy builder must not take down the whole agent.
When a position closes — profit, loss, or expiration — the agent runs a retrospective pass. The LLM is given the original reasoning trace, the actual outcome, market context at entry and exit, and a structured evaluation prompt. It produces a grade (A through F), a list of lessons, and a boolean flag for whether the original thesis held.
The ledger of lessons is included in the context prompt for future similar setups. Whether this actually produces learning or just produces plausible-sounding text is itself a research question — one I'll write about once there's enough data to say something honest about it.
All state lives in a single SQLite database in WAL mode: positions, trades, reasoning traces, risk gate decisions, self-evaluations, the lesson ledger, the equity curve. No other database, no Redis, no object store. WAL mode handles the reader/writer concurrency for the dashboard's public API without blocking the trading loop.
The 30-minute delay is enforced at the data layer: each trade row has a public_at timestamp set to filled_at + INTERVAL 30 MINUTE, and the public API reads through a view that filters WHERE public_at <= NOW(). The dashboard literally cannot display a trade that hasn't crossed its public_at threshold — not because the frontend hides it, but because the query doesn't return it.
Each of these subsystems gets its own engineering post as the project matures.