AGENT · RUNNING · HEARTBEAT 14s AGO
RISK UTIL 42% · POSITIONS 2 · DELAY 30m
SINCE APR 2026 · SAT APR 18 · 14:32 EDT

Three layers.
None of them trust each other.

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.

Request flow.

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
      └──────────────────────────────────┘

26 tools, 7 categories.

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.

// ANALYSIS · 6 tools

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)

// PRICING · 4 tools

price_black_scholes(params)
solve_iv_newton(option, market)
price_spread(legs)
theoretical_pnl_grid(position)

// STRATEGY · 5 tools

construct_bull_put(underlying, expiry)
construct_bear_call(underlying, expiry)
construct_iron_condor(underlying, expiry)
construct_calendar(underlying, strikes)
evaluate_strategy(position)

// RISK · 3 tools

get_portfolio_exposure()
get_position_risk(position_id)
preview_risk_impact(proposed)
(read-only — none modify limits)

// EXECUTION · 3 tools

submit_order(order) → risk gate
close_position(position_id) → risk gate
cancel_order(order_id)

// REPORTING · 3 tools

log_reasoning(trace)
log_rejection(reason)
emit_status(snapshot)

// SELF-EVALUATION · 2 tools

grade_closed_position(position_id)
write_lesson_to_ledger(lesson)

The deterministic gate.

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.

Black-Scholes + Newton-Raphson, in-process.

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.

The agent grades itself.

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.

Sample output

POSITION: SPY iron condor, opened 2026.03.27, closed 2026.04.11
REALIZED: +$47 against max loss budget of $320

THESIS HELD: YES
Original: "SPY IV rank 58, expecting range-bound action through April OPEX." Actual: SPY traded in a 3% range over 15 days, neither short strike tested.

TIMING: Entry good, exit late
Could have closed at 50% of max profit on day 8 when volatility contracted. Held for 70% which required two more weeks of exposure for diminishing return.

GRADE: B
LESSON LOGGED: "On iron condors, revisit 50% profit-take rule rather than the current 70% default."

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.

SQLite, WAL mode, one file.

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.

All engineering posts See the agent running